Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
127 changes: 127 additions & 0 deletions apps/server/src/cli/consoleStdin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const io = vi.hoisted(() => ({
platform: vi.fn(() => "win32"),
stat: vi.fn(() => ({ isCharacterDevice: (): boolean => true })),
open: vi.fn(() => 42),
close: vi.fn(),
isatty: vi.fn((fd: number) => fd !== 0),
}));

vi.mock("node:fs", () => ({ fstatSync: io.stat, openSync: io.open, closeSync: io.close }));
vi.mock("node:tty", async () => {
const { EventEmitter } = await import("node:events");
return {
isatty: io.isatty,
ReadStream: class extends EventEmitter {
readonly isTTY = true;
readonly _readableState = { reading: true };
_handle: { reading: boolean; readStop: () => void } | undefined = {
reading: true,
readStop: vi.fn(),
};
},
};
});
vi.mock("@t3tools/shared/HostProcess", () => ({ Platform: { defaultValue: io.platform } }));

const originalStdin = Object.getOwnPropertyDescriptor(process, "stdin")!;

beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
vi.stubEnv("T3CODE_DESKTOP_CLI_LAUNCHER", "1");
});

afterEach(() => {
Object.defineProperty(process, "stdin", originalStdin);
vi.unstubAllEnvs();
});

describe("attachConsoleStdin", () => {
it("reopens the console when Node replaces missing Windows stdin with NUL", async () => {
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");

expect(attachConsoleStdin()).toBe(42);
expect(process.stdin.isTTY).toBe(true);
expect(interactiveStdin()).toBe(42);
expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined();
});

it("releases console reads when the picker pauses stdin before launching an agent", async () => {
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
attachConsoleStdin();
const stdin = process.stdin as unknown as {
emit(event: string): boolean;
_readableState: { reading: boolean };
_handle: { reading: boolean; readStop: () => void };
};
stdin.emit("pause");
expect(stdin._readableState.reading).toBe(false);
expect(stdin._handle.reading).toBe(false);
expect(stdin._handle.readStop).toHaveBeenCalledOnce();
expect(interactiveStdin()).toBe(42);
});

it("can pause after the reopened stream's handle has been closed", async () => {
const { attachConsoleStdin } = await import("./consoleStdin.ts");
attachConsoleStdin();
const stdin = process.stdin as unknown as {
emit(event: string): boolean;
_handle: undefined;
};
stdin._handle = undefined;
expect(() => stdin.emit("pause")).not.toThrow();
});

it("still reopens a closed stdin", async () => {
io.stat.mockImplementation(() => {
throw new Error("EBADF");
});
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
expect(attachConsoleStdin()).toBe(42);
expect(interactiveStdin()).toBe(42);
});

it.each(["pipe", "file"])("preserves redirected %s input", async () => {
io.stat.mockReturnValue({ isCharacterDevice: () => false });
const stdin = process.stdin;
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
expect(attachConsoleStdin()).toBeUndefined();
expect(process.stdin).toBe(stdin);
expect(interactiveStdin()).toBe("inherit");
expect(io.open).not.toHaveBeenCalled();
});

it.each(["console input", "redirected output", "unmarked process", "non-Windows"])(
"leaves %s alone",
async (scenario) => {
if (scenario === "console input") io.isatty.mockReturnValue(true);
if (scenario === "redirected output") io.isatty.mockReturnValue(false);
if (scenario === "unmarked process") delete process.env.T3CODE_DESKTOP_CLI_LAUNCHER;
if (scenario === "non-Windows") io.platform.mockReturnValue("darwin");
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
expect(attachConsoleStdin()).toBeUndefined();
expect(interactiveStdin()).toBe("inherit");
expect(io.open).not.toHaveBeenCalled();
expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined();
},
);

it("keeps inherited input when CONIN$ cannot be opened", async () => {
io.open.mockImplementation(() => {
throw new Error("ENOENT");
});
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
expect(attachConsoleStdin()).toBeUndefined();
expect(interactiveStdin()).toBe("inherit");
});

it("closes a reopened descriptor that is not a console", async () => {
io.isatty.mockImplementation((fd) => fd === 1);
const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts");
expect(attachConsoleStdin()).toBeUndefined();
expect(interactiveStdin()).toBe("inherit");
expect(io.close).toHaveBeenCalledWith(42);
});
});
31 changes: 21 additions & 10 deletions apps/server/src/cli/consoleStdin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,30 @@ import * as NodeTty from "node:tty";
import * as HostProcess from "@t3tools/shared/HostProcess";

/**
* Electron's Windows executable is a GUI program, so `t3.cmd` under
* `ELECTRON_RUN_AS_NODE` gets the console for stdout and stderr but no stdin.
* Piped or redirected input and runs whose output is not a console are left alone.
* Electron's Windows launcher has no stdin; Node substitutes a non-console NUL device.
* Preserve pipes, files, console input, and runs without console output.
* Explicit `< NUL` is indistinguishable from missing launcher stdin and also reattaches.
*/
function needsConsoleStdin(input: {
readonly platform: NodeJS.Platform;
readonly env: NodeJS.ProcessEnv;
readonly stdinOpen: boolean;
readonly stdinMissing: boolean;
readonly stdoutIsConsole: boolean;
}): boolean {
return (
input.platform === "win32" &&
// Set only by the launcher (DesktopCliShim), never for the desktop backend.
input.env.T3CODE_DESKTOP_CLI_LAUNCHER === "1" &&
!input.stdinOpen &&
input.stdinMissing &&
input.stdoutIsConsole
);
}

const isOpen = (fd: number) => {
const isMissingInput = (fd: number) => {
try {
NodeFS.fstatSync(fd);
return true;
return NodeFS.fstatSync(fd).isCharacterDevice() && !NodeTty.isatty(fd);
} catch {
return false;
return true;
}
};

Expand All @@ -38,7 +37,7 @@ export function attachConsoleStdin(): number | undefined {
const needed = needsConsoleStdin({
platform: HostProcess.Platform.defaultValue(),
env: process.env,
stdinOpen: isOpen(0),
stdinMissing: isMissingInput(0),
stdoutIsConsole: NodeTty.isatty(1),
});
// The launcher marker must not propagate to child shells or t3 processes.
Expand All @@ -55,6 +54,18 @@ export function attachConsoleStdin(): number | undefined {
return undefined;
}
const stdin = new NodeTty.ReadStream(fd);
// Like Node's built-in stdin, release pending console reads before a child takes over.
stdin.on("pause", () => {
const internal = stdin as unknown as {
_handle?: { reading: boolean; readStop(): void };
_readableState: { reading: boolean };
};
const handle = internal._handle;
if (!handle) return;
internal._readableState.reading = false;
handle.reading = false;
handle.readStop();
});
Object.defineProperty(process, "stdin", { value: stdin, configurable: true, enumerable: true });
consoleStdinFd = fd;
return fd;
Expand Down
Loading