[Bug]: Closing a Windows terminal before its first output does not terminate it #36003
Send to my agentSummary#
Closing a Windows terminal after its process ID becomes available but before its first output does not reach process termination. Both the initial stop and the forced stop wait for output, so a silent or blocked startup can survive its terminal being closed. A focused probe reproduced the adapter failure using the pinned WindowsTerminal JavaScript with a fake native agent; an output-producing control reached termination normally.
Before submitting#
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area#
apps/server
Steps to reproduce#
- Use a checkout of the commit below with its pinned dependencies installed, for example with pnpm install --frozen-lockfile.
- Save the script below as repro.sh and run sh repro.sh /path/to/checkout.
- The script adds a temporary focused case beside the existing adapter suite, drives PID readiness without output, and invokes the same two stop requests used by the manager's close escalation.
The probe reproduced the failure once in one attempt. All ten existing NodePtyAdapter tests passed separately; their native kill stub does not model beta.15's output-dependent deferral.
The script requires Node.js, the checkout's installed Vite+ executable, and its pinned dependencies. It creates disposable HOME, TMPDIR, and T3CODE_HOME directories and deletes the temporary test and state on exit. No host process is spawned or signaled. Exit 0 means the expected termination behavior, 1 means the defect was reproduced after the control passed, and 2 means the setup or control was inconclusive.
Reproduction script: repro.sh
#!/bin/sh
set -u
if [ "$#" -ne 1 ]; then
echo 'RESULT: inconclusive'; exit 2
fi
checkout=$(cd "$1" && pwd) || { echo 'RESULT: inconclusive'; exit 2; }
scratch=$(mktemp -d)
test_file="$checkout/apps/server/src/terminal/NodePtyAdapter.pty-lifecycle.test.ts"
if [ -e "$test_file" ]; then
rm -rf "$scratch"; echo 'RESULT: inconclusive'; exit 2
fi
cleanup() { rm -f "$test_file"; rm -rf "$scratch"; }
trap cleanup EXIT HUP INT TERM
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/t3"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
printf 'node: '; node --version
printf 'vp: '; "$checkout/node_modules/.bin/vp" --version
(cd "$checkout/apps/server" && node -p '"node-pty: " + require("node-pty/package.json").version')
cp "$checkout/apps/server/src/terminal/NodePtyAdapter.test.ts" "$test_file"
cat >> "$test_file" <<'TEST'
it.effect("pty-lifecycle terminates a PID-ready Windows PTY without output", () =>
Effect.gen(function* () {
const { createRequire } = yield* Effect.promise(() => import("node:module"));
const require = createRequire(import.meta.url);
const agentModule = require("node-pty/lib/windowsPtyAgent.js");
const { WindowsTerminal } = require("node-pty/lib/windowsTerminal.js");
const realAgent = agentModule.WindowsPtyAgent;
const adapter = yield* PtyAdapter.PtyAdapter;
const makeWindowsTerminal = () => {
const agent = {
outSocket: new NodeNet.Socket(),
inSocket: new NodeNet.Socket(),
innerPid: 0, fd: 0, pty: 1, exitCode: 0,
onError: () => ({ dispose() {} }),
kill: vi.fn(), resize: vi.fn(), clear: vi.fn(),
};
agentModule.WindowsPtyAgent = function () { return agent; };
let native;
try {
native = new WindowsTerminal("powershell.exe", [], { cwd: ".", cols: 80, rows: 24, env: {} });
} finally { agentModule.WindowsPtyAgent = realAgent; }
const subscribed = Promise.withResolvers<void>();
native._socket.on("newListener", (event: string) => {
if (event === "ready_datapipe") queueMicrotask(() => subscribed.resolve());
});
spawn.mockReturnValueOnce(native);
return { native, agent, subscribed: Effect.promise(() => subscribed.promise) };
};
// The output-producing control uses the real beta.15 WindowsTerminal JavaScript.
const control = makeWindowsTerminal();
const controlFiber = yield* adapter.spawn(spawnInput).pipe(Effect.forkChild);
yield* control.subscribed;
control.agent.innerPid = 41001;
control.native._socket.emit("ready_datapipe");
const controlProcess = yield* Fiber.join(controlFiber);
control.native._socket.emit("data", "control output");
controlProcess.kill("SIGTERM");
assert.equal(control.agent.kill.mock.calls.length, 1);
console.log("CONTROL: output-ready Windows PTY reached agent.kill once");
const silent = makeWindowsTerminal();
const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.forkChild);
yield* silent.subscribed;
silent.agent.innerPid = 41002;
silent.native._socket.emit("ready_datapipe");
const process = yield* Fiber.join(fiber);
assert.equal(process.pid, 41002);
process.kill("SIGTERM");
process.kill("SIGKILL");
const kills = silent.agent.kill.mock.calls.length;
console.log(`OBSERVED: silent PID-ready PTY agent kills=${kills}, queued operations=${silent.native._deferreds.length}`);
if (kills === 0) console.log("DEFECT: both termination requests are waiting for first output");
// Release test-owned socket listeners without spawning or killing any host process.
control.native._socket.emit("exit", 0);
silent.native._socket.emit("exit", 0);
control.agent.outSocket.destroy(); control.agent.inSocket.destroy();
silent.agent.outSocket.destroy(); silent.agent.inSocket.destroy();
assert.isAbove(kills, 0);
}).pipe(Effect.provide(layerTest)),
);
TEST
(cd "$checkout" && node_modules/.bin/vp test run "$test_file" -t 'pty-lifecycle terminates') > "$scratch/output" 2>&1
status=$?
cat "$scratch/output"
if ! grep -q 'CONTROL: output-ready Windows PTY reached agent.kill once' "$scratch/output"; then
echo 'RESULT: inconclusive'; exit 2
fi
if [ "$status" -eq 0 ]; then
echo 'RESULT: expected behavior'; exit 0
fi
if grep -q 'DEFECT: both termination requests are waiting for first output' "$scratch/output"; then
echo 'RESULT: defect reproduced'; exit 1
fi
echo 'RESULT: inconclusive'; exit 2
Expected behavior#
Once a terminal has successfully opened, closing it must initiate termination even if its shell has not produced output. apps/server/src/terminal/Manager.ts#L2409-L2437 detaches the closed process and starts kill escalation; apps/server/src/terminal/Manager.ts#L1902-L1968 sends SIGTERM followed by SIGKILL if no exit arrives. apps/server/src/terminal/NodePtyAdapter.ts#L145-L168 already identifies public Windows kill's first-output dependency and bypasses it for interrupted startup. The same dependency remains relevant after PID readiness and before first output.
Actual behavior#
The real NodePtyAdapter returns a Windows process as soon as ready_datapipe assigns a positive PID. Calling its kill("SIGTERM") and kill("SIGKILL") before any data event leaves the native agent's kill count at zero and queues two operations in node-pty's WindowsTerminal. The same probe emits output before stopping its control and observes one native-agent kill. This is a JavaScript integration reproduction on macOS with the actual beta.15 WindowsTerminal implementation and a fake WindowsPtyAgent, not a native Windows process-survival observation.
Impact#
Minor bug or occasional failure
Impact: P2 - Closing an affected Windows terminal removes it from manager state while its two termination requests remain deferred, leaving its shell outside the terminal UI's lifecycle control.
The affected window is after ready_datapipe and before the first data event. A slow or blocked shell startup can occupy that window. NOT_ENOUGH_INFO: native Windows frequency, process-survival duration, and a practical UI workaround were not measured; a Windows run with a quiet or blocked shell startup would establish them. The reproduction establishes the missing agent calls without claiming a native Windows UI observation.
Version or commit#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; node-pty 1.2.0-beta.15 with the repository's patch.
Environment#
Probe host: macOS arm64, Node.js v26.12.1, Vite+ 1.4.0, Vitest 5.0.5, Effect 4.1.3. The adapter platform is injected as win32; the real beta.15 WindowsTerminal JavaScript runs against a fake native agent. Native Windows behavior was not run.
Evidence#
The focused script printed:
CONTROL: output-ready Windows PTY reached agent.kill once
OBSERVED: silent PID-ready PTY agent kills=0, queued operations=2
DEFECT: both termination requests are waiting for first output
AssertionError: expected +0 to be above +0
RESULT: defect reproduced
The output is an excerpt of the run; stack traces and runner paths are omitted. The installed dependency reported node-pty 1.2.0-beta.15. apps/server/src/terminal/NodePtyAdapter.ts#L201-L204 strips Windows signals but delegates to public kill. The pinned WindowsTerminal implementation#L61-L85 makes first data, rather than PID readiness, release deferred operations, and windowsTerminal.ts#L173-L195 defers public kill through that mechanism. T3's patches/node-pty@1.2.0-beta.15.patch#L1-L50 changes helper IPC and environments, leaving this deferral intact.
Restoration check#
A successful close must reach the process-termination backend without any additional output, input, or reconnection. The output-producing control must keep passing.
Interface#
API: terminal.close and PtyProcess.kill through NodePtyAdapter.
Earliest failure stage#
Cleanup or lifecycle
References#
https://github.com/pingdotgg/t3code/pull/13927 — handles delayed PID publication, readiness failures, and cancellation before adapter spawn returns; this report concerns closing after spawn has successfully returned but before first output.
https://github.com/pingdotgg/t3code/pull/18519 — handles console-list helper failure and exit-aware kill escalation after kill reaches the native agent; the reproduction here never reaches that helper or agent.
https://github.com/pingdotgg/t3code/issues/35881 — concerns interruption after shell creation during the manager's state handoff; this probe completes adapter spawn and calls normal stop operations without cancellation.
https://github.com/pingdotgg/t3code/pull/10771 — omits unsupported Windows signals; this probe uses that corrected signal-less path and still queues both stops until output.
tanstack-source added labelsbug
github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced
Sign in to join this conversation.