Skip to content

Input ownership: keys wait for the look, however long a loaded machine makes it (#426) - #433

Merged
raiseCatError merged 2 commits into
devfrom
fix/v022-input-ownership-look
Oct 21, 2026
Merged

raiseCatError merged 2 commits into
devfrom
fix/v022-input-ownership-look

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Draft, do not merge before v0.21 ships. #430 has carried dev into release/v0.21.0; a fix v0.21's physical QA needs would reach the release the same way, so anything merged into dev before the release would ride along. Part of v0.22 (#431).

What was wrong (#426)

While a command runs and NMSh has no evidence yet that it reads input, the frontend holds the keys that decide where typing goes (the first key of a draft, an Enter that would queue) while the session looks. On dev the hold ended after a fixed 800 ms whether or not the look had answered, so on a loaded machine a reply to a prompt-less read was queued and a password typed at once for a prompt-less read -s became a queued shell command.

Review of the timeout path (requested)

The first commit replaced the 800 ms deadline with a 5 s bound. Review showed the bound did not make it private; the second commit fixes each case at its cause.

Question On the first commit Now
1. What happens at 5 s with no decision? Held keys replayed as ordinary typing: drawn in the composer, Enter queued Nothing is routed. Typing stays held and hidden; the activity rows say Can't tell whether <program> is reading your typing · held, not shown and Ctrl+S sends it to <program> · Tab shows it · Esc discards it. Later evidence decides it automatically
2. Can a read -s/sudo/ssh password reach the composer, queue, transcript or logs? Yes, after the bound and whenever a look ended without evidence (a failed probe; sudo/ssh on a CPU in the only sample, which the shell-only follow-up did not cover) Echo off with line input is a hidden request whatever else is known (confirmed when idle, likely when the group computes or the kernel says it is blocked elsewhere), and hidden input is always direct: never drawn. A look now says what it saw; unknown is never treated as permission to draw. Key diagnostics already exclude running-program input; drafts are not journaled
3. Can held data go to a different command if the process exits, changes state or is interrupted? Yes: a command ending mid-hold (read -s -t 2 with a slow look) replayed the password at the prompt and Enter ran it A command that ends while its typing was held past 800 ms never runs it: discarded with a note (… ended before NMSh could tell whether your typing was for it; it was discarded, not run.). A quick end (type-ahead after a short command) still runs once. A program that changes state gets keys only on evidence (a hidden or line request, or the terminal handed over), as a terminal would deliver them
4. Ordering, Ctrl+C, cancel, prompt restoration Ctrl+C replayed held keys before interrupting: a held reply plus Enter became a paused queue entry Ctrl+C discards held typing (a terminal flushes its input queue on an interrupt; ShellSession.interrupt already discards input held behind terminal answers), then interrupts. Order is preserved on every other path: keys after a held Enter stay behind it; Ctrl+S sends composer text then held keys as typed
5. A safe, bounded failure state without a freeze or silent execution? No The held state above: typing keeps being captured (no freeze), nothing is shown or run without a decision or evidence, and Ctrl+S / Tab / Esc / Ctrl+C always resolve it
6. Heavy CPU, swap, stalled probes, long-lived processes Stalled probes led straight to 1–3 A failed or timed-out probe is unknown; it gets one more look (evidence, not waiting longer), then the held state. Long-lived processes keep the held state until a choice or evidence; their end discards held typing with a note

A bounded timeout alone does not guarantee privacy; the guarantee now rests on two rules: NMSh never draws input the terminal would not echo, and it never routes typing on a guess.

What changed

  • InputWatch: echo-off line input → hidden request (confirmed or likely). A first sample that finds the shell on a CPU with nothing to compare is runnable, not computing; the follow-up decides. lookNow() returns hidden | visible | unknown | idle | unavailable, using the echo state of any sample taken (even one overtaken by output).
  • Protocol: input-looked gains an optional result; services advertise input-look-result. Old frontends ignore the field. A new frontend talking to an older service gets no result and routes as before (documented limit).
  • inputState.directInput: hidden input is direct at either confidence.
  • TerminalApp: one hold with explicit outcomes (route, discard, send, reveal); Checking whether … · your typing is kept after 800 ms; the held state with its rows and composer hint; one evidence re-look; Ctrl+C discard; settle on command end.
  • Design note (docs/design/input-awareness.md) and changelog rewritten for the final behavior.

Verification

Regressions first. tests/inputOwnershipLook.test.ts (each look is a promise the test settles with what it saw; mocked timers; a nowhere() check that the secret is not on screen, in the composer, the queue, the transcript or a submitted command) and four new watch/protocol tests in tests/inputAwareness.test.ts:

  • against dev a8b383c2: the first commit's 7 regressions failed;
  • against the first commit 6958c277: 8 of 15 final frontend tests and 4 watch/protocol tests fail (12 in all; the second commit message says 10, an undercount);
  • this head: all pass. Guards that pass everywhere: quick looks add no delay, an older service routes as before, quick type-ahead after a short command runs once.

Suites. Focused input/queue/session suites, unit and real-PTY (zsh, Bash, Fish): 81/81. Full canonical npm run verify on this head (NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev, load 5–17): exit 0, 3,161 tests, 3,159 pass, 2 skipped, 0 failed. (First commit: 3,152 / 3,150 / 2 / 0.)

Live, first commit (4 copies of tests/inputOwnershipLive.test.ts in parallel on this 8 GB Mac): the unfixed baseline reproduced #426 (timed out waiting for ANSWER=hello, Probably waiting for input · zsh · ≡ 1 queued) and, in another round, a drawn hidden reply; the first commit was clean in 15 of 16 copies; the one failure is tracked separately as #436 and is not claimed fixed here. CPU contention alone does not reproduce #426 (a probe stays ~10 ms at load 64); slow looks come with memory pressure. Fish #356: 32/32 at 8× parallel on this branch; not declared fixed.

CI on this head (e566a1e6):

  • First run: one Ubuntu Fish live test failed (tests/commandQueueLive.test.ts, a multiline queue entry uses native grouping). A queued printf printed write: Interrupted system call, so Fish's write was interrupted by a signal.
  • Why it is unlikely to come from this PR: the diff changes no signal or resize path, and the test types nothing that would be held. The first commit's CI was green, and no earlier report of this error exists.
  • Rerun of the failed jobs: all green (both Ubuntu and macOS shards, Verify 22.x and 26.x). Noted as a possible Linux flake, not investigated further.

Limits

  • An older session service does not say what its look saw; its answers are routed as before.
  • The held state is reached only when a look is blind or never answers; on a loaded machine type-ahead appears when the look answers rather than by 800 ms.
  • Tab on typing held after an Enter that followed visible text shows that Enter as a new line (NMSh cannot know which line it ended).
  • Echo-off input from a program that is computing rather than reading is reported as "Probably waiting for input" (hidden, likely): keys go to it as a terminal would deliver them.

Physical QA (not done)

Checklist: docs/testing/v022-physical-qa.md pass 1 (in #435). Ghostty and Terminal.app; zsh, Bash and Fish:

  1. read -r reply; echo got=$reply, answer at once and after a pause → got=…, nothing queued.
  2. read -s (no prompt) and sudo -k true, password at once → never shown, never queued.
  3. sleep 5, type echo next + Enter → queued at once.
  4. With heavy load in another window, repeat 1–2 → the Checking whether … note may appear; answers still reach the read; nothing shown or queued.
  5. Ctrl+C right after typing a reply → the reply never appears in the queue or composer.

Refs #426

…e makes it

#426: under heavy load, a reply typed right after starting a prompt-less read
(`read -r reply`, `read -s`, `read -k 1`) was routed before NMSh knew who read
it. The frontend held such keys for at most 800 ms while the session looked;
a look that took longer (a prompt-less line read needs two process-table
samples, and each starts stty and ps) released them without evidence, so
Enter queued the reply and the read kept waiting. A password typed at once
for a prompt-less `read -s` became a queued shell command, and under load a
hidden reply could be drawn in the composer before the look confirmed it.

- TerminalApp: held keys are routed when the look answers, bounded at 5 s
  (a look is itself bounded by its probes' timeouts). Until then nothing
  typed is drawn, sent or queued. After 800 ms the composer says
  "Checking whether <program> is waiting for input · your typing is kept",
  so nobody types it again. The exec wait before the look uses the same
  bound. Ctrl+C still releases at once; a look that answers quickly (the
  usual case) adds no delay.
- InputWatch: a first sample that finds a process on a CPU with nothing
  earlier to compare is "runnable", not computing. When the shell holds its
  terminal, the follow-up sample decides, so a builtin still runnable for a
  moment before it blocks in read is not taken for a busy shell. Every other
  case keeps its previous meaning.

Deterministic regressions drive a look the test settles on mocked timers:
replies to prompt-less line, hidden and prompted (sudo) reads typed at once
are never queued, drawn or run; keys after a held Enter stay behind it; a
look that never answers releases at the bound; the command ending while keys
are held runs the line once as the next command; quick looks add no delay.
Seven of them fail on the previous code.

Refs #426
…er drawn

Review of the first #426 change found that its 5 s fallback, and any look
that ended without evidence, still routed held keys as ordinary typing: a
password typed for read -s, sudo or ssh could be drawn in the composer,
queued, and run as a command; a command that ended while keys were held
(read -s -t that gave up) replayed them at the prompt and ran them; Ctrl+C
replayed held keys before interrupting, so a held reply plus Enter became a
paused queue entry.

- InputWatch: echo off with line input is a hidden request whatever else is
  known (confirmed when the group is idle, likely when it computes or the
  kernel says it is blocked elsewhere). directInput treats hidden input as
  direct at either confidence, so NMSh never draws what the terminal would
  not echo.
- lookNow says what it saw: hidden, visible, unknown (no sample said how the
  terminal echoes: a probe failed or timed out), idle or unavailable. The
  echo state comes from any sample taken, even one overtaken by output.
  input-looked carries it (optional field; services advertise
  input-look-result; older frontends ignore it).
- TerminalApp: held keys are settled explicitly. visible or evidence routes
  as before. unknown gets one more look; if that is blind too, or no answer
  comes within 5 s, the typing stays held and hidden and the activity rows
  say "Can't tell whether <program> is reading your typing": Ctrl+S sends it
  to the program, Tab shows it in the composer (a final Enter is not
  replayed), Esc discards it, and later evidence decides it the same way.
  Ctrl+C discards held typing, as a terminal flushes its input on an
  interrupt. A command that ends while its typing was held past 800 ms never
  runs it (a note says it was discarded); a quick end still runs type-ahead.
  An older service's silent answer is routed as before (documented limit).

Adversarial regressions drive looks the test settles with what they saw, on
mocked timers, and assert the secret is nowhere: not on screen, in the
composer, the queue, the transcript or a submitted command. 10 of them fail
on the previous head; all pass here.

Refs #426
raiseCatError added a commit that referenced this pull request Oct 19, 2026
InputWatch: #436's done state stops looking, as before, and a look now
answers 'idle' once the shell said the command is done. Both changelog
entries are kept.
@raiseCatError
raiseCatError merged commit 3195d44 into dev Oct 21, 2026
20 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant