From 1a2be868d3d1265e3061b75a074edb4893c39ad1 Mon Sep 17 00:00:00 2001 From: TutorLink Dev Date: Sun, 4 Oct 2026 20:54:17 +0530 Subject: [PATCH] fix(worker): ignore Canceled unhandled rejections VS Code promise-cancellation races surface as Canceled rejections (e.g. a pending ptyHost resolved-variables RPC cancelled on extension-host restart). Treating them as fatal crashed the worker on every restart, and the respawn loop re-triggered the same race. Canceled rejections (by name or message) are now logged and ignored; all others still forward { type: error } to the parent and exit 1, so the respawn contract is unchanged. Adds lib/worker.test.ts: forks the actual worker.mjs against a stubbed dist entry in a temp dir and covers both Canceled shapes plus the forward-and-exit path end-to-end over IPC. --- README.md | 2 +- lib/worker.mjs | 18 +++++- lib/worker.test.ts | 150 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 lib/worker.test.ts diff --git a/README.md b/README.md index 5be180e..9a15779 120000 --- a/README.md +++ b/README.md @@ -1 +1 @@ -lib/README.md \ No newline at end of file +lib/README.md diff --git a/lib/worker.mjs b/lib/worker.mjs index dc466d6..04df972 100644 --- a/lib/worker.mjs +++ b/lib/worker.mjs @@ -61,4 +61,20 @@ const forwardFatal = (kind) => (err) => { }; process.on("uncaughtException", forwardFatal("uncaughtException")); -process.on("unhandledRejection", forwardFatal("unhandledRejection")); + +// VS Code's promise-cancellation races surface as `Canceled` rejections — for +// example a pending ptyHost resolved-variables RPC cancelled when the +// extension host restarts. Those are by design, not bugs: treating them as +// fatal makes every extension-host restart crash the worker, and because the +// parent respawns us and the browser reconnects, the same race fires again — +// a self-sustaining crash loop. Log and keep going; every other rejection +// still forwards to the parent and exits. +const isCanceled = (err) => err?.name === "Canceled" || err?.message === "Canceled"; + +process.on("unhandledRejection", (err) => { + if (isCanceled(err)) { + console.error("[coderaft worker] ignoring Canceled rejection:", err); + return; + } + forwardFatal("unhandledRejection")(err); +}); diff --git a/lib/worker.test.ts b/lib/worker.test.ts new file mode 100644 index 0000000..8283a9e --- /dev/null +++ b/lib/worker.test.ts @@ -0,0 +1,150 @@ +// Integration coverage for the worker's `Canceled`-rejection filter +// (lib/worker.mjs). +// +// The worker is a forked entry that imports the built code-server from +// ./dist/index.mjs, so these tests run the *actual shipped* worker.mjs bytes +// against a stubbed code-server entry in a temp dir — no build required, and +// the fork/IPC contract with lib/src/spawn.ts (forward `{ type: "error" }`, +// then exit 1) is exercised end-to-end. +// +// Cases mirror the production crash loop: +// - a `Canceled` rejection (by name OR message — the cancelled ptyHost +// resolved-variables RPC shape) must NOT terminate the worker; +// - any other rejection must still be forwarded to the parent and exit 1, +// so SpawnedCodeServer's respawn contract is unchanged. + +import { fork, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const workerSource = readFileSync(fileURLToPath(new URL("./worker.mjs", import.meta.url)), "utf8"); + +type WorkerMessage = { type: string } & Record; + +interface WorkerHarness { + child: ChildProcess; + messages: WorkerMessage[]; + /** Mutable container — the `data` callback fills it after `fork()` returns. */ + stderr: { text: string }; + exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; + dir: string; +} + +function spawnWorker(serverBody: string): WorkerHarness { + const dir = mkdtempSync(join(tmpdir(), "coderaft-worker-test-")); + mkdirSync(join(dir, "dist"), { recursive: true }); + writeFileSync(join(dir, "worker.mjs"), workerSource); + writeFileSync( + join(dir, "dist", "index.mjs"), + `export function startCodeServer() { ${serverBody} }`, + ); + + const child = fork(join(dir, "worker.mjs"), { + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + const messages: WorkerMessage[] = []; + const stderr: { text: string } = { text: "" }; + child.on("message", (msg) => messages.push(msg as WorkerMessage)); + child.stderr?.on("data", (chunk: Buffer) => { + stderr.text += chunk.toString(); + }); + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + return { child, messages, stderr, exit, dir }; +} + +const READY_HANDLE = + `return { url: "http://127.0.0.1:8080", port: 8080, ` + + `socketPath: undefined, connectionToken: "test" };`; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function startWorker(serverBody: string): Promise { + const harness = spawnWorker(serverBody); + harness.child.send({ type: "start", opts: {} }); + await waitForReady(harness); + return harness; +} + +function waitForReady(harness: WorkerHarness, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => { + if (harness.messages.some((m) => m.type === "ready")) { + resolve(); + } else if (Date.now() - started > timeoutMs) { + reject(new Error("worker did not become ready")); + } else { + setTimeout(poll, 10); + } + }; + poll(); + }); +} + +describe("worker unhandledRejection filter", () => { + const running: WorkerHarness[] = []; + + afterEach(async () => { + for (const harness of running) { + harness.child.kill("SIGTERM"); + await Promise.race([harness.exit, delay(1000)]); + rmSync(harness.dir, { recursive: true, force: true }); + } + running.length = 0; + }); + + it("does not terminate on a Canceled rejection (by name)", async () => { + const harness = await startWorker(` + setTimeout(() => { + Promise.reject(Object.assign(new Error("canceled"), { name: "Canceled" })); + }, 20); + ${READY_HANDLE} + `); + running.push(harness); + + await delay(200); + + expect(harness.messages.some((m) => m.type === "error")).toBe(false); + expect(harness.child.exitCode).toBeNull(); + expect(harness.stderr.text).toContain("ignoring Canceled rejection"); + }); + + it("does not terminate on a Canceled rejection (by message)", async () => { + const harness = await startWorker(` + setTimeout(() => { + Promise.reject(new Error("Canceled")); + }, 20); + ${READY_HANDLE} + `); + running.push(harness); + + await delay(200); + + expect(harness.messages.some((m) => m.type === "error")).toBe(false); + expect(harness.child.exitCode).toBeNull(); + expect(harness.stderr.text).toContain("ignoring Canceled rejection"); + }); + + it("forwards a non-Canceled rejection to the parent and exits 1", async () => { + const harness = await startWorker(` + setTimeout(() => { + Promise.reject(new Error("ptyHost resolve failed")); + }, 20); + ${READY_HANDLE} + `); + running.push(harness); + + const result = await Promise.race([harness.exit, delay(2000).then(() => null)]); + + expect(result?.code).toBe(1); + const forwarded = harness.messages.find((m) => m.type === "error"); + expect(forwarded).toBeDefined(); + expect(String(forwarded?.message)).toContain("unhandledRejection"); + expect(String(forwarded?.message)).toContain("ptyHost resolve failed"); + }); +});