SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5cb63c1 verified
Raw History Blame Contribute Delete
15.1 kB
// PTY adapter tests cover PTY lifecycle and termination behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferredCore } from "../../../shared/deferred.js";
import {
expectRealExitWinsOverSigkillFallback,
expectWaitStaysPendingUntilSigkillFallback,
mockLinuxOomWrapperShell,
} from "./test-support.js";
const { spawnMock, ptyKillMock, signalProcessTreeMock, signalPtySessionTreeMock } = vi.hoisted(
() => ({
spawnMock: vi.fn(),
ptyKillMock: vi.fn(),
signalProcessTreeMock: vi.fn(),
signalPtySessionTreeMock: vi.fn(),
}),
);
vi.mock("../../terminal-pty.js", () => ({
spawnTerminalPty: (...args: unknown[]) => spawnMock(...args),
}));
vi.mock("../../kill-tree.js", () => ({
signalProcessTree: (...args: unknown[]) => signalProcessTreeMock(...args),
signalPtySessionTree: (...args: unknown[]) => signalPtySessionTreeMock(...args),
}));
function createStubPty(pid = 1234) {
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | null = null;
const disposeData = vi.fn();
const disposeExit = vi.fn();
return {
pid,
write: vi.fn(),
onData: vi.fn(() => ({ dispose: disposeData })),
onExit: vi.fn((listener: (event: { exitCode: number; signal?: number }) => void) => {
exitListener = listener;
return { dispose: disposeExit };
}),
kill: (signal?: string) => ptyKillMock(signal),
emitExit: (event: { exitCode: number; signal?: number }) => {
exitListener?.(event);
},
disposeData,
disposeExit,
};
}
function expectSpawnOptions() {
const options = firstSpawnCall()[0];
if (typeof options !== "object" || options === null || Array.isArray(options)) {
throw new Error("expected spawn options to be an object");
}
return options as {
file: string;
args: string[];
env?: Record<string, string>;
name?: string;
};
}
function expectSpawnEnv() {
return expectSpawnOptions().env;
}
function expectSpawnCommand() {
return expectSpawnOptions().file;
}
function expectSpawnArgs() {
return expectSpawnOptions().args;
}
function firstSpawnCall(): unknown[] {
const [call] = spawnMock.mock.calls;
if (!call) {
throw new Error("expected spawn call");
}
return call;
}
describe("createPtyAdapter", () => {
let createPtyAdapter: typeof import("./pty.js").createPtyAdapter;
beforeAll(async () => {
({ createPtyAdapter } = await import("./pty.js"));
});
beforeEach(() => {
spawnMock.mockClear();
ptyKillMock.mockClear();
signalProcessTreeMock.mockClear();
signalPtySessionTreeMock.mockClear();
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it("does not spawn when construction aborts during the module import", async () => {
const abort = new AbortController();
spawnMock.mockReturnValue(createStubPty());
const starting = createPtyAdapter({
shell: "bash",
args: ["-lc", "echo started"],
abortSignal: abort.signal,
});
abort.abort();
await expect(starting).rejects.toThrow("PTY construction aborted");
expect(spawnMock).not.toHaveBeenCalled();
});
it("kills a PTY created after construction is aborted", async () => {
const abort = new AbortController();
const spawned = createDeferredCore<ReturnType<typeof createStubPty>>();
const stub = createStubPty();
spawnMock.mockReturnValue(spawned.promise);
const starting = createPtyAdapter({
shell: "bash",
args: ["-lc", "echo started"],
abortSignal: abort.signal,
});
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
abort.abort();
spawned.resolve(stub);
await expect(starting).rejects.toThrow("PTY construction aborted");
expect(ptyKillMock).toHaveBeenCalledOnce();
});
it("uses the default terminal name and child env when Windows TERM is blank", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
vi.stubEnv("TERM", " ");
vi.stubEnv("OPENCLAW_PTY_TEST_SENTINEL", "ambient");
spawnMock.mockReturnValue(createStubPty());
await createPtyAdapter({ shell: "powershell.exe", args: ["-NoLogo"] });
expect(expectSpawnOptions()).toMatchObject({
name: "xterm-256color",
env: { OPENCLAW_PTY_TEST_SENTINEL: "ambient", TERM: "xterm-256color" },
});
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("prefers the explicit child TERM without merging the Windows ambient env", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
vi.stubEnv("TERM", "ambient-term");
vi.stubEnv("OPENCLAW_PTY_TEST_SENTINEL", "ambient");
spawnMock.mockReturnValue(createStubPty());
await createPtyAdapter({
shell: "powershell.exe",
args: ["-NoLogo"],
env: { Term: "screen-256color", ONLY_CHILD: "yes" },
});
expect(expectSpawnOptions()).toMatchObject({
name: "screen-256color",
env: { TERM: "screen-256color", ONLY_CHILD: "yes" },
});
expect(expectSpawnEnv()).not.toHaveProperty("Term");
expect(expectSpawnEnv()).not.toHaveProperty("OPENCLAW_PTY_TEST_SENTINEL");
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it.each([
{ name: "vt100", expected: "vt100" },
{ name: " ", expected: "xterm-256color" },
])("uses explicit terminal name '$name' over the child env", async ({ name, expected }) => {
spawnMock.mockReturnValue(createStubPty());
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
name,
env: { TERM: "screen-256color" },
});
expect(expectSpawnOptions()).toMatchObject({
name: expected,
env: { TERM: expected },
});
});
it("forwards non-SIGTERM explicit signals to node-pty kill on non-Windows", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
spawnMock.mockReturnValue(createStubPty());
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
adapter.kill("SIGINT");
expect(ptyKillMock).toHaveBeenCalledWith("SIGINT");
expect(signalProcessTreeMock).not.toHaveBeenCalled();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("uses process-tree kill for graceful SIGTERM cancellation", async () => {
spawnMock.mockReturnValue(createStubPty(1234));
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
adapter.kill("SIGTERM");
expect(signalPtySessionTreeMock).toHaveBeenCalledWith(1234, "SIGTERM");
expect(signalProcessTreeMock).not.toHaveBeenCalled();
expect(ptyKillMock).not.toHaveBeenCalled();
});
it("uses process-tree kill for SIGKILL by default", async () => {
spawnMock.mockReturnValue(createStubPty());
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
adapter.kill();
expect(signalPtySessionTreeMock).toHaveBeenCalledWith(1234, "SIGKILL");
expect(signalProcessTreeMock).not.toHaveBeenCalled();
expect(ptyKillMock).not.toHaveBeenCalled();
});
it("keeps terminal fallback distinct from unconfirmed PTY cleanup", async () => {
vi.useFakeTimers();
spawnMock.mockReturnValue(createStubPty());
const onSpawnCleanup = vi.fn<(cleanup: Promise<void>) => void>();
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
onSpawnCleanup,
});
await expectWaitStaysPendingUntilSigkillFallback(adapter.wait(), () => {
adapter.kill();
});
expect(onSpawnCleanup).toHaveBeenCalledOnce();
await expect(onSpawnCleanup.mock.calls[0]![0]).rejects.toThrow(
"cleanup could not be confirmed",
);
});
it("prefers real PTY exit over SIGKILL fallback settle", async () => {
vi.useFakeTimers();
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
await expectRealExitWinsOverSigkillFallback({
waitPromise: adapter.wait(),
triggerKill: () => {
adapter.kill();
},
emitExit: () => {
stub.emitExit({ exitCode: 0, signal: 9 });
},
expected: { code: 0, signal: 9 },
});
});
it.each([true, false])(
"preserves the first PTY exit across waits (waitBefore=%s)",
async (waitBefore) => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "exit 3"],
});
expect(stub.onExit).toHaveBeenCalledTimes(1);
const pending = waitBefore ? [adapter.wait(), adapter.wait()] : [];
stub.emitExit({ exitCode: 3, signal: 0 });
const result = await adapter.wait();
expect(result).toStrictEqual({ code: 3, signal: null });
expect(adapter.stdin?.destroyed).toBe(true);
expect(adapter.stdin?.writable).toBe(false);
stub.emitExit({ exitCode: 9, signal: 15 });
adapter.dispose();
for (const wait of [...pending, adapter.wait()]) {
await expect(wait).resolves.toBe(result);
}
},
);
it("reports stdin as non-writable after EOF or dispose", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "cat"],
});
expect(adapter.stdin?.writable).toBe(true);
expect(adapter.stdin?.writableEnded).toBe(false);
adapter.stdin?.end();
expect(stub.write).toHaveBeenCalledWith(process.platform === "win32" ? "\x1a" : "\x04");
expect(adapter.stdin?.writable).toBe(false);
expect(adapter.stdin?.writableEnded).toBe(true);
adapter.dispose();
expect(adapter.stdin?.destroyed).toBe(true);
});
it("disposes PTY listeners", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "echo ok"],
});
adapter.onStdout(() => undefined);
const pending = adapter.wait();
adapter.dispose();
const result = await pending;
expect(result).toStrictEqual({ code: null, signal: null });
await expect(adapter.wait()).resolves.toBe(result);
expect(stub.disposeData).toHaveBeenCalledTimes(1);
expect(stub.disposeExit).toHaveBeenCalledTimes(1);
});
it("keeps inherited env when no override env is provided on non-Linux", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
});
expect(expectSpawnCommand()).toBe("bash");
expect(expectSpawnArgs()).toEqual(["-lc", "env"]);
expect(expectSpawnEnv()).toBeUndefined();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("wraps Linux PTY spawns so shell children inherit higher OOM score", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
const restoreLinuxShell = mockLinuxOomWrapperShell();
vi.resetModules();
try {
const { createPtyAdapter: createLinuxPtyAdapter } = await import("./pty.js");
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const adapter = await createLinuxPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv", TERM: "dumb" },
});
expect(adapter.oomScoreWrapperSelected).toBe(true);
} finally {
restoreLinuxShell();
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
expect(expectSpawnCommand()).toBe("/bin/sh");
expect(expectSpawnArgs()).toEqual([
"-c",
'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"',
"bash",
"-lc",
"env",
]);
expect(expectSpawnEnv()).toEqual({ PATH: "/usr/bin", TERM: "xterm-256color" });
});
it("passes explicit env overrides as strings", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
await createPtyAdapter({
shell: "bash",
args: ["-lc", "env"],
name: "xterm-256color",
env: { FOO: "bar", COUNT: "12", DROP_ME: undefined },
});
expect(expectSpawnEnv()).toEqual({ FOO: "bar", COUNT: "12", TERM: "xterm-256color" });
});
it("delegates non-SIGTERM explicit signals to the terminal owner on Windows", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
spawnMock.mockReturnValue(createStubPty());
const adapter = await createPtyAdapter({
shell: "powershell.exe",
args: ["-NoLogo"],
});
adapter.kill("SIGINT");
expect(ptyKillMock).toHaveBeenCalledWith("SIGINT");
expect(signalProcessTreeMock).not.toHaveBeenCalled();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("uses process-tree kill for SIGKILL on Windows", async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
spawnMock.mockReturnValue(createStubPty(4567));
const adapter = await createPtyAdapter({
shell: "powershell.exe",
args: ["-NoLogo"],
});
adapter.kill("SIGKILL");
expect(signalPtySessionTreeMock).toHaveBeenCalledWith(4567, "SIGKILL");
expect(signalProcessTreeMock).not.toHaveBeenCalled();
expect(ptyKillMock).not.toHaveBeenCalled();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
});