Skip to content

Close the rl.question EOF-hang class: the wizard fork menu, both y/N confirms, and the OAuth paste - #785

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-783
Aug 18, 2026
Merged

Close the rl.question EOF-hang class: the wizard fork menu, both y/N confirms, and the OAuth paste#785
philcunliffe merged 4 commits into
masterfrom
fix/issue-783

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

node:readline's rl.question() leaves its promise permanently unsettled when the input stream ends without a line. PR #773 closed the three members of that class inside src/core/cli/walkthrough.js. This closes the remaining four, and settles the two decisions #783 said an owner had to make. Based on master, not on #773's branch.

The two decisions

1. Where the shared helper lives: src/core/cli/line_asker.js

Not a new top-level location, and not src/core/util/. src/core/util/ is the fs-and-JSON drawer (fs_atomic.js, fs_copy.js, json_util.js); nothing about streams or terminals lives there. src/core/cli/ is where this repo already keeps its small shared CLI helpers: stdio.js (isTty, useColor), flush-streams.js, stream_errors.js, spinner.js, style.js. A line asker is one of those, so it sits with them.

The plugin-workspace member is not an obstacle: plugins already import core modules by relative path (hypaware-core/plugins-workspace/claude/src/settle.js reaches src/core/observability/index.js and src/core/usage-policy/index.js, ai-gateway/src/dataset.js reaches src/core/cache/partition.js), so claude-account reaches src/core/cli/line_asker.js the same way. No package export or imports entry is needed, and none is added.

The module exports two things:

  • queuedLineAsker, moved verbatim out of walkthrough.js (where it was module-private at line 123), including the readableEnded seeding an already-spent stream needs. Used by the prompts built with terminal: false, where writing the query straight to the output stream is byte-identical to what rl.question does.
  • askLineOnce, new, for the prompts that ask once on an interface that may be a real terminal. There, output.write(prompt) is not equivalent to asking: readline redraws the line it is editing from its own cursor bookkeeping, and a query it never saw is a query it cannot redraw. So askLineOnce keeps rl.question as the thing that writes and reads, and replaces only its promise, adding the two settlements it is missing (close, for a stream that ends while the question is on screen; readableEnded, for a stream that was already spent when the interface was built and so never emits close at all).

src/core/cli/confirm.js builds its interface without terminal: false and is TTY-gated by design, so it takes askLineOnce. That distinction is the one non-mechanical thing in this diff and is the part most worth pushing back on.

2. Exit code when the terminal dropped: 0, not 130

The rule is already in the corpus, in LLP 0190 §sync-gate: "A closed stdin takes that same default where enter has one. Where it does not ... it is a cancel rather than an answer ... (exit 130)." The fork menu prints Choose [1-3, default 3], and LLP 0129 §fork settled that the default is Quit. So EOF there is Quit, and runInitWizard turns Quit into exit 0 with nothing written.

Three reasons not to reach for 130 instead:

  1. The fork's own TUI path already exits 0 for a deliberate ctrl+c. promptForkChoice maps isPromptCancelledError to 'quit' (fork.js:121, and again at :248 for the returning gate), and wizard/index.js:106,232 turn 'quit' into { exitCode: 0 }. Making the readline fallback return 130 would have the fallback judge a dropped terminal more harshly than the TUI judges a deliberate cancel, at the same screen, for the same non-answer.
  2. A prompt that printed a default cannot distinguish the two cases, and should not pretend to. The whole point of coalescing EOF into the empty line rather than branching on it is that the EOF answer cannot drift from the advertised one. Branching to 130 reintroduces exactly the drift.
  3. Exit 0 is true. Quit wrote nothing, changed nothing, installed nothing. Today's exit 13 (unsettled top-level await) is nonzero but it is nonzero by accident, not by design, and it is a crash report rather than a status.

130 keeps its existing meaning: prompts whose enter answers nothing (WALKTHROUGH_CANCEL_EXIT_CODE, LLP 0190's cancel case). The two [y/N] confirms take their printed no, which is the safe direction for the irreversible verbs behind them (LLP 0104, LLP 0155 §delete-confirm); their callers' exit codes are untouched, so an EOF decline is reported exactly as a typed n is.

If a maintainer disagrees on either, both are one-line changes and both are recorded in the LLP so the disagreement has somewhere to land.

Sites fixed: all four

site prompt EOF now
src/core/cli/wizard/fork.js:386 legacyMenuPrompt (behind legacyForkPrompt and legacyReturningGatePrompt) quit, the printed default 3; wizard exits 0
src/core/cli/confirm.js:30 askYesNo false, the printed [y/N] default
src/core/plugin_install/confirm.js:150 buildTtyPrompt false, so decideConfirmation returns rejected, same as a typed n
hypaware-core/plugins-workspace/claude-account/src/index.js:167 the Code: OAuth paste see below

None left. One site needed more than the mechanical change:

The OAuth paste is the only prompt with no default, so it does not invent one. parsePastedAuthorization('') throws empty authorization code, so folding EOF into the empty line the way the wizard's prompts fold it would report an unanswerable prompt as a malformed paste the user never made. Worse, the paste lane is raced against the loopback callback server, so any settlement of the paste lane settles the race: a rejection at EOF would abort a browser sign-in that was still on its way. The lane (extracted as pasteAuthorizationLane, so the rule is readable and testable without a browser) now parts the two cases:

  • No listener (the port could not be bound, or the flow fell back to the hosted callback): nothing else can finish the login, so EOF is a real failure that says so, and runLogin prints it and exits 1. This is the hang Follow-up: close the remaining rl.question EOF-hang class #783 names.
  • A listener is up: the paste lane deliberately stays pending. An EOF on the fallback input is not evidence about the primary flow, and the process still has a live handle, so this is a legitimate wait on the browser rather than a hang caused by EOF. Behaviour here is unchanged on purpose.

Test evidence

test/core/readline-prompt-eof.test.js and test/plugins/claude-account-paste-eof.test.js. Both race every case against a 500ms timer, reusing test/core/walkthrough-prompt-eof.test.js's pattern, because the pre-fix failure mode is a hang rather than a wrong value and an unraced assertion would simply never run.

Before (behavioural change reverted in all four files, helper module and extraction kept so imports still resolve, tests unchanged):

not ok 1 - fork menu takes its printed default on a stdin that ends without a line
not ok 2 - fork menu takes its printed default on a stdin that was already spent
ok 3 - fork menu still honours an explicit pick
not ok 4 - returning gate menu takes its printed default on a stdin that ends without a line
not ok 5 - askYesNo declines on a stdin that ends without a line
not ok 6 - askYesNo declines on a stdin that was already spent
ok 7 - askYesNo still honours an explicit yes
not ok 8 - plugin install confirm declines on a stdin that ends without a line
not ok 9 - plugin install confirm declines on a stdin that was already spent
ok 10 - plugin install confirm still honours an explicit yes
not ok 11 - paste lane fails, rather than hanging, when stdin ends with no listener to wait on
not ok 12 - paste lane fails, rather than hanging, on a stdin that was already spent
ok 13 - paste lane leaves the loopback listener to finish when stdin ends under it
ok 14 - paste lane still parses a pasted code
# tests 14 / # pass 5 / # fail 9

After: # tests 14 / # pass 14 / # fail 0.

The five that pass in both are the guards: each fixed prompt still honours a real answer, and the paste lane still waits for the browser and still parses a paste.

Process-level, the headline case

hyp init on a pty stdout with < /dev/null stdin and a temp HYP_HOME, which is the exposure #783 describes (a terminal run whose stdin dries up):

# before (fork.js from origin/master)
$ script -qec "node bin/hypaware.js init < /dev/null" /dev/null
...
Choose [1-3, default 3]: Warning: Detected unsettled top-level await at .../bin/hypaware.js:67
  exitCode = await dispatch(argv)
exit=13

# after
$ script -qec "node bin/hypaware.js init < /dev/null" /dev/null
...
Choose [1-3, default 3]:
exit=0

Suite

  • npm test: 4098 tests, 4097 pass, 0 fail, 1 skipped (the pre-existing skip).
  • npm run typecheck: clean.

Docs

LLP 0190 is Status: Draft, so it is still editable. A new §eof-everywhere records both decisions, the shared home, and the askLineOnce/queuedLineAsker split; the eight new @refs point at it. LLP 0129 §fork is @ref'd as [constrained-by] from legacyMenuPrompt and [tests] from the test, and is not edited: it already settled that quit is the fork's default, and this only applies that to a stdin that cannot answer.

Expected conflict with #773

#773 is unmerged and this is based on master, as #783 asked. Two mechanical conflicts if #773 lands first:

  1. src/core/cli/walkthrough.js. This PR deletes queuedLineAsker from the file (lines 87-154) and imports it from ./line_asker.js; Wizard prompts take their printed default at EOF instead of hanging #773 adds three callers of it in the same file. The callers are far from the deleted block, so the likely outcome is a clean merge or a trivial one; the resolution in either direction is "keep Wizard prompts take their printed default at EOF instead of hanging #773's three call sites, keep the import, drop the local definition".
  2. llp/0190-wizard-defaults-gate.decision.md. Wizard prompts take their printed default at EOF instead of hanging #773 rewrites the paragraph ending "closing that class is a separate change"; this PR appends a new section a few lines below it. Both edits are wanted; take both. Note that after both land, Wizard prompts take their printed default at EOF instead of hanging #773's rewritten sentence ("The wizard's fork screen ... is the one prompt outside this file still asking through rl.question, and closing that is a separate change") is stale and should be dropped in the resolution.

Nothing here depends on #773 landing, and #773 does not depend on this.

Fixes #783

test and others added 2 commits August 15, 2026 02:33
…he OAuth paste

`node:readline`'s `rl.question()` leaves its promise permanently unsettled
when the input stream ends without a line. PR #773 fixed the three prompts
in `walkthrough.js`; the four sites outside it had the same defect. The
worst is the wizard's fork menu, which is the first screen `hyp init` shows,
so a terminal whose stdin dried up never got past it: exit 13 on an
unsettled top-level await when nothing else held the event loop, an
indefinite hang when something did.

Two decisions this makes, both stated in LLP 0190 #eof-everywhere:

- The asker lives in `src/core/cli/line_asker.js`, beside `stdio.js` and
  `flush-streams.js` in the tree's existing home for small shared CLI
  helpers, not in `src/core/util/` (fs and JSON) and not in a new top-level
  location. Plugin workspaces already import `src/core/...` by relative
  path, so `claude-account` reaches it the way it reaches the observability
  and usage-policy modules. `queuedLineAsker` moves there unchanged;
  `askLineOnce` joins it for the prompts that ask once on an interface that
  may be a real terminal, keeping `rl.question` as the thing that writes the
  query (readline redraws a terminal line from its own cursor bookkeeping)
  and replacing only its promise.

- EOF at the fork exits 0, not 130. The prompt prints `default 3` and
  LLP 0129 #fork settled that the default is Quit, so a spent stdin takes
  the answer the screen advertised; the fork's TUI path already returns
  `quit` for a real ctrl+c, so 130 in the readline fallback would judge a
  dropped terminal more harshly than the TUI judges a deliberate cancel.
  130 stays where LLP 0135's cancel put it: prompts whose enter answers
  nothing.

The `Code: ` paste in `claude-account login` is the one prompt with no
default, so it does not invent one. With no loopback listener left to finish
the sign-in, EOF is a failure that says so; with a listener up the paste lane
stays pending rather than losing a race the browser may still win.

Regression tests race every case against a 500ms timer, because the pre-fix
failure mode is a hang and an unraced assertion never runs. Nine of the
fourteen fail on master's behaviour and all fourteen pass here.
…785)

`rl.question` hands its answer back through a promise, a microtask
late, while `close` fires synchronously. A stdin that delivers the
line and the EOF in one burst - `Readable.from(['y\n'])`, the idiom
this repo's own stdin fixtures use, or any readable that pushes data
and `null` together - had `close` win that race, so a typed `y` at
an irreversible `[y/N]` was silently read as the printed no, and a
pasted OAuth code was silently discarded. `rl.question` alone got
these right, so the new asker was strictly worse than what it
replaced.

The EOF settlements now run a turn behind the answer, and the last
line of a stream that ends without a trailing newline (readline hands
that one to `line`, never to the pending question) is taken too.
Genuine EOF, spent streams, and real-terminal redraw are unchanged:
verified on a pty for both the paced-keystroke redraw and ctrl+D, and
for `hyp init < /dev/null` still exiting 0.

Also corrects the stale claim beside the OAuth race that closing the
interface rejects a pending question. It does not; that is the defect
being worked around.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with one fix, pushed. One confirmed correctness defect in the new askLineOnce (a silent wrong answer, not a hang), fixed on this branch as 2843b78. Both of the decisions the body puts up for challenge hold; I verified the first empirically on a pty rather than taking the reasoning on trust.

Reviewed manually against the brief: codex and code-review are not invocable in this environment, so this is a hand review plus experiments, not a tool run.

Decision 1: the askLineOnce / queuedLineAsker split - correct, and now empirically confirmed

The stated reason is right, and it is not a subtle margin. I ran both askers against the same prompt on a real pty (script -qec, stdin.isTTY and stdout.isTTY both true, so terminal: true), typing n, backspace, y, enter with pacing so readline processes each keystroke separately:

askLineOnce      ...Proceed? [y/N] n  <ESC>[1G<ESC>[0J Proceed? [y/N] y
queuedLineAsker  ...Proceed? [y/N] n  <ESC>[1G<ESC>[0J > y

On the refresh that the backspace forces, queuedLineAsker loses the query entirely and readline redraws its own default > in its place. So output.write(prompt) really is not equivalent to asking on a terminal, exactly as the body says. A single fast y\n paste hides it (readline appends the char without a refresh), which is why nothing but a paced pty run would have caught it. The reverse also checks out: under terminal: false all three of rl.question, queuedLineAsker and askLineOnce emit the identical byte sequence ["Proceed? [y/N] "].

Call sites are each on the right one. terminal: false (so queuedLineAsker): fork.js:397, plugin_install/confirm.js:161, and the four pre-existing walkthrough.js interfaces. No terminal option, so TTY-derived (so askLineOnce): cli/confirm.js:36 (output is ctx.stderr) and claude-account runLogin (output is cmdCtx.stdout). No interface's terminal setting was misread.

Decision 2: exit 0 rather than 130 - the argument holds

Every reference checks out, with one off-by-one in the body's prose only:

  • fork.js:121 if (isPromptCancelledError(err)) return 'quit' - correct.
  • The returning gate's copy of it is at fork.js:248, not :247 (:247 is the } catch (err) {). Prose only; nothing to change in the tree.
  • wizard/index.js:106 if (gate.action === 'quit') return { exitCode: 0 } - correct.
  • wizard/index.js:232 if (choice === 'quit') return { exitCode: 0 } - correct.

So the TUI path really does return exit 0 for a deliberate ctrl+c at the same screen, and 130 in the readline fallback would judge a dropped terminal more harshly than a deliberate cancel. LLP 0129 #fork confirms it independently ("The fork's position, values, and quit default are unchanged"), and LLP 0190 #sync-gate's rule reads on it: "A closed stdin takes that same default where enter has one". The fork's enter has one. I also reproduced the headline case end to end: script -qec "node bin/hypaware.js init < /dev/null" prints Choose [1-3, default 3]: and exits 0.

One consequence the body does not name, which I think is right but is worth stating out loud: with terminal: true readline handles ctrl+c itself by closing the interface, so ctrl+c at a [y/N] confirm now returns the printed no instead of hanging. That is the same rule and the safe direction, but it is a behaviour change beyond EOF.

Decision 3: the OAuth-paste analysis - correct

The Promise.race is real (index.js:214, against callback.result from the loopback listener at startCallbackServer). Both halves behave as claimed:

  • Listener up: pasteAuthorizationLane returns new Promise(() => {}), so it cannot settle the race. Verified by test 15, which races the lane against a 20ms callback and asserts the callback's value wins. If the lane settled early in either direction that assertion fails.
  • Listener down: callback is null, so runLogin awaits the lane directly, the lane throws, runLogin prints and returns 1. No hang.
  • Not folding null into the empty line is right: parsePastedAuthorization('') throws empty authorization code, which would misreport an unanswerable prompt as a malformed paste.

No unhandled-rejection path: the lane only rejects on a malformed paste or on the no-listener EOF, and Promise.race plus the explicit .catch both hold handlers.

Test evidence - load-bearing, exactly as claimed

I reverted the behaviour in all four files while keeping the extraction and the helper module, and reran. The result is the body's table byte for byte: # tests 14 / # pass 5 / # fail 0 -> # fail 9, with the same nine names failing and the same five guards passing. Every case is raced against a 500ms timer, which is the right shape for a pre-fix mode that is a hang.

LLP and conventions - clean

  • llp/0190 is Status: Draft, and #eof-everywhere exists at line 128. It is the only doc edited; llp/0129 is untouched and is @ref'd [constrained-by] from legacyMenuPrompt and [tests] from the test, as asked. No Accepted doc was edited.
  • All eight @refs to 0190#eof-everywhere resolve (the body says seven; it is eight, counting both exports in line_asker.js). All are attached to the construct below with no blank line breaking attachment.
  • No em dashes anywhere in the nine files, no semicolons, no @typedef, no inline import('...'), JSDoc types only. The plugin's relative reach into src/core/ matches heavy existing precedent (vector-search, context-graph, claude, format-iceberg, gascity), and claude-account has no package.json of its own, so nothing is being crossed that is not already crossed.
  • npm test 4098/4097 pass/1 skip and npm run typecheck clean on 171e0e9, matching the body.

Finding 1 (medium, fixed in 2843b78): askLineOnce discarded an answer that arrived, when the line and the EOF landed in the same burst

src/core/cli/line_asker.js:47-50 (as of 171e0e9)

rl.question hands its answer back through a promise, so done(line) runs one microtask late. rl.once('close', () => done(null)) runs synchronously. When a stream delivers the line and the EOF in a single synchronous burst, close won that race and the real answer was thrown away in favour of the printed default:

Readable.from(['y\n'])           plain rl.question -> "y"     askLineOnce -> null
new Readable({read(){ this.push('y\n'); this.push(null) }})   askLineOnce -> null

So the new asker was strictly worse than the rl.question it replaced for this input class, and the failure is silent: a typed y at hyp purge's or hyp attach's irreversible [y/N] reads as the safe no, and a pasted OAuth code is discarded with stdin ended before an authorization code was pasted.

Reachability: real process.stdin is safe. I checked a real pipe, a redirected file, and a pty, and all three interleave a microtask between the data and the end, so all three returned "y". What is not safe is an injected stdin, and Readable.from([...]) is this repo's dominant stdin fixture idiom - test/core/cli/wizard/fork.test.js:39, test/core/cli/wizard/back_navigation.test.js:121, test/plugins/claude-desktop-install.test.js in nine places. Those all pass today only because they land on queuedLineAsker, which reads line synchronously and is immune. The next test or caller written against askYesNo or the paste lane with Readable.from gets a silently inverted answer and a bug hunt in the wrong place. That is why I fixed rather than filed it: it is a defect in new code, in the direction of quietly wrong rather than loudly broken.

A second answer in the same class: readline routes the final line of a stream that ends without a trailing newline to line rather than to the pending question (emit('line', ...) from onend, bypassing kQuestionCallback), so printf 'y' | ... was also read as a decline. Not a regression - it was a hang before - but the same "an answer arrived and was dropped" statement, so the same fix covers it.

Fix (2843b78): the two EOF settlements are deferred a turn (setImmediate) so a delivered line always settles first, and rl.on('line', done) takes the answer readline routes past the question. Three tests added, each verified to fail without the fix and pass with it.

Re-verified after the fix:

  • npm test 4101 tests, 4100 pass, 0 fail, 1 skipped (the pre-existing skip). npm run typecheck clean.
  • The 17 EOF tests pass; reverting just the askLineOnce body fails exactly the 3 new ones and nothing else.
  • Genuine EOF is unchanged: askYesNo still declines on a stdin that ends without a line and on a spent one, the fork menu and both confirms still take their printed defaults, the paste lane still fails with no listener and still stays pending with one.
  • Real-terminal behaviour unchanged: the paced-keystroke pty run still redraws Proceed? [y/N] y correctly, ctrl+D on a pty still settles null and exits 0, and hyp init < /dev/null on a pty still exits 0.
  • Pipe, file, and no-trailing-newline stdin now all return the answer.

Finding 2 (nit, fixed in 2843b78): stale claim beside the OAuth race

hypaware-core/plugins-workspace/claude-account/src/index.js:211

"readline close (finally) rejects a pending question, so keep that rejection handled" is not true of readline/promises - close neither resolves nor rejects a pending question, which is precisely the defect this PR exists to work around. The comment sat two lines from the fix for it, on a statement the PR rewrote, so I corrected it to name what the .catch actually guards (a malformed paste landing after the callback already won the race). No behaviour change.

Left unfixed

Nothing blocking. Two preferences, both the author's call:

  • Preference, not a blocker: llp/0190 is formally Status: Draft, so editing it is allowed, but it already carries Extended-by: LLP 0201 and is implemented, which makes it a record in all but the status line. Appending a decision that reaches well outside 0190's own subject (plugin_install/confirm.js, a plugin's OAuth lane, a tree-wide exit-code rule) is closer to a new decision than to the editorial fixes the immutability rule carves out. It reads well where it is and I am not asking for it to move; I am noting that a reviewer could reasonably have wanted a new LLP @refing 0190 instead.
  • Prose nit, no change needed: the body's fork.js:247 is :248, and "seven new @refs" is eight.

The expected mechanical conflict with #773 on walkthrough.js and llp/0190 is as documented and is not a finding. I confirmed the three rl.question sites still in walkthrough.js at :253, :376 and :444 are exactly the three #773 closes, so "none left" is accurate once both land.

)

The `rl.on('line', done)` added in 2843b78 takes the LAST line of a
burst rather than the first. Readline emits `line` only while no
question is pending, so the handler fires for everything typed or
pasted past the answer, and it fires synchronously while the question
hands its own answer back a microtask later. A stdin delivering
"n\ny\n" in one chunk therefore answered `y`.

That is the one direction an irreversible `[y/N]` must never drift in:
`hyp purge`, `hyp report delete` and `hyp attach`'s enable prompt read
a typed `n` as a confirmation. Reproduced on a real pty, where a paced
`n` is still correct but a pasted "n\ny\n" proceeded with the delete;
both `rl.question` and the pre-2843b78 asker returned "n" there, so it
was a regression introduced by the fix rather than a pre-existing gap.

The line is now held rather than settled on, and read only at EOF and
only if the question never answered. That keeps 2843b78's two cases
(an answer delivered in the same burst as the EOF, and the
unterminated last line readline routes past the question) and drops
the overwrite. On every input where `rl.question` answers at all,
`askLineOnce` now returns exactly what it returns.

Three regression tests, each verified to fail against 2843b78's body
and pass against this one.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with one fix, pushed. Round 2 of 2, reviewing 2843b78 (round 1's own fix). That fix closed one silent-wrong-answer defect and opened another in the same handful of lines, in the same direction. Fixed on this branch as 75a3eff, which is now head. Everything else round 1 settled still holds.

Reviewed manually: codex and code-review are not invocable here, so this is a hand review plus experiments on a real pty, not a tool run.


Finding 1 (high, fixed in 75a3eff): askLineOnce answered with the LAST line of a burst, not the line that answered the question

src/core/cli/line_asker.js:64 as of 2843b78 (rl.on('line', done))

Readline emits line only while no question is pending ([kOnLine] hands the line to kQuestionCallback if there is one, and emits line otherwise). So the handler 2843b78 added fires for two different things: the unterminated last line of a stream, which is what it was added for, and anything that arrives past the answer. And it fires synchronously, while rl.question hands its own answer back a microtask later, so past-the-answer wins.

A stdin delivering n\ny\n in one chunk therefore answered y:

input        rl.question   pre-2843b78   2843b78     75a3eff
'y\n'          "y"            "y"          "y"         "y"
'n\ny\n'       "n"            "n"          "y"   <--   "n"
'n\ny'         "n"            "n"          "y"   <--   "n"
'y\nzzz'       "y"            "y"          "zzz" <--   "y"

That is the one direction an irreversible [y/N] must never drift in. askYesNo is the confirm behind hyp purge (purge.js:61), hyp report delete (report_commands.js:361) and hyp attach's enable prompt (clients.js:708); each is TTY-gated, and each read a typed n as a confirmation.

Reproduced end to end on a real pty, which is the only environment those three gates admit:

$ printf 'n\ny\n' | script -qec "node prog.js" /dev/null     # prog.js = askYesNo on process.stdin
2843b78:  Delete everything? [y/N] n / y   RESULT=true    <-- proceeds with the delete
75a3eff:  Delete everything? [y/N] n / y   RESULT=false

Paced keystrokes are safe on a pty (the microtask drains between them), so what reaches this is a paste or type-ahead for the next prompt, plus every injected stdin. It is a regression rather than a pre-existing gap: on that same pty input both plain rl.question and the pre-2843b78 askLineOnce returned "n". The same shape hit the OAuth lane, where a second line in the paste replaced the code.

Fix (75a3eff): the line is held rather than settled on, and read only at EOF and only if the question never answered.

let unaskedLine = null
const endOfInput = () => setImmediate(() => done(unaskedLine))
rl.once('close', endOfInput)
rl.on('line', (line) => { unaskedLine = line })
rl.question(prompt).then(done, () => done(null))

This keeps both of 2843b78's cases and drops the overwrite, and it is order-independent rather than resting on two setImmediates landing in the right sequence. On every input where rl.question answers at all, askLineOnce now returns exactly what rl.question returns; where rl.question hangs it still settles. Three regression tests added (two in test/core/readline-prompt-eof.test.js, one in test/plugins/claude-account-paste-eof.test.js), each verified to fail against 2843b78's body and pass against this one.

The rest of 2843b78, checked against the hazards a timing fix invites

  • Double settlement: no. settled is set before resolve, and every path goes through done.
  • A line leaking into the next prompt: no. Every call site builds its own interface immediately before asking and closes it in a finally (confirm.js:36-44, plugin_install/confirm.js:161, claude-account runLogin:203-236), so the listener cannot outlive its prompt. askLineOnce is never called twice on one interface, so the un-removed line listener does not accumulate either. It was the same-prompt leak that was wrong, and that is finding 1.
  • A fast EOF missed by the setImmediate deferral: no. A pending immediate keeps the loop alive for that turn, so the deferral moves the settlement, it does not lose it. Re-verified on the headline case: script -qec "node bin/hypaware.js init < /dev/null" still prints Choose [1-3, default 3]: and exits 0, and ctrl+D on a pty [y/N] still returns the printed no.
  • Unhandled rejection: no. rl.question(prompt).then(done, () => done(null)) holds both settlements, and the lane's .catch is separate.
  • Round 1's three tests are load-bearing. Reverted 2843b78's behaviour while keeping its structure (helper module, extraction, endOfInput name): exactly tests 8, 9 and 16 fail, nothing else. Same check for my three: exactly 10, 11 and 19 fail against 2843b78's body.

The LLP 0190 question, answered for triage

Recommendation: leave #eof-everywhere where it is. No new LLP, no change to this PR. Round 1 flagged this as a reviewer's-preference and did not settle it; it settles cleanly against the corpus rather than against taste.

  1. The rule keys on status, and names the exception. CLAUDE.md: "Once an LLP is Accepted or Active, do not edit what it settled" and "Drafts are still editable." 0190 is Status: Draft. Extended-by: is a forward-ref, not a status: 0201 is itself Status: Draft, so reading Extended-by: as promotion to a record would mean one Draft freezes another.
  2. The repo has already done exactly this, to exactly this document. 7c1f187 (Numbered fallback re-asks once on an answer naming no row (#634) #648) added 41 lines to 0190 #sync-gate after 0190's code shipped in 7116f95, and said so in its own commit message ("LLP 0190 #sync-gate amended (Draft)"). 4fb95dc (Integrate the three wizard PRs (#675, #674, #677): conflicts resolved, LLP 0200 collision renumbered, cross-PR seams closed #679) amended it again. "Implemented" has not made a Draft a record here before, and singling this one out would be a new rule, not the existing one.
  3. The content is 0190's own unfinished sentence. #sync-gate already states the rule ("A closed stdin takes that same default where enter has one") and explicitly defers the remainder ("The file's other readline prompts ... still hang at EOF; closing that class is a separate change"). A new LLP would have to restate 0190's rule in order to extend it; the append finishes a sentence 0190 wrote.

The one honest counter, and the reason this is a judgement rather than a formality: 0190's title and Systems: (Onboarding, CLI) are narrower than a rule that reaches plugin_install/confirm.js and a plugin's OAuth lane. If a maintainer wants the tree-wide rule discoverable on its own, the cheap move is to flip 0190 to Accepted when the wizard work settles and mint the next EOF-class change as a new LLP @refing it. That is future work either way, and not something this PR should carry. I have not minted anything; that is a maintainer call.

Also checked, no finding

  • pasteAuthorizationLane extraction. The asymmetry is right and is pinned by tests 17 (listener wins the race over a lane that stays pending) and 15/16 (no listener, EOF is a failure that says so). new Promise(() => {}) cannot settle the race; runLogin's finally closes the interface and the listener, so the pending lane holds nothing open past the command. 2843b78's comment correction at index.js:211 is right: closing the interface does not reject a pending question, which is the defect being worked around.
  • Pre-existing, out of scope, flagging only because the extraction is where you would notice it: a bare enter at the Code: prompt (empty line, not EOF) makes parsePastedAuthorization('') throw empty authorization code, which settles the race and aborts a browser sign-in still on its way. That is master's behaviour verbatim (rl.question('Code: ').then(parsePastedAuthorization)), unchanged by this PR. Not a finding against Close the rl.question EOF-hang class: the wizard fork menu, both y/N confirms, and the OAuth paste #785.
  • Conventions on both commits: no em dashes, no semicolons, no @typedef, no inline import('...'). @refs unchanged and still resolving.

Suite, on 75a3eff

  • npm test: 4104 tests, 4103 pass, 0 fail, 1 skipped (the pre-existing skip).
  • npm run typecheck: clean.
  • pty re-checks: hyp init < /dev/null exit 0; ctrl+D at a [y/N] returns the printed no; a paced y still returns yes; a pasted n\ny\n now declines.

Blocker vs preference, for triage

Fixed, nothing to classify: finding 1 (blocker-class had it been left: a silent wrong answer at hyp purge / hyp report delete / hyp attach). Fixed in 75a3eff.

Left unfixed, all preference:

  1. LLP 0190 append (round 1 carried this forward as an open preference). Preference; recommend closing as no-change on the three grounds above. Nothing in the tree changes either way.
  2. PR body prose: fork.js:247 is :248, and "seven new @refs" is eight. Preference. Cosmetic, body-only, and the review rung does not edit PR bodies, so it is left for whoever merges.

Nothing left needs a human decision beyond item 1, and item 1 has a recommendation rather than an open question.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage verdict: ship. Head 75a3eff verified independently; nothing unresolved blocks the merge. Deferred findings recorded in #786.

Independent verification of the current askLineOnce

Ran a fresh differential harness (current head vs plain rl.question, fresh interface per case) rather than rereading the tests. On every input where rl.question answers, askLineOnce returns the same value; where rl.question hangs, it settles:

input rl.question askLineOnce
paced y\n "y" "y"
y\n + EOF in one burst "y" "y"
final y, no trailing newline, then EOF hang "y"
n\ny\n one chunk "n" "n"
n\ny one chunk "n" "n"
y\nzzz (junk after answer) "y" "y"
EOF, nothing typed hang null
already-spent stream hang null
unterminated y, stream still open hang (correctly) hang (correctly)

Repeated prompts on one interface behave exactly like repeated rl.question calls (a line arriving between questions is dropped by both, not queued); no call site asks twice on one interface, and the JSDoc says ask once. Process-level headline re-confirmed: script -qec "node bin/hypaware.js init < /dev/null" prints Choose [1-3, default 3]: and exits 0.

Both rounds' tests are load-bearing: spot-checked

Reverting askLineOnce to 2843b78's body fails exactly tests 10, 11, 19. Reverting to 171e0e9's original body fails exactly 8, 9, 18, 19 (round 2 reported "8, 9 and 16" against the 17-test file; 16 is now 18 after round 2's additions, and 19 also catches the original body). Full suite on head: 4104 tests, 4103 pass, 1 pre-existing skip; typecheck clean.

Call sites and the paste lane

All four sites use the right asker for their interface's terminal setting; the three rl.question left in walkthrough.js (:253, :376, :444) are exactly the three #773 closes. pasteAuthorizationLane with a listener up returns a forever-pending promise, so it cannot settle the race early, and runLogin's finally closes both the interface and the listener; with no listener it throws and runLogin exits 1.

Classification of what was unresolved

  1. Stray npm-install.log at repo root, committed by 75a3eff itself (the round 2 fix commit; neither review round noticed it). Non-blocking: excluded from the npm publish file set, no runtime effect. Deferred to Follow-up: deferred review findings from PR #785 #786 for removal after merge.
  2. LLP 0190 #eof-everywhere placement: preference, closed as no-change. Verified round 2's grounds myself: 0190 is Status: Draft; 7c1f187 (Numbered fallback re-asks once on an answer naming no row (#634) #648) and 4fb95dc (Integrate the three wizard PRs (#675, #674, #677): conflicts resolved, LLP 0200 collision renumbered, cross-PR seams closed #679) amended this same document after its code shipped; #sync-gate explicitly deferred this class ("closing that class is a separate change", line 121). The Systems-narrowness counter is a real but future-facing discoverability point, recorded in Follow-up: deferred review findings from PR #785 #786.
  3. PR body prose nits: fixed in the body (:247 corrected to :248; "seven new @refs" corrected to eight, which I recounted: six [implements] plus two [tests]). The body is the squash-merge message, so this was worth doing.

One behaviour change beyond EOF that round 1 already named, restated for the merger: with terminal: true readline handles ctrl+c by closing the interface, so ctrl+c at a [y/N] confirm now returns the printed no instead of hanging. Same rule, safe direction, deliberate.

It was picked up by the round 2 fix commit. It is not gitignored and is
not in the published files set, so it has no runtime effect, but it would
otherwise land on master as build noise.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Re-triage at head a1787e0. The only delta since the cleared head 75a3eff is one commit deleting npm-install.log from the repo root (11 lines of build output committed by accident in the round 2 fix; its removal was the substantive item of #786). Verified the diff contains nothing else, the file is gone from the branch, and nothing references it. The prior clearance stands: previous triage marker, the Fixes #783 trailer, and the corrected body prose are all intact, and CI is green at a1787e0. No new findings; #786 continues to track the deferred non-blocking items. Marker for the new head appended to the PR body.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Heads up from the #786 follow-up (PR #796, which adds a *.log ignore rule plus a lint asserting no .log is tracked): this branch still carries an x/ scratch directory, including x/npm-test.log and x/typecheck.log alongside x/burst.mjs, x/invert.mjs, x/line_asker.fixed.js, x/line_asker.orig.js, x/msg.txt, x/order.mjs, x/pty_prog.mjs, and x/pty_variants.mjs. Same class as the npm-install.log already removed in a1787e0.

Whichever of the two lands second will need the other rebased in; if #796 goes first, the new lint fails here until x/ is dropped.

@philcunliffe
philcunliffe merged commit adb448a into master Aug 18, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-783 branch August 18, 2026 19:24
philcunliffe added a commit that referenced this pull request Aug 18, 2026
… with a lint on the tree (#796)

* Tool transcripts cannot be committed again: .gitignore refuses *.log, with a lint on the tree

Triage of PR #785 found an npm-install.log committed at the repo root. It was
removed by hand, but nothing in the toolchain had objected to it: it sits
outside the package files allowlist, touches no code path, and .gitignore
carried no rule for tool transcripts, so git status listed it as an ordinary
new file. Only a human reading the diff caught it.

This closes the class from both ends. .gitignore now ignores *.log, so an
install/test/typecheck transcript never reaches git add. test/core/
repo-scratch-hygiene.test.js asserts both halves: no .log is tracked, and the
ignore rule actually covers the paths that showed up (root and nested).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Ignore-rule probe reads the rules, not the index, so a tracked .log names the right fix

* Ignore probe answers from the committed .gitignore, not a per-clone ignore source

---------

Co-authored-by: test <test@test.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: neutral <review@neutral>
philcunliffe added a commit that referenced this pull request Aug 18, 2026
…#853)

`test/core/repo-scratch-hygiene.test.js` has been failing on `master` since it
merged: `x/npm-test.log` and `x/typecheck.log` are tracked, and the test's first
half asserts no `.log` is. Both came in on `adb448ab` (#785) via the `git add -A`
sweep that #786 wrote this test to catch; the files predate the test, so it was
red on arrival. Every branch cut since inherits it, currently blocking #833,
#849, #850 and #851 for a reason none of them caused.

The transcripts are deleted rather than the test relaxed, which is what its
message asks for. `.gitignore` needs nothing: `*.log` is already committed and
the rule test already passes, since an ignore rule cannot reach a path that is
already tracked. That asymmetry is the whole reason the file carries two tests.

Scope is exactly the two `.log` paths. The other eight files under `x/` are
untouched: the hygiene test forbids tracked transcripts and nothing else, and
sweeping up scratch it does not name would be a judgement this fix has no
authority to make.

Fixes #852

Co-authored-by: test <test@test.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
x/npm-test.log and x/typecheck.log are tracked leftovers from the
git add -A sweep in #785 (pre-dating the repo-scratch-hygiene test
added by #786). master already carries this fix via #853; this
branch forked before that landed, so it inherited the red test
separately. Same fix, scoped to the same two paths.
philcunliffe added a commit that referenced this pull request Aug 19, 2026
…sage a failed batch consumed (#851)

* Deferred #843 findings: the listener's counters, its refusal log, and the usage a failed batch consumed

Six of the ten findings deferred from PR #818, the ones that are a bug with a
reproducing test rather than a design call.

- A batch whose message write fails no longer loses the usage it claimed. The
  usage index outlives one POST because an `api_request` and the
  `assistant_response` that names its `request_id` routinely arrive in
  different flushes; projection claimed the entry, the write then failed, and
  the exporter's retry re-projected against a drained index and wrote the same
  rows with no `attributes.usage` and no `claude.cost_usd`. The catch puts
  back only what the batch consumed.
- `client_attach_stale` no longer fires at an `otel`-attached client. The
  marker records the gateway port that mode never uses, so a gateway rebind
  printed a re-attach warning at a client the rebind did not touch;
  `client_telemetry_stale` already watches the port that decides whether
  anything is captured.
- A refused `body_ref` is logged as a 12-hex digest, not as the raw
  wire-supplied path (LLP 0257 S23).
- `deleteSpooledBodies` counts what it removed. `fs.rm(..., { force: true })`
  succeeds on a missing path, so `bodies_deleted` and `bodies_dropped`
  over-reported every already-evicted ref.
- A policy drop brings `spool_bytes` down with the files it deleted, instead
  of leaving the gauge high until the next sweep restates it.
- `last_event_at` is maxed by instant, not by string. Mixed `Z` / `.000Z` /
  offset shapes let the capture-gap baseline run backwards.

The other six findings stay open on the issue: the picker's proxy-mode
contradiction between two Accepted docs, the `dedupeStoredPartIds` scan cost,
`hyp session status` reading only the gateway, the `lastEventAt` stamping
order, the telemetry-endpoint prevention half, and the two shapes in finding
10 - each needs a decision this PR is not the place to make.

Refs #843

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete two committed tool transcripts holding this branch red

x/npm-test.log and x/typecheck.log are tracked leftovers from the
git add -A sweep in #785 (pre-dating the repo-scratch-hygiene test
added by #786). master already carries this fix via #853; this
branch forked before that landed, so it inherited the red test
separately. Same fix, scoped to the same two paths.

* Review fixes: a malformed stamp cannot pin last_event_at, and spool_bytes counts what left the disk

Two follow-ups from the review of this branch, both in the code it added.

`newerEventTimestamp` fell back to the string compare whenever EITHER side
failed to parse. `event.timestamp` is read off the wire unvalidated
(`telemetry/events.js` takes whatever string the attribute carried), so a
producer that stamps a non-date wins that compare outright - nothing an ISO
stamp can begin with sorts above `u` in `unknown` - and because the fallback
is symmetric it then beats every genuinely newer event that follows. The
baseline is pinned for the life of the daemon, `hyp status` parses it to
undefined, falls back to `listener_started_at`, and raises `capture_gap`
against a listener that is capturing fine. A value that names an instant now
beats one that names none, whichever side it arrives on; the string compare
survives only for the pair where neither parses.

The projected-body arm still subtracted `spooled.consumedBytes`, the bytes
READ, while the PR added `bytesRemoved` precisely so the gauge tracks what
left the disk. A body whose unlink fails (EPERM, a read-only spool) is still
occupying the cap, and deducting it under-reported `spool_bytes` until the
next sweep restated it: the drop arm's bug in the other direction.

The new listener case fails on 40e40f9 and passes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Review fixes: a failed batch's usage restore respects the index cap, and a ref stops citing S18 for something S18 does not say

The catch-block restore reinstated every key missing from the pre-projection
snapshot, which includes the ones USAGE_INDEX_LIMIT evicted during the batch,
not only the ones projection claimed. During a sustained dataset outage nothing
is ever claimed and nothing ever shrinks the index, so each failed batch added
its `api_request` entries on top of a map that could no longer trim itself and
the 512 cap stopped bounding anything.

The restore is now `restoreUnclaimedUsage`, which puts the snapshot back and
re-applies the cap, with a unit test that drives 40 failed batches through the
real projector and asserts the index settles at the cap instead of growing past
it (it reaches 576 by round 8 without the trim).

The `@ref LLP 0257#failure-modes` glosses on the source and on the accounting
test cited "S18 - a retried batch is re-projected from the same inputs, so its
inputs have to survive". S18 says delivery is best effort and that a down
daemon's behavioral-event loss is accepted; it says nothing about retry inputs
surviving. 0257 is Accepted, so the mis-citation is removed rather than the
spec edited, and the prose above each already carries the reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: neutral <neutral@hyperparam.app>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: close the remaining rl.question EOF-hang class

1 participant