Skip to content
Open
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
153 changes: 153 additions & 0 deletions apps/server/src/terminal/nodePtyFlushDataAndCleanUp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import * as NodeModule from "node:module";

import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const requireCjs = NodeModule.createRequire(import.meta.url);
const {
WindowsPtyAgent,
}: {
WindowsPtyAgent: {
prototype: {
_flushDataAndCleanUp(this: FakeAgent): void;
_cleanUpProcess(this: FakeAgent): void;
};
};
} = requireCjs("node-pty/lib/windowsPtyAgent.js");

interface FakeAgent {
_useConptyDll: boolean;
_closeTimeout?: unknown;
_inSocket: { readable: boolean };
_outSocket: { readable: boolean; destroy: () => void };
_cleanUpProcess: () => void;
}

// The sockets are stand-ins; the timer logic and _cleanUpProcess are node-pty's own (as patched by T3).
const makeAgent = (): FakeAgent => {
const agent: FakeAgent = {
_useConptyDll: false,
_inSocket: { readable: true },
_outSocket: { readable: true, destroy: vi.fn() },
_cleanUpProcess: () => WindowsPtyAgent.prototype._cleanUpProcess.call(agent),
};
return agent;
};

const flush = (agent: FakeAgent) => WindowsPtyAgent.prototype._flushDataAndCleanUp.call(agent);

// A minimal event loop that mirrors Node's phases: expired timers run first, then immediates (the check phase).
// node-pty calls the global timer functions at call time, so replacing them lets the test place a "poll phase"
// (delivery of already-buffered pipe data) exactly between the two.
interface Timer {
readonly id: number;
readonly dueAt: number;
readonly run: () => void;
}
let now = 0;
let nextId = 0;
let timers: Timer[] = [];
let immediates: Array<() => void> = [];

const runExpiredTimers = () => {
for (const timer of timers.filter((t) => t.dueAt <= now).toSorted((a, b) => a.dueAt - b.dueAt)) {
timers = timers.filter((t) => t.id !== timer.id);
timer.run();
}
};
const runImmediates = () => {
const batch = immediates;
immediates = [];
for (const run of batch) run();
};
/** One loop iteration after `ms` of wall time; `poll` runs between the timers and check phases. */
const loopIteration = (ms: number, poll?: () => void) => {
now += ms;
runExpiredTimers();
poll?.();
runImmediates();
};

// ConPTY never closes the output pipe after the shell exits, so node-pty ends the session on a timer.
// T3 shortens it (patches/node-pty@1.2.0-beta.15.patch) so the terminal reports "exited" about 0.9 s sooner.
describe("node-pty Windows exit flush", () => {
beforeEach(() => {
now = 0;
nextId = 0;
timers = [];
immediates = [];
vi.stubGlobal("setTimeout", (run: () => void, ms: number) => {
const id = ++nextId;
timers.push({ id, dueAt: now + ms, run });
return id;
});
vi.stubGlobal("clearTimeout", (id: number) => {
timers = timers.filter((t) => t.id !== id);
});
vi.stubGlobal("setImmediate", (run: () => void) => {
immediates.push(run);
return ++nextId;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});

it("closes the output socket 100 ms after the exit, not a second later", () => {
const agent = makeAgent();
flush(agent);

loopIteration(99);
expect(agent._outSocket.destroy).not.toHaveBeenCalled();

loopIteration(1);
expect(agent._outSocket.destroy).toHaveBeenCalledTimes(1);
expect(agent._outSocket.readable).toBe(false);
expect(agent._inSocket.readable).toBe(false);
});

it("restarts the window when more output arrives", () => {
const agent = makeAgent();
flush(agent);
loopIteration(60);
flush(agent);

loopIteration(99);
expect(agent._outSocket.destroy).not.toHaveBeenCalled();

loopIteration(1);
expect(agent._outSocket.destroy).toHaveBeenCalledTimes(1);
});

// Timers run before the poll phase. After a main-thread stall longer than the window, output that is already
// buffered in the pipe is read only after the timer has expired, so the cleanup has to wait one loop turn for it.
it("keeps the socket open when buffered output is read right after a stalled timer expires", () => {
const agent = makeAgent();
flush(agent);

loopIteration(150, () => flush(agent));
expect(agent._outSocket.destroy).not.toHaveBeenCalled();

loopIteration(99);
expect(agent._outSocket.destroy).not.toHaveBeenCalled();

loopIteration(1);
expect(agent._outSocket.destroy).toHaveBeenCalledTimes(1);
});

it("closes after a stall when no output was buffered", () => {
const agent = makeAgent();
flush(agent);

loopIteration(150);
expect(agent._outSocket.destroy).toHaveBeenCalledTimes(1);
});

it("does nothing when node-pty hosts conpty itself", () => {
const agent = makeAgent();
agent._useConptyDll = true;
flush(agent);

loopIteration(5000);
expect(agent._outSocket.destroy).not.toHaveBeenCalled();
});
});
33 changes: 32 additions & 1 deletion patches/node-pty@1.2.0-beta.15.patch
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ diff --git a/lib/windowsConoutConnection.js b/lib/windowsConoutConnection.js
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -218,13 +218,31 @@
@@ -20,7 +20,9 @@ var conptyNative;
* shutting down the socket. The timer will be reset if a new data event comes in after the timer
* has started.
*/
-var FLUSH_DATA_INTERVAL = 1000;
+// ConPTY never closes the output pipe on its own after the shell exits, so wait this long for the last
+// screen update. Trailing output follows the exit within about 20 ms; 1000 ms made every exit report 1 s late.
+var FLUSH_DATA_INTERVAL = 100;
var CONNECTION_TIMEOUT = 5000;
var defaultWindowsPtyAgentOptions = {
connectionTimeout: CONNECTION_TIMEOUT,
@@ -218,13 +220,31 @@
return Promise.resolve([]);
}
return new Promise(function (resolve) {
Expand Down Expand Up @@ -53,3 +64,23 @@ diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
- resolve([_this._innerPid]);
+ finish([_this._innerPid]);
}, 5000);
@@ -259,7 +279,18 @@ var WindowsPtyAgent = /** @class */ (function () {
if (this._closeTimeout) {
clearTimeout(this._closeTimeout);
}
- this._closeTimeout = setTimeout(function () { return _this._cleanUpProcess(); }, FLUSH_DATA_INTERVAL);
+ // Timers run before the poll phase. After a main-thread stall longer than the interval, output that is already
+ // buffered in the pipe would be dropped by the cleanup, so give the poll phase one turn to read it first; every
+ // chunk it reads re-arms this timer.
+ this._flushDataSeq = (this._flushDataSeq || 0) + 1;
+ this._closeTimeout = setTimeout(function () {
+ var seq = _this._flushDataSeq;
+ setImmediate(function () {
+ if (_this._flushDataSeq === seq) {
+ _this._cleanUpProcess();
+ }
+ });
+ }, FLUSH_DATA_INTERVAL);
};
WindowsPtyAgent.prototype._cleanUpProcess = function () {
if (this._useConptyDll) {
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading