fix(tui): close the scroll-demon class structurally (#1085 regression)
Issue #1085 ("TUI viewport drifts down inside alt-screen at end of turn, leaving top rows blank, esp. after sub-agents") was closed in v0.8.18 by adding `reset_terminal_viewport()` to home the cursor on TurnComplete / focus / resize. v0.8.27's flicker fix (`abf3fa66f`) dropped the `\x1b[2J\x1b[3J` deep-clear from that path to stop the double-clear flicker on Ghostty / VSCode / Win10 conhost. That left ratatui's incremental-diff renderer relying on its internal model matching reality — which only holds while nothing else writes to the terminal. Two latent `eprintln!` sites had been quietly emitting raw bytes into the alt-screen for the entire v0.8.x cycle: * `tools/subagent/mod.rs::persist_state_best_effort` (fires whenever the per-step sub-agent state save hits an error; under parallel sub-agents this can fire dozens of times per turn) * `tools/subagent/mod.rs::new_shared_subagent_manager` (fires once on init if the prior state file fails to load) Plus a third found during this fix: * `network_policy.rs::record` (fires every time a network-policy audit write fails) Each eprintln advanced the alt-screen cursor by one row and scrolled the buffer up by one row, but ratatui's renderer didn't know — it kept writing to absolute row positions, which now meant "one row higher than visible." After ~30 leaks the TUI content appeared to drift downward, with a blank band growing above the header. v0.8.18's periodic full-clear had been masking it; v0.8.27's flicker fix unmasked it. Three layers of defence so this class of bug "isn't an option anymore": 1. **`crates/tui/src/runtime_log.rs` — file-backed tracing subscriber + Unix fd-level stderr redirect.** A daily-rolling log file at `~/.deepseek/logs/tui-YYYY-MM-DD.log` is created at TUI startup (right after `EnterAlternateScreen`). A `tracing-subscriber` registry routes `tracing::warn!` / `tracing::error!` calls to it. On Unix, the process's stderr fd is `dup2`'d to the same file for the lifetime of the `TuiLogGuard`. Any future raw `eprintln!` — ours, a panic message, a third-party crate's verbose output — lands in the log file instead of the alt-screen. The guard restores the original stderr fd on drop so shutdown messages still reach the user's terminal. 2. **`tracing::warn!` replacements** for the three known leak sites (`subagent/mod.rs` ×2, `network_policy.rs` ×1). With (1) in place these messages now go to the log file with structured fields (`?err`, `host`, `tool`) instead of opaque text rows in the alt-screen. 3. **Module-level `#![deny(clippy::print_stdout, clippy::print_stderr)]`** on `tools/`, `core/`, `tui/`, `runtime_threads.rs`, and `network_policy.rs`. Any future `eprintln!` / `println!` added to a TUI runtime path fails the lint at compile time. Legitimate CLI-print paths (`main.rs` eval / init / doctor, `runtime_api.rs` server banners, `logging.rs` verbose helpers, `skills/mod.rs` listing utilities, `execpolicy/execpolicycheck.rs` JSON output, `ui::run_event_loop` post-`LeaveAlternateScreen` resume hint, two `#[test] #[ignore]` perf benches in `tui/transcript.rs` / `tui/widgets/mod.rs` / `core/capacity.rs`) keep their existing prints — they all run outside the alt-screen lifetime. The dup2 redirect is Unix-only because there's no equivalent stable Rust API for fd-redirecting `STDERR_FILENO` on Windows; on Windows the tracing-subscriber layer + the clippy denies still apply, and ratatui's own use of crossterm avoids the worst leakage classes. Cross-platform stderr redirect via `SetStdHandle` is a follow-up. The new `runtime_log` module ships with one test (`log_directory_prefers_home`) that pins the `HOME` / `USERPROFILE` / `dirs::home_dir()` resolution order — uses the process-wide `test_support::lock_test_env()` lock for env-mutation safety. Two `#[test] #[ignore]` benches in `tui/transcript.rs` (rail-prefix memory) and `tui/widgets/mod.rs` (transcript scroll bench) and one in `core/capacity.rs` (`bench_compute_profile`) keep their stdout prints via `#[allow(clippy::print_stdout)]` on the individual test. New dependencies: `tracing-subscriber 0.3` (env-filter + fmt features) and `tracing-appender 0.2` at the workspace root, both pulled into `crates/tui` only. Closes the v0.8.28 regression Hunter reported in screenshots: parallel sub-agents running `exec_shell` triggered the scroll demon with the TUI content squeezed into the bottom third of the terminal and ~30 rows of blank above the header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+108
-13
@@ -160,7 +160,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -813,7 +813,7 @@ version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -938,6 +938,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -1338,6 +1347,8 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.0",
|
||||
"uuid",
|
||||
@@ -1502,7 +1513,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1688,7 +1699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1803,7 +1814,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2579,7 +2590,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2941,6 +2952,15 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
@@ -3120,6 +3140,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -4125,7 +4154,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4138,7 +4167,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4195,7 +4224,7 @@ dependencies = [
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4620,6 +4649,15 @@ dependencies = [
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -4938,7 +4976,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5073,6 +5111,15 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
@@ -5355,6 +5402,18 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -5373,6 +5432,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5410,7 +5499,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset 0.9.1",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5528,6 +5617,12 @@ dependencies = [
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -5831,7 +5926,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -47,4 +47,6 @@ toml = "0.9.7"
|
||||
sha2 = "0.10"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
|
||||
@@ -55,6 +55,8 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3.16"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
wait-timeout = "0.2"
|
||||
multimap = "0.10.0"
|
||||
|
||||
@@ -772,6 +772,9 @@ mod tests {
|
||||
/// Establishes a baseline cost so we can detect regressions when the
|
||||
/// observation cadence is high (50+ message turns × per-step calls). Adds
|
||||
/// no dev-deps; we measure with `Instant` and print rather than gating CI.
|
||||
// Perf bench prints per-call timing — runs in `cargo test`, never
|
||||
// inside the TUI alt-screen.
|
||||
#[allow(clippy::print_stdout)]
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_compute_profile() {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
//! - `session`: Session state management
|
||||
//! - `turn`: Turn context and tracking
|
||||
|
||||
// Engine code runs inside the TUI alt-screen — see `runtime_log` for why
|
||||
// raw stdio prints must not appear here. Use `tracing::*` instead.
|
||||
#![deny(clippy::print_stdout)]
|
||||
#![deny(clippy::print_stderr)]
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod repl;
|
||||
mod retry_status;
|
||||
pub mod rlm;
|
||||
mod runtime_api;
|
||||
mod runtime_log;
|
||||
mod runtime_threads;
|
||||
mod sandbox;
|
||||
mod schema_migration;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// approval-modal hook that v0.7.x adds incrementally. Dead-code warnings
|
||||
// would otherwise be noisy until those call sites land.
|
||||
#![allow(dead_code)]
|
||||
// Audit-write failure must route through `tracing::*`, not raw stderr —
|
||||
// see `runtime_log` for the scroll-demon rationale.
|
||||
#![deny(clippy::print_stdout)]
|
||||
#![deny(clippy::print_stderr)]
|
||||
|
||||
//! Per-domain network policy for outbound network calls (#135).
|
||||
//!
|
||||
@@ -290,7 +294,11 @@ impl NetworkAuditor {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.try_record(host, tool, decision_label) {
|
||||
eprintln!("network audit write failed: {err}");
|
||||
// Routed through tracing so it lands in
|
||||
// `~/.deepseek/logs/tui-YYYY-MM-DD.log` rather than the
|
||||
// alt-screen — see `runtime_log` for the scroll-demon
|
||||
// rationale.
|
||||
tracing::warn!(target: "network_policy", ?err, host, tool, "network audit write failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -569,7 +569,10 @@ mod tests {
|
||||
// Explicit-user-override clause keeps the prompt useful for the
|
||||
// opposite preference (#1118 commenters who want English
|
||||
// thinking for token-cost reasons).
|
||||
for phrase in ["think in English", "\u{7528}\u{82F1}\u{6587}\u{601D}\u{8003}"] {
|
||||
for phrase in [
|
||||
"think in English",
|
||||
"\u{7528}\u{82F1}\u{6587}\u{601D}\u{8003}",
|
||||
] {
|
||||
assert!(
|
||||
lang.contains(phrase),
|
||||
"expected the user-override example `{phrase}`"
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! TUI runtime logging. Initializes a `tracing-subscriber` that writes to a
|
||||
//! daily-rolling file under `~/.deepseek/logs/`, and (on Unix) redirects the
|
||||
//! process's `stderr` fd to that same file for the lifetime of the alt-screen
|
||||
//! TUI.
|
||||
//!
|
||||
//! Why this exists:
|
||||
//!
|
||||
//! The TUI runs inside an alt-screen buffer drawn by `ratatui` using an
|
||||
//! incremental diff renderer. The renderer assumes nothing else is writing
|
||||
//! to the terminal — its internal "current cells" model is the only source
|
||||
//! of truth for what's on screen. If anything emits raw bytes to stdout or
|
||||
//! stderr while the alt-screen is active (an `eprintln!` from a sub-agent,
|
||||
//! a `tracing` warning that defaulted to `stderr`, a panic message, a
|
||||
//! third-party crate's verbose output, …) those bytes land in the alt-screen
|
||||
//! buffer at the current cursor position, scroll the buffer up, and leave
|
||||
//! the renderer's model out of sync with reality. The visible symptom is
|
||||
//! "scroll demon": the TUI content drifts down, leaving a band of blank
|
||||
//! rows above the header. This was the regression in issue #1085 (fixed in
|
||||
//! v0.8.18 by adding a viewport-reset path) and re-surfaced in v0.8.27
|
||||
//! when the flicker fix dropped the `\x1b[2J\x1b[3J` deep-clear that had
|
||||
//! been masking the underlying leak.
|
||||
//!
|
||||
//! Defence-in-depth:
|
||||
//! 1. A `tracing-subscriber` writes formatted logs to
|
||||
//! `~/.deepseek/logs/tui-YYYY-MM-DD.log` so `tracing::warn!` /
|
||||
//! `tracing::error!` calls go somewhere observable instead of
|
||||
//! disappearing into the void (the TUI previously had no global
|
||||
//! subscriber, so contributors reached for `eprintln!`).
|
||||
//! 2. On Unix the process's stderr fd is redirected (via `dup2`) to the
|
||||
//! same log file for the lifetime of `TuiLogGuard`. Any raw stderr
|
||||
//! write — ours, a dependency's, a panic message — lands in the log
|
||||
//! file instead of the alt-screen. The guard restores the original
|
||||
//! stderr fd on drop so post-TUI shutdown messages still reach the
|
||||
//! user's terminal.
|
||||
//! 3. Crate-level `#![deny(clippy::print_stderr, clippy::print_stdout)]`
|
||||
//! on the TUI runtime modules forbids new `eprintln!` / `println!`
|
||||
//! calls at compile time. CLI-output paths (`main.rs` eval, init,
|
||||
//! `runtime_api::print_*`, `logging::info`/`warn`) keep their existing
|
||||
//! prints via `#[allow(clippy::print_stderr)]` because they run before
|
||||
//! the alt-screen is entered.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
/// Owns the active tracing subscriber and (on Unix) a saved copy of the
|
||||
/// original `stderr` fd so it can be restored on drop. Dropped when the TUI
|
||||
/// exits the alt-screen.
|
||||
pub struct TuiLogGuard {
|
||||
#[cfg(unix)]
|
||||
saved_stderr_fd: Option<libc::c_int>,
|
||||
_file: File,
|
||||
// Exposed via `log_path()` for diagnostics (e.g. `/doctor`,
|
||||
// `--print-log-path`). Currently no caller — keep the accessor
|
||||
// wired up so adding one later doesn't require revisiting the
|
||||
// guard struct.
|
||||
#[allow(dead_code)]
|
||||
log_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TuiLogGuard {
|
||||
/// Path the subscriber is writing to.
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn log_path(&self) -> &std::path::Path {
|
||||
&self.log_path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for TuiLogGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(saved) = self.saved_stderr_fd.take() {
|
||||
// SAFETY: `saved` came from `libc::dup` of the original stderr
|
||||
// fd in `init`; calling `dup2` to restore it is the standard
|
||||
// pairing. If `dup2` fails we just leak the saved fd — the
|
||||
// process is exiting anyway.
|
||||
unsafe {
|
||||
let _ = libc::dup2(saved, libc::STDERR_FILENO);
|
||||
let _ = libc::close(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
impl Drop for TuiLogGuard {
|
||||
fn drop(&mut self) {}
|
||||
}
|
||||
|
||||
/// Initialize the TUI logging subsystem. Idempotent across re-entry by way
|
||||
/// of `set_default` — if a global subscriber is already set we still install
|
||||
/// the stderr redirect.
|
||||
///
|
||||
/// Returns a guard that must outlive the alt-screen session. Drop it after
|
||||
/// `LeaveAlternateScreen` so any shutdown messages reach the user.
|
||||
pub fn init() -> Result<TuiLogGuard> {
|
||||
let log_dir = log_directory().context("could not resolve TUI log directory")?;
|
||||
fs::create_dir_all(&log_dir)
|
||||
.with_context(|| format!("failed to create {}", log_dir.display()))?;
|
||||
|
||||
let date = chrono::Local::now().format("%Y-%m-%d");
|
||||
let log_path = log_dir.join(format!("tui-{date}.log"));
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.with_context(|| format!("failed to open {}", log_path.display()))?;
|
||||
|
||||
// The tracing-subscriber consumes a clone of the file handle for its
|
||||
// writer. We keep our own handle for the dup2 redirect below — we need
|
||||
// the same on-disk file but a separate fd so the subscriber's writes
|
||||
// and the raw-stderr writes don't fight over the same kernel offset.
|
||||
let subscriber_file = file
|
||||
.try_clone()
|
||||
.context("failed to clone log file handle for subscriber")?;
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new("info"))
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
let subscriber = tracing_subscriber::registry().with(env_filter).with(
|
||||
fmt::layer()
|
||||
.with_writer(move || {
|
||||
subscriber_file
|
||||
.try_clone()
|
||||
.expect("clone log file handle for tracing writer")
|
||||
})
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false),
|
||||
);
|
||||
|
||||
// Best-effort: if a subscriber is already set (e.g., re-entry, or a
|
||||
// host process installed one), we skip ours rather than panic. The
|
||||
// stderr redirect below still happens.
|
||||
let _ = tracing::subscriber::set_global_default(subscriber);
|
||||
|
||||
#[cfg(unix)]
|
||||
let saved_stderr_fd = redirect_stderr_to(&file).ok();
|
||||
|
||||
Ok(TuiLogGuard {
|
||||
#[cfg(unix)]
|
||||
saved_stderr_fd,
|
||||
_file: file,
|
||||
log_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn log_directory() -> Option<PathBuf> {
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
|
||||
&& !home.as_os_str().is_empty()
|
||||
{
|
||||
return Some(home.join(".deepseek").join("logs"));
|
||||
}
|
||||
if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from)
|
||||
&& !userprofile.as_os_str().is_empty()
|
||||
{
|
||||
return Some(userprofile.join(".deepseek").join("logs"));
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".deepseek").join("logs"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn redirect_stderr_to(file: &File) -> Result<libc::c_int> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let target = file.as_raw_fd();
|
||||
// SAFETY: `libc::dup` and `libc::dup2` are the documented fd-management
|
||||
// primitives. We save the current stderr fd before reassigning so the
|
||||
// guard can restore it on drop.
|
||||
unsafe {
|
||||
let saved = libc::dup(libc::STDERR_FILENO);
|
||||
if saved < 0 {
|
||||
return Err(
|
||||
anyhow::Error::from(std::io::Error::last_os_error()).context("dup(STDERR_FILENO)")
|
||||
);
|
||||
}
|
||||
if libc::dup2(target, libc::STDERR_FILENO) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
let _ = libc::close(saved);
|
||||
return Err(anyhow::Error::from(err).context("dup2(log_file, STDERR_FILENO)"));
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn log_directory_prefers_home() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let prev_home = std::env::var_os("HOME");
|
||||
let prev_userprofile = std::env::var_os("USERPROFILE");
|
||||
// SAFETY: serialised by lock_test_env.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.path());
|
||||
std::env::set_var("USERPROFILE", "");
|
||||
}
|
||||
|
||||
let resolved = log_directory().expect("log_directory should resolve");
|
||||
assert_eq!(resolved, tmp.path().join(".deepseek").join("logs"));
|
||||
|
||||
// SAFETY: cleanup under the same lock.
|
||||
unsafe {
|
||||
match prev_home {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match prev_userprofile {
|
||||
Some(v) => std::env::set_var("USERPROFILE", v),
|
||||
None => std::env::remove_var("USERPROFILE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
//! This module keeps DeepSeek-only execution while exposing Codex-like lifecycle
|
||||
//! semantics (threads, turns, items, interrupt/steer, and replayable events).
|
||||
|
||||
// Background-task runtime — runs alongside the TUI. Raw stdio prints
|
||||
// here would still land in the alt-screen on whichever terminal the
|
||||
// foreground TUI happens to own. Route everything through `tracing::*`
|
||||
// instead — see `runtime_log` for the rationale.
|
||||
#![deny(clippy::print_stdout)]
|
||||
#![deny(clippy::print_stderr)]
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
//! Tool system modules and re-exports.
|
||||
|
||||
// Tools run inside the TUI alt-screen runtime. Raw `print!` / `eprintln!`
|
||||
// inside this module tree leaks into ratatui's diff-renderer buffer and
|
||||
// produces the "scroll demon" regression (#1085 / v0.8.27 follow-up).
|
||||
// Route status/error reporting through `tracing::*` instead — the
|
||||
// `runtime_log` subscriber captures it to `~/.deepseek/logs/`.
|
||||
#![deny(clippy::print_stdout)]
|
||||
#![deny(clippy::print_stderr)]
|
||||
|
||||
pub mod apply_patch;
|
||||
pub mod approval_cache;
|
||||
pub mod arg_repair;
|
||||
|
||||
@@ -956,7 +956,11 @@ impl SubAgentManager {
|
||||
|
||||
fn persist_state_best_effort(&self) {
|
||||
if let Err(err) = self.persist_state() {
|
||||
eprintln!("Failed to persist sub-agent state: {err}");
|
||||
// Must not be `eprintln!` — raw stderr inside the alt-screen
|
||||
// leaks into the buffer and produces the scroll-demon
|
||||
// regression (#1085). Routed through tracing so the
|
||||
// file-backed subscriber in `runtime_log` captures it.
|
||||
tracing::warn!(target: "subagent", ?err, "failed to persist sub-agent state");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,7 +1543,9 @@ pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> Sha
|
||||
let state_path = default_state_path(&workspace);
|
||||
let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path);
|
||||
if let Err(err) = manager.load_state() {
|
||||
eprintln!("Failed to load sub-agent state: {err}");
|
||||
// Routed through tracing instead of stderr — see comment in
|
||||
// `persist_state_best_effort` above.
|
||||
tracing::warn!(target: "subagent", ?err, "failed to load sub-agent state");
|
||||
}
|
||||
Arc::new(RwLock::new(manager))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
//! Terminal UI (TUI) module for `DeepSeek` CLI.
|
||||
|
||||
// The rendering layer runs inside the alt-screen. Raw stdio prints
|
||||
// produce the scroll demon (see `runtime_log` for full context). Use
|
||||
// `tracing::*` for diagnostics — `runtime_log` captures it to disk.
|
||||
// `ui::run_event_loop` legitimately prints a post-exit resume hint
|
||||
// AFTER `LeaveAlternateScreen`; that single site uses
|
||||
// `#[allow(clippy::print_stdout)]` locally.
|
||||
#![deny(clippy::print_stdout)]
|
||||
#![deny(clippy::print_stderr)]
|
||||
|
||||
// === Submodules ===
|
||||
|
||||
pub mod active_cell;
|
||||
|
||||
@@ -916,6 +916,9 @@ mod tests {
|
||||
/// tool headers with multiple decorative spans) and report the memory
|
||||
/// consumed by `rail_prefix_widths`. This is informational — the assertion
|
||||
/// only fails if the per-line overhead exceeds a generous bound.
|
||||
// Test prints memory-overhead diagnostics — runs in `cargo test`, never
|
||||
// inside the TUI alt-screen, so the module-level deny doesn't apply.
|
||||
#[allow(clippy::print_stderr)]
|
||||
#[test]
|
||||
fn rail_prefix_widths_memory_overhead_complex_session() {
|
||||
let mut cells: Vec<HistoryCell> = Vec::new();
|
||||
|
||||
@@ -226,6 +226,24 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
if use_alt_screen {
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
}
|
||||
// Initialize the file-backed TUI log and (on Unix) redirect raw stderr
|
||||
// away from the alt-screen for the lifetime of this guard. Any
|
||||
// `eprintln!`, panic message, or third-party stderr write that would
|
||||
// otherwise leak into the alt-screen buffer and shift ratatui's
|
||||
// diff-renderer view (the "scroll demon" reported in #1085) now lands
|
||||
// in `~/.deepseek/logs/tui-YYYY-MM-DD.log` instead. The guard is held
|
||||
// until the function returns; dropping it (after `LeaveAlternateScreen`
|
||||
// below) restores the original stderr fd so shutdown messages reach
|
||||
// the user's terminal. We accept the init failing (e.g., read-only
|
||||
// `$HOME`) and continue without the redirect rather than refusing to
|
||||
// start the TUI.
|
||||
let _tui_log_guard = match crate::runtime_log::init() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(err) => {
|
||||
tracing::warn!(target: "runtime_log", ?err, "TUI log init failed; stderr leaks may render as scroll-demon");
|
||||
None
|
||||
}
|
||||
};
|
||||
// Mouse capture, bracketed paste, focus events, and the Kitty
|
||||
// keyboard-protocol escape-disambiguation flag (#442). Single source
|
||||
// of truth shared with the FocusGained recovery path and
|
||||
@@ -447,7 +465,15 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
if result.is_ok()
|
||||
&& let Some(hint) = format_resume_hint(app.current_session_id.as_deref())
|
||||
{
|
||||
println!("{hint}");
|
||||
// Printed AFTER `LeaveAlternateScreen` / `drop(terminal)` above,
|
||||
// so we're back on the primary screen — this is the one
|
||||
// legitimate stdout write in the TUI module tree. The
|
||||
// module-level `#![deny(clippy::print_stdout)]` would otherwise
|
||||
// refuse it.
|
||||
#[allow(clippy::print_stdout)]
|
||||
{
|
||||
println!("{hint}");
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -3104,6 +3104,9 @@ mod tests {
|
||||
///
|
||||
/// Run with: `cargo test -p deepseek-tui --release bench_transcript_scroll
|
||||
/// -- --ignored --nocapture`
|
||||
// Perf bench prints timing rows to stdout — runs in `cargo test`,
|
||||
// never inside the TUI alt-screen.
|
||||
#[allow(clippy::print_stdout)]
|
||||
#[test]
|
||||
#[ignore = "perf bench; run with --release"]
|
||||
fn bench_transcript_scroll_5000_messages() {
|
||||
|
||||
Reference in New Issue
Block a user