pmacs/docs/process-signal-tolerance-fr...

11 KiB
Raw Permalink Blame History

Framing — make the PTY terminate failure self-describing (diagnostic only)

Revision 4. Status: awaiting review round 4. Lane: pty-terminate-eperm, worktree ../pmacs-math-slice, based on githubsucks/main @ ccf29e3.

Diagnostic only. No disposition changes, no tolerance rules, no behavioural fix. Every rule this document proposed across revisions 1 to 3 is parked (§5). The lane's entire deliverable is that the next occurrence of the failure explains itself.

Revision history

Revision 3 → 4, after review round 3 (two blocking, one major) and its scope call. All accepted.

  • Group-directed ESRCH was also unsafe, for the same reason EPERM was: it proves the selected foreground group vanished, not that the leader exited. A job-control race — foreground job exits after tcgetpgrp and before kill, shell alive and not yet reclaiming the terminal — would have been reported as success with the leader never signalled. Rev 3's acceptance 7 pinned that unsafe behaviour. All tolerance is parked (§5).
  • Rev 3's Stage A implemented Stage B. It declared itself diagnostic-only, then listed tolerance and bookkeeping acceptances. Removed.
  • Q#PS6 (already-reaped terminate is Ok) is parked separately. It is an independent behavioural fix answering a different failure; under one-feature/one-PR it does not ride with instrumentation.
  • "Strictly additive / cannot regress behaviour" was overstated and is narrowed (Q#PD3).
  • The injected-kill seam is restored as an explicit decision (Q#PD4).

Rounds 13, for the record. Rev 1 classified on errno alone and claimed a live owned child cannot yield EPERM — false. Rev 2 gated on try_wait, which observes the leader while a PTY signal targets the foreground group — unsound whenever those diverge, and it could not be shown to fix the observed failure at all. Rev 3 corrected EPERM but left ESRCH unsafe and mixed the stages. Three consecutive designs were wrong in the same direction: each tried to conclude something about a process from something that was not about that process.

0. Coherence impact (COHERENCE §20)

  • Journey step 8, "Open a terminal" (§2), teardown half. No grade change and no behavioural change — this lane only improves what a failure reports.
  • Serves §9 (worker model), failure attribution, in its most literal sense: an error that names only an errno cannot be attributed.
  • Interaction islands: none. Config registry: not adopted. Background-work attribution: unchanged.
  • No audited claim in COHERENCE.md changes, so under §25 no COHERENCE edit rides this PR.

1. Ground truth (scouted @ ccf29e3, re-verified each revision)

1.1 The failure reports an errno and nothing else

ProcessSupervisor::signal (src/process.rs:921) maps the kill failure to format!("kill: {e}") (:931). That string is everything a reader gets.

1.2 The signal target is not the observation target

  • Signal targetsignal_target (:687) returns -pgrp for a PTY, where pgrp = master.process_group_leader(): the tty's current foreground process group, read at signal time.
  • Observation targetChildHandle::try_wait (:668) observes the spawned leader.

They coincide only while the leader owns the terminal. Job control is precisely the mechanism that makes them diverge, and the PTY path is always group-directed by design — spawn rejects group = true for PTY mode with the rationale that "PTY children already lead their own session and are signaled group-wide" (:1428-1429).

This is why every tolerance rule across rev 13 failed review, and why the diagnostic must record the target and the leader state as separate facts.

1.3 The reap ledger is disjoint from this path

tick_reap_ledger (:1075) treats any probe error as "nothing left we can reach" for bounded growth, asserting EPERM "cannot happen for our own children". It is armed only for proc.spec.group, which PTY mode cannot set. Rev 1's "asymmetry" argument was a misreading; withdrawn.

1.4 The observed failure, and the limits of the evidence

macOS CI, PR #172 (docs-only diff), Test (macos-latest / luajit), acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel (attempt 1):

in function 'terminate'
cause: ExternalError(Process("kill: EPERM: Operation not permitted"))

Established: the errno, and the call path (pmacs.terminal.terminatesession.rs:566signal).

Not established: that the child had exited (the probe's last source statement is a file write at tests/bottom_panel_stage1_acceptance.rs:2239; CPython teardown follows and does not synchronise with it); that any pgid was recycled; or what the signal target actually was.

This is the whole reason the lane is diagnostic. Every candidate fix needs at least one of those three facts, and none is available.

1.5 Caller inventory

Caller Disposition
src/lsp.rs:1364, :2427 discards (let _ =)
src/mcp.rs:1229, :1239, :1915 discards (let _ =)
src/terminal/session.rs:319, :607, :635 discards (let _ =)
src/terminal/session.rs:566 (propagating at :577) propagates as TerminalError::Process
supervisor-internal shutdown path discards
src/lua_bindings/mod.rs:8150, :8164 propagates to Lua
src/lua_bindings/mod.rs:8717 propagates (via session.rs:566)
src/daemon.rs:4162 test-only .expect, not production

No test in the repository asserts either error string, so widening the message breaks nothing.

1.6 portable-pty caches the exit status on Unix

Pinned portable-pty 0.9.0: spawn_command returns std::process::Child (unix.rs:228), and impl Child for std::process::Child::try_wait delegates to std::process::Child::try_wait (lib.rs:271-277), which caches into self.status. Both ChildHandle variants therefore cache.

2. Decisions

Q#PD1 — what the widened error records

On a kill failure in signal, the error carries:

Field Why
target sourcetcgetpgrp vs group vs leader-pid fallback which branch of signal_target (:687) ran
target kind and value-pgid or pid, with the number the entity actually signalled
spawn-time pgid / leader pid a divergence from the target is the job-control hypothesis, visible only by comparison
errno as today
leader try_wait stateexited(status) / live / unobservable(e) separates "the leader is gone" from "the group we signalled is gone" — the distinction all three failed designs collapsed

Every candidate Stage B rule is decidable from these five together, and none is decidable from the errno alone.

Q#PD2 — the disposition is preserved exactly

The call still fails, with the same Err, in every case. No state transition changes, no ledger arming changes, no tolerance. A reader diffing behaviour should find none.

Q#PD3 — the honest claim is "no disposition change", not "strictly additive"

Rev 3 said the diagnostic was only an error-string change and could not regress behaviour. That overstated it. try_wait on an exited child reaps it and caches the status, so consulting it in the failure path is an internal state change: the child may be reaped earlier than it otherwise would be.

Observably safe, because both variants cache (§1.6) and poll_one (:1133) will still see Ok(Some(_)) and emit its event. But safe by argument is not safe by assertion, so the terminate-failure-then-tick event pin is retained (acceptance 5).

Q#PD4 — the injected-kill seam injects the KILL, never the observation

Acceptance 5 needs a forced kill failure while the real ChildHandle::try_wait runs against the real child. A stubbed observation would bypass exactly the code path in question.

So the seam is a test-only override of the kill attempt's result, consumed once by the signal path; everything downstream — target selection, the observation, the error construction — runs for real. This also makes the diagnostic's own fields testable without racing the kernel.

Q#PD5 — nothing else lands here

No tolerance rule, no idempotence change, no signal_target change. See §5.

3. Bets (falsifiable)

  • B1 — The five fields are sufficient to discriminate the §1.4 hypotheses. Falsified if a recurrence carries all five and still leaves the cause ambiguous — which would itself be a finding worth having.
  • B2 — Widening the message breaks no caller. Evidence: §1.5, and no test asserts the string.

Retracted across revisions and not reinstated: rev 1's "a live owned child cannot yield EPERM"; rev 2's "exit observation suffices"; rev 2's "this removes the failure class"; rev 3's "group ESRCH is safe to tolerate".

4. Acceptance

  1. A group-directed kill failure produces an error carrying all five Q#PD1 fields, with the target rendered as -pgid and the leader state distinct from it.
  2. A leader-directed kill failure does the same, with the target rendered as pid and the target source recorded as the fallback branch.
  3. The leader state renders each of exited(status), live, and unobservable(e) correctly.
  4. The disposition is unchanged: every injected failure still returns Err, with no state transition and no ledger arming (Q#PD2). Falsified by revert — flipping any arm to Ok fails this.
  5. Forced injected kill failure against the real PTY child observation, then tick: exactly one exit event, with the correct status (Q#PD3/Q#PD4). A fully stubbed observation does not satisfy this and is rejected as vacuous.
  6. The existing suites stay green, pinning "no behavioural change" from the outside.

5. Parked (not deferred-and-forgotten — each needs its own evidence)

  • All tolerance rules. Group-directed EPERM and ESRCH both fail on the §1.2 entity split; leader-directed tolerance is plausible but unmotivated until evidence shows the fallback branch is ever taken. Needs Stage A evidence first.
  • Q#PS6, terminate on an already-reaped process returning Ok. Independent behavioural fix, different failure (§1.6 of rev 3), its own lane under one-feature/one-PR.
  • signal_target's read-then-kill of tcgetpgrp — still the most likely real fix site, still unframed.
  • terminate cancelling pending restarts; PTYs in pmacs.process.list; any change to C-c delivery.

6. Gates

Full suite per CLAUDE.md. Touched suites: bottom_panel_stage1_acceptance, the vterm stages, and compile_mode_acceptance. Sweep with -- --skip basedpyright.

7. Branch plan

pty-terminate-eperm, one PR, diagnostic only. This framing is its first commit; the instrumentation and its tests are the second.

The lane then closes. It does not wait for the flake to recur: the next occurrence — whenever it happens, under whoever's PR — carries its own evidence, and Stage B is framed then. Math work proceeds immediately after this lands.