Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/wiki-reader-tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
92 changes: 82 additions & 10 deletions crates/wiki-reader-tools/src/bin/check-orphan-exit/cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>;
Expand Down Expand Up @@ -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<OwnedFd>,
}

impl Drop for Session {
Expand Down Expand Up @@ -142,20 +144,36 @@ fn wait_for_pid(pidfile: &Path) -> Result<u32, String> {

/// 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<Session, String> {
#[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<Session, String> {
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)?;
Expand All @@ -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));
Expand All @@ -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!(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
))
}
7 changes: 6 additions & 1 deletion crates/wiki-reader-tools/src/bin/check-orphan-exit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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))),
];

Expand Down
5 changes: 5 additions & 0 deletions crates/wiki-reader-tools/src/bin/check-orphan-exit/proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
ps_field("stat", pid)
}

pub fn ppid_of(pid: u32) -> Option<u32> {
ps_field("ppid", pid)?.parse().ok()
}
Expand Down
14 changes: 14 additions & 0 deletions crates/wiki-reader-tools/src/bin/check-orphan-exit/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,6 +38,19 @@ pub fn open_pty() -> io::Result<Pty> {
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
Expand Down
13 changes: 6 additions & 7 deletions wiki/guides/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion wiki/roadmap/audit-v0.1.8.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions wiki/roadmap/dogfood-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading