Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5c1aa9e
fix(uninstall): stop confirm prompt auto-abort on TTY
hunglp6d Jun 10, 2026
24e1a5b
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 10, 2026
11ebf71
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
fb68b82
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
fa21ef4
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
8008e2d
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
4ac0d6c
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
e6814c2
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
2cac99a
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
886ab98
fix(uninstall): extract stdin line reader from uninstall run-plan
hunglp6d Jun 11, 2026
a0cec8e
fix(uninstall): pty coverage for prompt waiting on non-blocking fd 0
hunglp6d Jun 11, 2026
75d9b88
fix(uninstall): state stdin retry boundary and removal condition comment
hunglp6d Jun 11, 2026
c356d39
fix(uninstall): bound non-TTY stdin retries; harden test env isolation
hunglp6d Jun 11, 2026
f793ab8
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
6d9b49a
Merge branch 'fix/uninstall-prompt-eagain-abort' of github.com:NVIDIA…
hunglp6d Jun 11, 2026
7466e99
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
65da466
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 11, 2026
1ea8392
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 12, 2026
f5bcec6
Merge branch 'main' into fix/uninstall-prompt-eagain-abort
hunglp6d Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 77 additions & 7 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import path from "node:path";

import { describe, expect, it, vi } from "vitest";

import { buildRunPlan, runUninstallPlan, type RunResult } from "./run-plan";
import { buildRunPlan, type RunResult, runUninstallPlan } from "./run-plan";

function ok(stdout = ""): RunResult {
return { status: 0, stdout, stderr: "" };
Expand Down Expand Up @@ -77,6 +77,7 @@ describe("uninstall run plan", () => {
commandExists: () => true,
env: {
HOME: "/tmp/nemoclaw-uninstall-test",
NEMOCLAW_AGENT: "",
TMPDIR: "/tmp/nemoclaw-uninstall-test",
} as NodeJS.ProcessEnv,
existsSync: () => false,
Expand Down Expand Up @@ -206,7 +207,7 @@ describe("uninstall run plan", () => {
const result = runUninstallPlan(
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv,
env: { HOME: "/tmp/nemoclaw-uninstall-test", NEMOCLAW_AGENT: "" } as NodeJS.ProcessEnv,
existsSync: () => false,
isTty: true,
log: (line) => logs.push(line),
Expand Down Expand Up @@ -241,6 +242,53 @@ describe("uninstall run plan", () => {
expect(run).not.toHaveBeenCalled();
});

it("explains how to proceed when stdin yields no input at the confirm prompt", () => {
const logs: string[] = [];
const run = vi.fn();
const result = runUninstallPlan(
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv,
log: (line) => logs.push(line),
readLine: () => null,
run,
},
);

expect(result.exitCode).toBe(0);
expect(logs).toContain(
"No input available on stdin (closed or non-interactive); re-run with --yes to skip this prompt.",
);
expect(logs).toContain("Aborted.");
expect(run).not.toHaveBeenCalled();
});

it("builds the default runtime without touching process.stdin (#5188)", () => {
const stdinGet = vi.spyOn(process, "stdin", "get");
try {
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: () => false,
env: { HOME: "/tmp/nemoclaw-uninstall-test" } as NodeJS.ProcessEnv,
existsSync: () => false,
kill: () => true,
log: () => {},
rmSync: vi.fn(),
run: vi.fn(() => ok()),
runDocker: () => ok(""),
// isTty/readLine intentionally not injected: the default
// isStdinTty/readLineFromStdin pair must never instantiate
// process.stdin, which would flip fd 0 non-blocking (#5188).
},
);
expect(result.exitCode).toBe(0);
expect(stdinGet).not.toHaveBeenCalled();
} finally {
stdinGet.mockRestore();
}
});

it("kills the Ollama auth proxy via the persisted PID file (#2759)", () => {
const logs: string[] = [];
const killed: number[] = [];
Expand Down Expand Up @@ -580,7 +628,14 @@ describe("uninstall run plan", () => {
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: (command) => command !== "docker" && command !== "pgrep",
env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
// Neutralize NEMOCLAW_NON_INTERACTIVE: the runtime merges the real
// process.env, so a developer shell exporting it would silently flip
// this interactive scenario onto the non-interactive path.
env: {
HOME: "/home/test",
NEMOCLAW_NON_INTERACTIVE: "",
TMPDIR: "/tmp/test",
} as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: (target) =>
target === "/swapfile" || target === "/home/test/.nemoclaw/managed_swap",
Expand Down Expand Up @@ -664,7 +719,10 @@ describe("uninstall run plan", () => {
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: () => false,
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
env: {
HOME: tmpHome,
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
} as NodeJS.ProcessEnv,
existsSync: tempScopedExistsSync(tmpHome),
isTty: false,
log: (line) => logs.push(line),
Expand Down Expand Up @@ -735,7 +793,11 @@ describe("uninstall run plan", () => {
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
commandExists: () => false,
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
env: {
HOME: tmpHome,
NEMOCLAW_NON_INTERACTIVE: "",
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
} as NodeJS.ProcessEnv,
existsSync: tempScopedExistsSync(tmpHome),
isTty: true,
log: (line) => logs.push(line),
Expand Down Expand Up @@ -763,7 +825,11 @@ describe("uninstall run plan", () => {
{ assumeYes: false, deleteModels: false, keepOpenShell: true },
{
commandExists: () => false,
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
env: {
HOME: tmpHome,
NEMOCLAW_NON_INTERACTIVE: "",
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
} as NodeJS.ProcessEnv,
existsSync: tempScopedExistsSync(tmpHome),
isTty: true,
log: (line) => logs.push(line),
Expand Down Expand Up @@ -799,6 +865,7 @@ describe("uninstall run plan", () => {
env: {
HOME: tmpHome,
NEMOCLAW_NON_INTERACTIVE: "1",
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
} as NodeJS.ProcessEnv,
existsSync: tempScopedExistsSync(tmpHome),
// Simulate a TTY so we exercise the env-var-only branch (the prior
Expand Down Expand Up @@ -850,7 +917,10 @@ describe("uninstall run plan", () => {
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
commandExists: () => false,
env: { HOME: tmpHome } as NodeJS.ProcessEnv,
env: {
HOME: tmpHome,
NEMOCLAW_UNINSTALL_DESTROY_USER_DATA: "",
} as NodeJS.ProcessEnv,
error: (line) => warnings.push(line),
existsSync: tempScopedExistsSync(tmpHome),
isTty: false,
Expand Down
29 changes: 10 additions & 19 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import path from "node:path";

import { dockerSpawnSync } from "../../adapters/docker/exec";
import { type AgentBranding, getAgentBranding } from "../../cli/branding";
import { isStdinTty, readLineFromStdin } from "../../core/stdin";
import { sleepMs } from "../../core/wait";
import {
defaultUninstallPaths,
Expand Down Expand Up @@ -92,23 +93,6 @@ function defaultCommandExists(command: string, env: NodeJS.ProcessEnv): boolean
return false;
}

function defaultReadLine(): string | null {
const chunks: Buffer[] = [];
const byte = Buffer.alloc(1);
while (true) {
let bytesRead = 0;
try {
bytesRead = fs.readSync(0, byte, 0, 1, null);
} catch {
return chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null;
}
if (bytesRead === 0 || byte[0] === 10) break;
chunks.push(Buffer.from(byte));
}
if (chunks.length === 0) return null;
return Buffer.concat(chunks).toString("utf-8").replace(/\r$/, "");
}

function splitNonEmptyLines(output: string): string[] {
return output
.split(/\r?\n/)
Expand Down Expand Up @@ -254,7 +238,9 @@ function buildRuntime(deps: UninstallRunDeps): UninstallRuntime {
env,
error: deps.error ?? ((message) => console.error(message)),
existsSync: deps.existsSync ?? ((target) => fs.existsSync(target)),
isTty: deps.isTty ?? !!process.stdin.isTTY,
// Side-effect-free TTY check + EAGAIN-tolerant reader; the
// process.stdin/non-blocking-fd hazard is documented in core/stdin.ts.
isTty: deps.isTty ?? isStdinTty(),
kill:
deps.kill ??
((pid, signal) => {
Expand All @@ -266,7 +252,7 @@ function buildRuntime(deps: UninstallRunDeps): UninstallRuntime {
}
}),
log: deps.log ?? ((message) => console.log(message)),
readLine: deps.readLine ?? defaultReadLine,
readLine: deps.readLine ?? readLineFromStdin,
rmSync: deps.rmSync ?? fs.rmSync,
run: deps.run ?? defaultRun,
runDocker: deps.runDocker ?? defaultRunDocker,
Expand Down Expand Up @@ -311,6 +297,11 @@ function confirm(options: UninstallRunOptions, runtime: UninstallRuntime): boole
runtime.log("Proceed? [y/N]");
const reply = runtime.readLine();
if (reply && /^(y|yes)$/i.test(reply.trim())) return true;
if (reply === null) {
runtime.log(
"No input available on stdin (closed or non-interactive); re-run with --yes to skip this prompt.",
);
}
runtime.log("Aborted.");
return false;
}
Expand Down
89 changes: 89 additions & 0 deletions src/lib/core/stdin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import { isStdinTty, readLineFromStdin } from "./stdin";

/**
* Scripted fs.readSync stand-in: a single character delivers that byte, 0
* reports EOF, and any longer string throws an error with that code.
*/
function makeReadSync(events: Array<string | 0>) {
return vi.fn((_fd: number, buffer: Buffer): number => {
const event = events.shift();
if (event === undefined) throw new Error("readSync called past end of script");
if (event === 0) return 0;
if (event.length === 1) {
buffer[0] = event.charCodeAt(0);
return 1;
}
const err = new Error(event) as NodeJS.ErrnoException;
err.code = event;
throw err;
});
}

describe("readLineFromStdin", () => {
it("retries on EAGAIN until input arrives instead of treating it as EOF (#5020)", () => {
const sleep = vi.fn();
const readSync = makeReadSync(["EAGAIN", "EAGAIN", "y", "\n"]);

expect(readLineFromStdin({ readSync, sleep })).toBe("y");
expect(sleep).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(25);
});

it("retries immediately on EINTR without sleeping", () => {
const sleep = vi.fn();
const readSync = makeReadSync(["EINTR", "n", "\n"]);

expect(readLineFromStdin({ readSync, sleep })).toBe("n");
expect(sleep).not.toHaveBeenCalled();
});

it.each([
["returns null at immediate EOF", [0], null],
["strips the trailing CR from CRLF input", ["y", "e", "s", "\r", "\n"], "yes"],
["returns buffered bytes when EOF arrives before a newline", ["y", 0], "y"],
["returns null on a hard error with no buffered bytes", ["EBADF"], null],
["returns buffered bytes when a hard error interrupts mid-line", ["y", "e", "EBADF"], "ye"],
] as const)("%s", (_label, events, expected) => {
expect(readLineFromStdin({ readSync: makeReadSync([...events]), sleep: vi.fn() })).toBe(
expected,
);
});

it.each([
["EAGAIN"],
["EWOULDBLOCK"],
] as const)("gives up with null after the non-TTY deadline on persistent %s", (code) => {
const sleep = vi.fn();
const readSync = vi.fn((): number => {
const err = new Error(code) as NodeJS.ErrnoException;
err.code = code;
throw err;
});

expect(readLineFromStdin({ isTty: () => false, readSync, sleep })).toBeNull();
// 10s virtual deadline at 25ms per retry = exactly 400 bounded waits.
expect(sleep).toHaveBeenCalledTimes(400);
});

it("keeps waiting past the deadline on a TTY until input arrives", () => {
const sleep = vi.fn();
const events = [...Array.from({ length: 450 }, () => "EAGAIN"), "y", "\n"];

expect(readLineFromStdin({ isTty: () => true, readSync: makeReadSync(events), sleep })).toBe(
"y",
);
expect(sleep).toHaveBeenCalledTimes(450);
});
});

describe("isStdinTty", () => {
it("reports false when stdin is not a terminal", () => {
// Vitest workers run with piped stdio, so fd 0 is never a TTY here.
expect(isStdinTty()).toBe(false);
});
});
Loading
Loading