Repository navigation
fix(onboard): cancel on stdin EOF typed between prompts #12216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,020
−38
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
b6dba04
fix(onboard): cancel on stdin EOF typed between prompts
yanyunl1991 0a6d42d
fix(cli): keep prompt transcript and piped answers on EOF recovery
yanyunl1991 e0c7487
Merge remote-tracking branch 'origin/main' into fix/prompt-pending-st…
yanyunl1991 23cc67e
Merge branch 'main' into fix/prompt-pending-stdin-eof-12169
yanyunl1991 fd4cdf5
fix(credentials): cancel masked prompts on Ctrl-D
yanyunl1991 cdba111
Merge branch 'main' into fix/prompt-pending-stdin-eof-12169
yanyunl1991 1a4c51c
Merge branch 'main' into fix/prompt-pending-stdin-eof-12169
yanyunl1991 e073b28
fix(onboard): cancel every raw-mode menu on Ctrl-D
yanyunl1991 7091471
Merge branch 'main' into fix/prompt-pending-stdin-eof-12169
rsliter 1ad6cae
test(policy): cover a terminal Ctrl-D queued before the preset picker
yanyunl1991 65826ac
test(onboard): cover EOF cancellation in the Hermes managed-tools sel…
yanyunl1991 47756b6
Merge remote-tracking branch 'origin/main' into fix/prompt-pending-st…
yanyunl1991 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { PassThrough } from "node:stream"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { takePendingStdinEof } from "./pending-stdin-eof"; | ||
|
|
||
| type PromptInput = PassThrough & { isTTY?: boolean }; | ||
|
|
||
| function makeInput(isTTY: boolean | undefined): PromptInput { | ||
| const input = new PassThrough() as PromptInput; | ||
| input.isTTY = isTTY; | ||
| input.pause(); | ||
| return input; | ||
| } | ||
|
|
||
| describe("takePendingStdinEof", () => { | ||
| it("returns false synchronously for piped stdin so prompt timing is unchanged", () => { | ||
| expect(takePendingStdinEof(makeInput(false), 10)).toBe(false); | ||
| expect(takePendingStdinEof(makeInput(undefined), 10)).toBe(false); | ||
| }); | ||
|
|
||
| it("reports the cancellation when stdin ends during the window (#12169)", async () => { | ||
| const input = makeInput(true); | ||
| const pending = takePendingStdinEof(input, 50); | ||
| input.end(); | ||
|
|
||
| await expect(pending).resolves.toBe(true); | ||
| }); | ||
|
|
||
| it("reports the cancellation when stdin closes without ending", async () => { | ||
| const input = makeInput(true); | ||
| const pending = takePendingStdinEof(input, 50); | ||
| input.emit("close"); | ||
|
|
||
| await expect(pending).resolves.toBe(true); | ||
| }); | ||
|
|
||
| it("reports no cancellation for a quiet terminal and leaves stdin paused", async () => { | ||
| const input = makeInput(true); | ||
|
|
||
| await expect(takePendingStdinEof(input, 5)).resolves.toBe(false); | ||
| expect(input.isPaused()).toBe(true); | ||
| }); | ||
|
|
||
| it("hands type-ahead back so the prompt still answers with it", async () => { | ||
| const input = makeInput(true); | ||
| const pending = takePendingStdinEof(input, 50); | ||
| input.write("2\n"); | ||
|
|
||
| await expect(pending).resolves.toBe(false); | ||
| expect(input.read()?.toString()).toBe("2\n"); | ||
| }); | ||
|
|
||
| it("answers with input that arrives ahead of a closing stdin", async () => { | ||
| const input = makeInput(true); | ||
| const pending = takePendingStdinEof(input, 50); | ||
| input.write("yes\n"); | ||
| input.end(); | ||
|
|
||
| await expect(pending).resolves.toBe(false); | ||
| expect(input.read()?.toString()).toBe("yes\n"); | ||
| }); | ||
|
|
||
| it("stops listening once it settles so the prompt owns stdin alone", async () => { | ||
| const input = makeInput(true); | ||
|
|
||
| await takePendingStdinEof(input, 5); | ||
|
|
||
| expect(input.listenerCount("data")).toBe(0); | ||
| expect(input.listenerCount("end")).toBe(0); | ||
| expect(input.listenerCount("close")).toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| /** How long to let a queued terminal read complete before a prompt starts. */ | ||
| export const PENDING_STDIN_WINDOW_MS = 10; | ||
|
|
||
| interface PendingStdinInput extends NodeJS.ReadableStream { | ||
| isTTY?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Recover a Ctrl-D that reached the terminal while no prompt was reading. | ||
| * | ||
| * Interactive prompts own stdin only while a question is outstanding: each | ||
| * prompt pauses stdin when it settles so onboarding steps between questions | ||
| * (and any child process sharing the terminal) are not competing for input. | ||
| * A Ctrl-D typed inside one of those gaps — the reporter's "close stdin" | ||
| * while the Ollama model loads and the configuration summary prints — stays | ||
| * queued in the terminal, because nothing is reading it yet. | ||
| * | ||
| * The next prompt never sees it. `readline.createInterface()` switches a TTY | ||
| * to raw mode before its first read, and the queued end-of-file indication | ||
| * does not survive that mode change; in raw mode the byte would no longer | ||
| * mean EOF either. The prompt therefore waits for an answer that can never | ||
| * arrive: `nemoclaw onboard` hangs at `Choose [1]:` instead of reporting the | ||
| * cancellation and exiting non-zero (#12169). A Ctrl-D typed while the | ||
| * question is on screen is unaffected — that one reaches readline directly. | ||
| * | ||
| * Reading stdin for one queued-read window before the prompt takes over | ||
| * surfaces the pending EOF as a normal stream `end`. Anything that is real | ||
| * input instead (type-ahead, or piped answers that close the pipe behind | ||
| * them) ends the window immediately and is pushed back, so the prompt still | ||
| * answers with it and observes any EOF that follows exactly as before. Only a TTY needs this: a pipe or `< /dev/null` reports its EOF through | ||
| * the same stream end whenever the prompt gets around to reading, so those | ||
| * callers are left completely untouched. | ||
| * | ||
| * Returns `false` — synchronously, not a promise — when there is nothing to | ||
| * recover, so every non-TTY caller keeps today's exact prompt timing and only | ||
| * a real terminal pays the window. Otherwise resolves `true` when stdin ended | ||
| * during the window, meaning the caller must cancel instead of asking its | ||
| * question. | ||
| */ | ||
| export function takePendingStdinEof( | ||
| input: PendingStdinInput = process.stdin, | ||
| windowMs: number = PENDING_STDIN_WINDOW_MS, | ||
| ): Promise<boolean> | false { | ||
| if (input.isTTY !== true) return false; | ||
| return new Promise((resolve) => { | ||
| const chunks: Array<string | Uint8Array> = []; | ||
| let settled = false; | ||
|
|
||
| function onData(chunk: string | Uint8Array): void { | ||
| chunks.push(chunk); | ||
| // Input beats a queued EOF: an answer that is already typed (or piped) | ||
| // must still answer the question, and the EOF behind it is then the | ||
| // prompt's own to observe. | ||
| finish(false); | ||
| } | ||
|
|
||
| function finish(ended: boolean): void { | ||
| if (settled) return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| input.removeListener("data", onData); | ||
| input.removeListener("end", onEnd); | ||
| input.removeListener("close", onEnd); | ||
| if (typeof input.pause === "function") input.pause(); | ||
| // Restore type-ahead in arrival order. After `end` the stream rejects | ||
| // unshift(), and the buffered text is moot anyway: the caller cancels. | ||
| if (!ended) { | ||
| for (let index = chunks.length - 1; index >= 0; index -= 1) { | ||
| input.unshift(chunks[index]); | ||
| } | ||
| } | ||
| resolve(ended); | ||
| } | ||
|
|
||
| function onEnd(): void { | ||
| finish(true); | ||
| } | ||
|
|
||
| // Deliberately ref'd: an unref'd window would let the process exit | ||
| // mid-prompt, which is the silent exit-0 shape of this very bug. | ||
| const timer = setTimeout(() => finish(false), windowMs); | ||
| input.on("data", onData); | ||
| // Both settle the prompt readers, so both count as a closed stdin here. | ||
| input.once("end", onEnd); | ||
| input.once("close", onEnd); | ||
| if (typeof input.resume === "function") input.resume(); | ||
| }); | ||
| } | ||
|
|
||
| /** The rejection every prompt uses for a stdin EOF that arrived before an answer. */ | ||
| export function pendingStdinEofError(): Error { | ||
| return Object.assign(new Error("Prompt closed before input"), { code: "EOF" }); | ||
| } | ||
|
|
||
| /** | ||
| * Reject a prompt whose cancellation was already queued, after echoing the | ||
| * question. Callers that reach EOF while the question is on screen leave it | ||
| * there, so echoing keeps the transcript identical either way: operators and | ||
| * the CLI contract tests both see the question that was cancelled. | ||
| */ | ||
| export function cancelPromptWithPendingEof( | ||
| question: string, | ||
| output: NodeJS.WritableStream = process.stderr, | ||
| ): never { | ||
| output.write(question); | ||
| throw pendingStdinEofError(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.