Download src/process/supervisor/adapters/pty.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/process/supervisor/adapters/pty.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/process/supervisor/adapters/pty.test.ts
-
curl -L -o pty.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/process/supervisor/adapters/pty.test.ts
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); | |
| } | |
| } | |
| }); | |
| }); | |