From cbd2b483ab3b777ce34cd1bb8b4e3340322c2eff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:28:39 -0700 Subject: [PATCH] docs(exit-cause): pin why isProvenProcessExit(0) must stay true isProvenProcessExit asks whether the process ended; the cause resolvers ask why. Their disagreement on 0 is the design, not a defect: login(1) wraps every macOS local PTY once the TCC preflight passes, so routing hostReportsChildExitStatus through the predicate would leave every pane a user closed with `exit` mounted forever. No behavior change; comment and regression tests only. --- src/shared/terminal-exit-cause.test.ts | 18 ++++++++++++++++++ src/shared/terminal-exit-cause.ts | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/shared/terminal-exit-cause.test.ts b/src/shared/terminal-exit-cause.test.ts index 3fafba16faa..504c1fae602 100644 --- a/src/shared/terminal-exit-cause.test.ts +++ b/src/shared/terminal-exit-cause.test.ts @@ -109,4 +109,22 @@ describe('isProvenProcessExit', () => { // A host shutdown can deliver -1 for every PTY without proving process death. expect(isProvenProcessExit(-1)).toBe(false) }) + + it('answers whether the process ended, not why — so it may differ from the cause on zero', () => { + // Deliberate, not an oversight: an unknowable *reason* is not an unknown + // *fact of death*. Collapsing these would strand every cleanly-exited pane. + expect(resolveUnreportedExitCause(0)).toEqual({ kind: 'unknown', reason: 'cause_unreported' }) + expect(isProvenProcessExit(0)).toBe(true) + }) + + it('keeps a shell that a user closed with `exit` tearing down on macOS', () => { + // login(1) wraps every macOS local PTY once the TCC preflight passes, so + // hostReportsChildExitStatus is false for essentially all of them. login + // forks the shell and waits, so its exit still proves the shell died — + // routing that evidence through here would leave the pane mounted forever. + expect( + resolveProcessExitCause({ exitCode: 0, signal: 0, hostReportsChildExitStatus: false }).kind + ).toBe('unknown') + expect(isProvenProcessExit(0)).toBe(true) + }) }) diff --git a/src/shared/terminal-exit-cause.ts b/src/shared/terminal-exit-cause.ts index 2e3ac7919c4..1fa3d2f14b6 100644 --- a/src/shared/terminal-exit-cause.ts +++ b/src/shared/terminal-exit-cause.ts @@ -108,6 +108,23 @@ export function isDeliberateTerminalExit(cause: TerminalExitCause): boolean { * Negative codes are synthetic stop sentinels; they mean that the host lost * contact before it could vouch for the child, so downstream cleanup must use * the `unverifiable` path instead of treating the tab as exited. + * + * This asks *whether* the process ended; the cause resolvers above ask *why*. + * The two deliberately disagree about `0`, and that is not a defect: + * + * - `resolveUnreportedExitCause(0)` is `unknown` because a bare zero cannot + * distinguish a clean finish from a signal — an unknowable **reason**. + * - `isProvenProcessExit(0)` is `true` because a host only forwards a + * non-negative code after its provider observed the process end — a known + * **fact of death**, whatever the reason. + * + * Do not route `hostReportsChildExitStatus` or `signal` through here to + * "narrow" the zero. `login(1)` wraps every macOS local PTY once the TCC + * preflight passes, so `hostReportsChildExitStatus` is false for essentially + * all of them (macos-tcc-login-shell.ts) — yet login forks the shell and waits, + * so its own exit *is* evidence the shell died. Treating those as unproven + * would strand every macOS pane that a user closed with `exit`, which is the + * mirror-image regression of reporting a lost host as exited. */ export function isProvenProcessExit(exitCode: number): boolean { return resolveProcessExitCause({ exitCode }).kind !== 'unknown'