From 3b424c58758310a999bcb0e5423d847c0a8b5c27 Mon Sep 17 00:00:00 2001 From: Damir Vazgird Date: Tue, 6 Oct 2026 14:41:13 -0700 Subject: [PATCH] chore: P3-38 investigate V23, guard the unread-master exit, drop the known issue A forced exit with the PTY master open and unread shows macOS state E for about 0.6 s and completes, the same as with a drained master; tcflush before the forced exit changed nothing. Case (v) of check-orphan-exit now guards that the reader exits with the master open and unread. The development guide's known-issues list is empty. Co-Authored-By: Claude Sonnet 5.5 --- crates/wiki-reader-tools/Cargo.toml | 2 +- .../src/bin/check-orphan-exit/cases.rs | 92 +++++++++++++++++-- .../src/bin/check-orphan-exit/main.rs | 7 +- .../src/bin/check-orphan-exit/proc.rs | 5 + .../src/bin/check-orphan-exit/pty.rs | 14 +++ wiki/guides/development.md | 13 ++- wiki/roadmap/audit-v0.1.8.md | 2 +- wiki/roadmap/dogfood-log.md | 1 + wiki/roadmap/phase-3-alpha.md | 2 +- 9 files changed, 117 insertions(+), 21 deletions(-) diff --git a/crates/wiki-reader-tools/Cargo.toml b/crates/wiki-reader-tools/Cargo.toml index 36cbefe..5b5e65a 100644 --- a/crates/wiki-reader-tools/Cargo.toml +++ b/crates/wiki-reader-tools/Cargo.toml @@ -17,7 +17,7 @@ path = "src/bin/check-orphan-exit/main.rs" [dependencies] pulldown-cmark = { workspace = true } -rustix = { workspace = true, features = ["pty"] } +rustix = { workspace = true, features = ["pty", "termios"] } thiserror = { workspace = true } wiki-reader-core = { workspace = true } diff --git a/crates/wiki-reader-tools/src/bin/check-orphan-exit/cases.rs b/crates/wiki-reader-tools/src/bin/check-orphan-exit/cases.rs index 423d2bb..cc0542a 100644 --- a/crates/wiki-reader-tools/src/bin/check-orphan-exit/cases.rs +++ b/crates/wiki-reader-tools/src/bin/check-orphan-exit/cases.rs @@ -13,8 +13,8 @@ use std::{ use rustix::process::Signal; use crate::{ - proc::{alive, cpu_seconds, ppid_of, send, wait_gone}, - pty::{Pty, drain, open_pty}, + proc::{alive, cpu_seconds, ppid_of, send, state, wait_gone}, + pty::{Pty, drain, open_pty, set_winsize}, }; pub type Outcome = Result; @@ -114,6 +114,8 @@ struct Session { leader: Guard, app_pid: u32, pidfile: PathBuf, + /// The master end when nothing drains it (V23); dropped after the reader is killed. + master: Option, } impl Drop for Session { @@ -142,20 +144,36 @@ fn wait_for_pid(pidfile: &Path) -> Result { /// Start the reader on a PTY inside a fresh session. /// -/// `launcher == false`: the session leader is also the reader's parent. -/// `launcher == true`: the leader stays alive and idle; a launcher child it spawned is the +/// `opts.launcher == false`: the session leader is also the reader's parent. +/// `opts.launcher == true`: the leader stays alive and idle; a launcher child it spawned is the /// reader's parent and is the one that can exit. -fn start_in_session(cfg: &Config, watchdog_off: bool, launcher: bool) -> Result { +#[derive(Clone, Copy, Default)] +struct Opts { + /// Set `WIKI_READER_NO_WATCHDOG=1` for the reader. + watchdog_off: bool, + /// Insert a launcher between the session leader and the reader. + launcher: bool, + /// Keep the master open but never read it, on a large screen (V23). + unread_master: bool, +} + +fn start_in_session(cfg: &Config, opts: Opts) -> Result { let Pty { master, slave } = setup(open_pty())?; - drain(master); + let master = if opts.unread_master { + setup(set_winsize(&slave, 120, 400))?; + Some(master) + } else { + drain(master); + None + }; let n = NEXT_PIDFILE.fetch_add(1, Ordering::Relaxed); let pidfile = env::temp_dir().join(format!( "wiki-reader-orphan-exit-{}-{n}.pid", std::process::id() )); - let mut cmd = helper(if launcher { "launcher" } else { "app" }, cfg)?; + let mut cmd = helper(if opts.launcher { "launcher" } else { "app" }, cfg)?; cmd.arg(&pidfile); - if watchdog_off { + if opts.watchdog_off { cmd.env("WIKI_READER_NO_WATCHDOG", "1"); } let leader = spawn_on(&slave, cmd)?; @@ -164,6 +182,7 @@ fn start_in_session(cfg: &Config, watchdog_off: bool, launcher: bool) -> Result< leader, app_pid: 0, pidfile, + master, }; session.app_pid = wait_for_pid(&session.pidfile)?; thread::sleep(Duration::from_millis(1500)); @@ -173,7 +192,13 @@ fn start_in_session(cfg: &Config, watchdog_off: bool, launcher: bool) -> Result< /// (ii) / (ii'): the session leader (also the parent) dies. The reader spun at ~100 %CPU before /// V22; with the watchdog disabled it must stay up. pub fn leader_killed(cfg: &Config, watchdog_off: bool) -> Outcome { - let mut session = start_in_session(cfg, watchdog_off, false)?; + let mut session = start_in_session( + cfg, + Opts { + watchdog_off, + ..Opts::default() + }, + )?; let app = session.app_pid; if !alive(app) { return Err(format!( @@ -204,7 +229,13 @@ pub fn leader_killed(cfg: &Config, watchdog_off: bool) -> Outcome { /// (ii''): only the launcher (the reader's parent) exits; the session leader and the terminal /// stay alive. The reader must keep running and sit idle: it is a legitimate session. pub fn launcher_exits(cfg: &Config) -> Outcome { - let session = start_in_session(cfg, false, true)?; + let session = start_in_session( + cfg, + Opts { + launcher: true, + ..Opts::default() + }, + )?; let app = session.app_pid; let launcher = ppid_of(app).filter(|&p| p != session.leader.id()); let Some(launcher) = launcher.filter(|_| alive(app)) else { @@ -279,3 +310,44 @@ pub fn not_a_tty(cfg: &Config) -> Outcome { )), } } + +/// (v) V23: the terminal keeps the master open but never reads it, on a screen large enough to +/// fill the PTY buffer, and the session leader dies, so the watchdog hard-exits the reader while +/// its main thread may be blocked in a `write`. The reader must still exit with the master open. +/// (An early test harness saw macOS state `E` here and blamed an undrained close; the exit takes +/// the same ~0.6 s with a drained master, so this case guards the property, not a delay.) +pub fn unread_master_leader_killed(cfg: &Config) -> Outcome { + let mut session = start_in_session( + cfg, + Opts { + unread_master: true, + ..Opts::default() + }, + )?; + let app = session.app_pid; + if !alive(app) { + return Err(format!( + "pid {app} was not running before the leader was killed" + )); + } + // A few redraws on the large screen, none of them read. + if let Some(master) = &session.master { + for _ in 0..10 { + setup(rustix::io::write(master, b"\x1b[6~"))?; + thread::sleep(Duration::from_millis(20)); + } + } + thread::sleep(Duration::from_millis(1500)); + setup(send(session.leader.id(), Signal::KILL))?; + session.leader.wait(LIMIT); + // Watchdog: polls every 1 s and hard-exits after a 2 s grace. + if wait_gone(app, Duration::from_secs(8)) { + return Ok(format!( + "pid {app} exited although nothing reads the master" + )); + } + let stuck = state(app).unwrap_or_else(|| "?".into()); + Err(format!( + "pid {app} still running (state {stuck}) with the master open and unread" + )) +} diff --git a/crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs b/crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs index 747d502..01886ca 100644 --- a/crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs +++ b/crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs @@ -12,7 +12,8 @@ //! (ii'') only the launcher (its parent) exits while the session leader lives: it must keep //! running and idle, //! (iii) stdio is not a terminal at all (non-TTY guard), and -//! (iv) SIGHUP, SIGTERM and SIGINT (registered before raw mode) end the session cleanly. +//! (iv) SIGHUP, SIGTERM and SIGINT (registered before raw mode) end the session cleanly, and +//! (v) the master stays open but is never read (V23) while the session leader dies. //! Exits 0 when every case passes, 1 when one fails, 2 on a setup error. Unix only (CI: ubuntu //! and macOS). @@ -84,6 +85,10 @@ fn main() -> std::process::ExitCode { "(iv'') SIGINT ends the session", Box::new(|| cases::signal(&cfg, Signal::INT, "SIGINT")), ), + ( + "(v) V23: unread master, leader killed: still exits", + Box::new(|| cases::unread_master_leader_killed(&cfg)), + ), ("(iii) not a tty", Box::new(|| cases::not_a_tty(&cfg))), ]; diff --git a/crates/wiki-reader-tools/src/bin/check-orphan-exit/proc.rs b/crates/wiki-reader-tools/src/bin/check-orphan-exit/proc.rs index d32cd83..d3cf879 100644 --- a/crates/wiki-reader-tools/src/bin/check-orphan-exit/proc.rs +++ b/crates/wiki-reader-tools/src/bin/check-orphan-exit/proc.rs @@ -36,6 +36,11 @@ pub fn wait_gone(pid: u32, limit: Duration) -> bool { false } +/// The `ps` state of `pid` (for example `S`, `R+`, `Z`, or `E` for a macOS process stuck exiting). +pub fn state(pid: u32) -> Option { + ps_field("stat", pid) +} + pub fn ppid_of(pid: u32) -> Option { ps_field("ppid", pid)?.parse().ok() } diff --git a/crates/wiki-reader-tools/src/bin/check-orphan-exit/pty.rs b/crates/wiki-reader-tools/src/bin/check-orphan-exit/pty.rs index 41c6661..c7a399a 100644 --- a/crates/wiki-reader-tools/src/bin/check-orphan-exit/pty.rs +++ b/crates/wiki-reader-tools/src/bin/check-orphan-exit/pty.rs @@ -11,6 +11,7 @@ use rustix::{ fs::{Mode, OFlags, open}, io::{FdFlags, fcntl_setfd}, pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}, + termios::{Winsize, tcsetwinsize}, }; /// Both ends of a fresh pseudo-terminal. @@ -37,6 +38,19 @@ pub fn open_pty() -> io::Result { Ok(Pty { master, slave }) } +/// Resize the terminal; a large screen makes every frame big enough to fill the PTY buffer. +pub fn set_winsize(slave: &OwnedFd, rows: u16, cols: u16) -> io::Result<()> { + Ok(tcsetwinsize( + slave, + Winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }, + )?) +} + /// Read and discard everything on `master`, like a live terminal emulator would. /// /// Without a reader the PTY buffer fills, the app blocks writing, and the "parent killed" cases diff --git a/wiki/guides/development.md b/wiki/guides/development.md index d4c2cfc..35580b2 100644 --- a/wiki/guides/development.md +++ b/wiki/guides/development.md @@ -86,7 +86,7 @@ process (SIGABRT) because `eprintln!` panics on a dead terminal. Now non-TTY std immediately, a watchdog exits when the session leader is gone or stdin reports POLLHUP/ERR, and error output never panics. The `check-orphan-exit` tool ([`crates/wiki-reader-tools/src/bin/check-orphan-exit/`](../../crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs)) runs the cases on a PTY (master closed with the slave held, master closed during startup, session leader killed, -the same with `WIKI_READER_NO_WATCHDOG=1`, only the launcher exiting, no TTY) and fails unless the binary exits by itself with an exit code. `scripts/check.sh` and CI +the same with `WIKI_READER_NO_WATCHDOG=1`, only the launcher exiting, an open but unread master, no TTY) and fails unless the binary exits by itself with an exit code. `scripts/check.sh` and CI (ubuntu and macOS) run it: ```bash @@ -96,12 +96,11 @@ cargo run --locked --quiet -p wiki-reader-tools --bin check-orphan-exit -- targe ## Known issues -- **A reader can linger in macOS state `E` when its PTY master is never read (V23, P3-38).** If the - terminal side keeps the master open but stops reading, the exiting process can stay in the - uninterruptible `E` state (`(wiki-reader)` in `ps`) and ignores SIGKILL until the master is - closed or read. A real terminal emulator always reads, so this has only shown up in test - harnesses; `check-orphan-exit` drains the master for that reason. Cause not confirmed, - fix not attempted: see [the register](../roadmap/audit-v0.1.8.md). +None open. A reader seen twice in macOS state `E` with a PTY master that was never read (V23, +P3-38) could not be reproduced as a hang: a forced exit shows `E` for about 0.6 s with or without +a reader on the master, and `check-orphan-exit` now guards that a reader exits with the master +open and unread. If it recurs, capture `ps -o pid,ppid,stat,wchan` for the process and say which +process holds the other end of the terminal; see [the register](../roadmap/audit-v0.1.8.md). ## Crate boundaries diff --git a/wiki/roadmap/audit-v0.1.8.md b/wiki/roadmap/audit-v0.1.8.md index 7fa5c75..1d8ff68 100644 --- a/wiki/roadmap/audit-v0.1.8.md +++ b/wiki/roadmap/audit-v0.1.8.md @@ -27,7 +27,7 @@ No `unwrap`/`expect` problems were found in the audited non-test code, no trunca |---|---|---|---|---| | V4 | `render.rs` wrap helpers; `diagrams.rs` fallback | Quadratic wrapping: each cut copies the remainder. A multi-MB token or data URI stalls open and each resize. | Before: open 1 MB **26.3 s**, 400 KB resize **8.7 s**. After linear wrap: open 1 MB **8.2 ms**, 400 KB resize **6.0 ms** (two release runs; re-confirmed on `58fe76b`). | fixed (P3-34) | | V22 | `events.rs` `run_loop`; crossterm `event::poll` | Orphaned processes burn ~100 %CPU indefinitely. Live sample (v0.1.8): main thread stuck in `poll` → `try_read` → `read` with a still-open slave TTY and no interactive shell. Not V17 hygiene. Fix: `IsTerminal` guard, early signals, session-leader watchdog (`kill(sid, 0)` via rustix), stdin POLLHUP/ERR poll via rustix (covers live-parent + closed master). Forced exit skips Drop and terminal restore (hung-up PTY writes can block forever; herdr TTL / no session flush). | Dogfood 2026-10-05: four spinners ~45 min. PTY matrix on release binary (2026-10-06): (i) parent alive + master closed + slave held → exit; (ii) parent killed → exit; (iii) `/dev/null` → exit; (iv) HUP/TERM/INT → exit. On main before fix, (i) spun at ~100 %CPU. Follow-up check `scripts/check-orphan-exit` (CI, ubuntu + macOS) also found a startup race: closing the PTY master during startup aborted the process (SIGABRT, macOS crash report) because `eprintln!` panics on a dead stderr; `main` and the background-panic report now use `eprint_line` (write errors ignored). Root cause pinned on macOS (v0.1.10 work): the spin follows the death of the **session leader**, with the PTY master still open and drained; killing only the parent while the leader lives leaves the reader idle (0.2 % CPU), and a stdin hang-up poll does not see the leader case. The first V22 fix keyed on the parent pid, which was both too broad (a launcher exiting would end a healthy session) and indirect. `WIKI_READER_NO_WATCHDOG=1` skips the leader check. | fixed (P3-37, v0.1.10) | -| V23 | process exit on macOS; `events.rs` forced exit, `check-orphan-exit` | **Known issue, investigate later.** A `wiki-reader` that is exiting while its PTY master stays open but is never read can stay in macOS state `E` (shown as `(wiki-reader)` in `ps`) and ignores SIGKILL until the master is closed. Hypothesis: closing the slave waits for pending output to drain, and nothing drains it. Seen twice in tests, both with a test harness that never read the master (an early "parent killed" case and a run against v0.1.9); not seen with a terminal emulator, which always reads. Impact is a process lingering while a frozen terminal holds the other end. Ideas to try: flush pending output (`tcflush` through `rustix::termios`) before the forced exit, and check whether a clean exit is affected the same way. | Reproduced only with an unread master; the harness now drains it. No numbers. | staged (P3-38) | +| V23 | process exit on macOS; `events.rs` forced exit, `check-orphan-exit` | **Not reproducible (see Measured).** A `wiki-reader` that is exiting while its PTY master stays open but is never read can stay in macOS state `E` (shown as `(wiki-reader)` in `ps`) and ignores SIGKILL until the master is closed. Hypothesis: closing the slave waits for pending output to drain, and nothing drains it. Seen twice in tests, both with a test harness that never read the master (an early "parent killed" case and a run against v0.1.9); not seen with a terminal emulator, which always reads. Impact is a process lingering while a frozen terminal holds the other end. Ideas to try: flush pending output (`tcflush` through `rustix::termios`) before the forced exit, and check whether a clean exit is affected the same way. | Investigated 2026-10-06 (P3-38, macOS, v0.1.10 debug build, PTY with the master open and never read): `q` and SIGTERM exit within 1 s. A forced exit (session leader killed, watchdog `process::exit`) on 24×80, 120×400, 250×800 and 400×1200 screens shows `?E` for about 0.6 s and then the process is gone, with the master still unread and open. The same 0.6 s window appears with a drained master, so it is the ordinary exit and not a wait for a drain; `tcflush(OFlush)` before the forced exit changed nothing and was not shipped. The two original incidents are not reproduced and stay unexplained. | dropped (P3-38): not reproducible; the unread-master exit is now guarded by case (v) of `check-orphan-exit` | ## Medium diff --git a/wiki/roadmap/dogfood-log.md b/wiki/roadmap/dogfood-log.md index 10d0f18..851b0bf 100644 --- a/wiki/roadmap/dogfood-log.md +++ b/wiki/roadmap/dogfood-log.md @@ -234,6 +234,7 @@ Phase 2 was closed early on 2026-10-06 (clock started 2026-09-29), so dogfooding | 2026-10-06 | decision | P3-10 herdr checks done by the operator on herdr 0.9.0 with v0.1.10, using `herdr pane get` in a second pane. (1) Title: shown as the pane's border label; token: `tokens.page` (path relative to the launch root) is visible through `pane get`; both follow page changes; the workspace sidebar was not checked separately. (2) `[herdr] publish = false` via `--config`: `title` and `tokens` stay null through several page changes; publishing resumes without the flag. (3) Agent pane (fake agent registered with `report-agent`, released afterwards): `agent` stayed `claude` and `agent_status` `idle` the whole time the reader published, and both fields were intact after quit with title and token cleared. (4) Unclean kill (`kill -9`, reader started from a shell so the pane survived): title and token stayed for the next 10 polls (12:13:33 to 12:22:33, the kill was just before 12:13:33) and were null at 12:23:33, matching the 10-minute TTL. The killed reader left the pane in raw mode with Kitty keyboard flags and mouse reporting on, so `reset` could not be typed; expected for SIGKILL, recovery is to write the reset sequences from another pane or close the pane. All four P3-10 exit checks are confirmed | P3-10 | | 2026-10-06 | decision | **Phase 2 closed (operator decision).** The dogfood clock (2026-09-29, planned exit ≈ 2026-10-13) was cut short at about one week: Phase 2 is `done` in [phase-2-mvp.md](phase-2-mvp.md) and the roadmap table, the two-weeks adoption measure is recorded as closed early, not as passed, and any further dogfood findings are filed against Phase 3. The first crates.io release ([P3-39](phase-3-alpha.md)) no longer waits for the Phase 2 verdict; it still waits for the Phase 3 exit decision and a second drift check | P3-39 | | 2026-10-06 | fix | P3-41: the V22 orphan-exit check is now a Rust bin, `check-orphan-exit` in `wiki-reader-tools` (rustix PTY, `setsid` and signals, no `unsafe`), replacing `scripts/check-orphan-exit.py`. Same nine cases and exit codes; on macOS the Rust and Python runs gave the same PASS set, and a stub reader that ignores the terminal fails cases (i), (i'), (ii), (iii) and (iv). `check.sh` and CI no longer need `python3` | P3-41, P3-37 | +| 2026-10-06 | decision | P3-38 (V23) investigated with the new Rust harness and dropped as not reproducible: with the PTY master open and unread, `q` and SIGTERM exit within 1 s, and a forced exit (leader killed, watchdog `process::exit`) shows macOS state `?E` for about 0.6 s and completes, on screens from 24×80 to 400×1200; a drained master gives the same 0.6 s, and `tcflush(OFlush)` before the forced exit changed nothing, so nothing was shipped. Case (v) of `check-orphan-exit` now guards that a reader exits with the master open and unread. The development guide's known-issues list is empty | P3-38, P3-41 | ## Related diff --git a/wiki/roadmap/phase-3-alpha.md b/wiki/roadmap/phase-3-alpha.md index fd298de..4c65faa 100644 --- a/wiki/roadmap/phase-3-alpha.md +++ b/wiki/roadmap/phase-3-alpha.md @@ -101,7 +101,7 @@ Per batch: manual passes in Ghostty and herdr, plus iTerm2/tmux for media or fal | P3-35 | Audit fixes: media | — | todo | [audit-v0.1.8.md](audit-v0.1.8.md). Measure first: harness SVG + image-plan (V6); Kitty byte counter for V15; V16 LRU cap + keep fallback reason. Then decide. Operator manual pass in Ghostty and herdr. Does **not** gate the Phase 3 exit | | P3-36 | Audit fixes: process and tooling | — | todo | [audit-v0.1.8.md](audit-v0.1.8.md). V17 remainder (reap children, herdr timeout, surface open errors; early signal registration moved to P3-37), V19 (`release.yml` split; dry run first), V20 (`deny.toml` / Dependabot / pin hygiene; audit found unmaintained `bincode` / `ttf-parser`), V21 (`check_doc` via `core::nav::resolve`), boolean-arg tidy-ups. Does **not** gate the Phase 3 exit | | P3-37 | Audit fix: orphan process spin (V22) + early signals (V17) | — | done | V22: TTY guard + session-leader watchdog + stdin hangup poll (`WIKI_READER_NO_WATCHDOG=1` opts out of the leader check); V17: SIGHUP/SIGTERM/SIGINT before raw mode; `scripts/check-orphan-exit.py` runs the PTY cases in `check.sh` and CI; error output no longer panics on a dead terminal (SIGABRT race). Ships in v0.1.10. Does **not** gate the Phase 3 exit | -| P3-38 | Known issue: exiting process stuck in macOS state `E` with an unread PTY master (V23) | — | todo | [audit-v0.1.8.md](audit-v0.1.8.md). Investigate later. Reproduce with a PTY whose master is open and never read while the reader exits; decide whether `tcflush(TCOFLUSH)` before a forced exit, or another change, releases it; check a clean exit too. Documented in the [development guide](../guides/development.md#known-issues). Does **not** gate the Phase 3 exit | +| P3-38 | Known issue: exiting process stuck in macOS state `E` with an unread PTY master (V23) | — | dropped | [audit-v0.1.8.md](audit-v0.1.8.md). Investigated 2026-10-06 and **not reproducible as a hang**: with the master open and never read, `q` and SIGTERM exit within 1 s, and a forced exit (session leader killed) shows `?E` for about 0.6 s and then completes, on screens up to 400×1200. The same 0.6 s window appears with a drained master, so the drain hypothesis does not hold; `tcflush(OFlush)` before the forced exit made no difference and was not shipped. The property is guarded by case (v) of `check-orphan-exit` (P3-41). The two original incidents stay unexplained; the development guide's known-issues list is now empty and says what to capture if it recurs. Does **not** gate the Phase 3 exit | | P3-39 | First crates.io release | — | todo | Prep landed in #169 (package `wiki-reader-tui`, MSRV 1.92 + CI job, `cargo-deny` + CI job, `NOTICE`, per-crate licence copies, `THIRD-PARTY-LICENSES` in tarballs, crate READMEs, SECURITY.md, Dependabot); `cargo publish --workspace --dry-run` and the release workflow dry run pass. **Gates before publishing (operator):** the Phase 3 exit decision (P3-10 checks or deferral, table-viewer screenshots, V8), and a second drift check. The first-publish release PR also drops the `v0.1.*` prerelease rule from `release.yml`, updates the release wording in `releasing.md`, README and SECURITY.md, and adds the banned-phrase check. Then `cargo publish --workspace --locked`, tag, verify `cargo install --locked wiki-reader-tui` in a fresh `CARGO_HOME`. Later: trusted publishing and the V19 workflow split. Does **not** gate the Phase 3 exit | | P3-40 | Docs: one assets directory; drop the dogfood screenshot refs | — | done | Operator request 2026-10-06 before the Phase 3 close-out. All screenshots live in `wiki/assets/` (inside the collection root, so wiki pages render them when the reader is opened on `wiki/`); root `assets/` removed; the root README, the crates.io README and the `image-protocol` example point at `wiki/assets/`; the notes moved to the [development guide](../guides/development.md#screenshots). The four table-viewer dogfood images and the links to them are gone: the dogfood-log and P3-30 descriptions are the record. Does **not** gate the Phase 3 exit | | P3-41 | Rewrite `scripts/check-orphan-exit.py` in Rust | — | done | Operator request 2026-10-06. A `check-orphan-exit` bin in `wiki-reader-tools` using rustix (`pty`, `setsid`, signals; no `unsafe`; the helper modes re-execute the binary because `std` only offers `setsid` through unsafe `pre_exec`), the same nine cases and CLI, wired into `check.sh` and CI; the script and the `python3` requirement are gone. Parity run against the Python script on macOS: identical PASS set; a stub reader that ignores the terminal fails the cases it should. Also hosts the V23 reproducer ([P3-38](#tasks)). Does **not** gate the Phase 3 exit |