b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
131 lines
4.0 KiB
TypeScript
131 lines
4.0 KiB
TypeScript
/**
|
|
* Exit Handler Tests
|
|
*
|
|
* Regression coverage for a race in WorkerdSandboxRunner.restart(): the
|
|
* exit handler used to capture `this` and unconditionally mutate
|
|
* `this.workerdProcess` / `this.healthy` whenever it fired. A late exit
|
|
* from a previously-spawned workerd could therefore null out the handle
|
|
* of a freshly-spawned workerd and mark it unhealthy.
|
|
*
|
|
* The fix is an identity guard inside `makeWorkerdExitHandler`: if
|
|
* `host.workerdProcess` no longer points at the proc the handler was
|
|
* bound to, the handler is a stale survivor from a prior workerd and
|
|
* must short-circuit.
|
|
*/
|
|
|
|
import type { ChildProcess } from "node:child_process";
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import { makeWorkerdExitHandler } from "../src/sandbox/runner.js";
|
|
import type { ExitHandlerHost } from "../src/sandbox/runner.js";
|
|
|
|
interface FakeHost extends ExitHandlerHost {
|
|
scheduleRestartCalls: number;
|
|
}
|
|
|
|
function makeHost(overrides: Partial<ExitHandlerHost> = {}): FakeHost {
|
|
const host: FakeHost = {
|
|
workerdProcess: null,
|
|
healthy: false,
|
|
shuttingDown: false,
|
|
intentionalStop: false,
|
|
scheduleRestartCalls: 0,
|
|
scheduleRestart() {
|
|
host.scheduleRestartCalls++;
|
|
},
|
|
...overrides,
|
|
};
|
|
return host;
|
|
}
|
|
|
|
// The handler only ever uses `proc` as an identity reference (===
|
|
// comparison against host.workerdProcess), so any object will do.
|
|
function fakeProc(label: string): ChildProcess {
|
|
return { __label: label } as unknown as ChildProcess;
|
|
}
|
|
|
|
describe("makeWorkerdExitHandler", () => {
|
|
it("is a no-op when host has moved on to a newer workerd", () => {
|
|
const procA = fakeProc("A");
|
|
const procB = fakeProc("B");
|
|
const host = makeHost({ workerdProcess: procB, healthy: true });
|
|
|
|
makeWorkerdExitHandler(host, procA)(1, null);
|
|
|
|
// procB is still installed and untouched -- the stale handler
|
|
// must not null it out or mark it unhealthy.
|
|
expect(host.workerdProcess).toBe(procB);
|
|
expect(host.healthy).toBe(true);
|
|
expect(host.scheduleRestartCalls).toBe(0);
|
|
});
|
|
|
|
it("nulls out the handle and schedules a restart on crash exit", () => {
|
|
const procA = fakeProc("A");
|
|
const host = makeHost({ workerdProcess: procA, healthy: true });
|
|
|
|
makeWorkerdExitHandler(host, procA)(1, null);
|
|
|
|
expect(host.workerdProcess).toBeNull();
|
|
expect(host.healthy).toBe(false);
|
|
expect(host.scheduleRestartCalls).toBe(1);
|
|
});
|
|
|
|
it("clears intentionalStop and skips restart for intentional stops", () => {
|
|
const procA = fakeProc("A");
|
|
const host = makeHost({
|
|
workerdProcess: procA,
|
|
healthy: true,
|
|
intentionalStop: true,
|
|
});
|
|
|
|
makeWorkerdExitHandler(host, procA)(0, null);
|
|
|
|
// Handle/health still get cleared (the process is gone), but
|
|
// the intentional-stop flag must be consumed and no restart
|
|
// must be scheduled -- otherwise every plugin reload would
|
|
// trigger a phantom crash-restart cycle.
|
|
expect(host.workerdProcess).toBeNull();
|
|
expect(host.healthy).toBe(false);
|
|
expect(host.intentionalStop).toBe(false);
|
|
expect(host.scheduleRestartCalls).toBe(0);
|
|
});
|
|
|
|
it("skips restart while the runner is shutting down", () => {
|
|
const procA = fakeProc("A");
|
|
const host = makeHost({
|
|
workerdProcess: procA,
|
|
healthy: true,
|
|
shuttingDown: true,
|
|
});
|
|
|
|
makeWorkerdExitHandler(host, procA)(1, "SIGTERM");
|
|
|
|
expect(host.workerdProcess).toBeNull();
|
|
expect(host.healthy).toBe(false);
|
|
expect(host.scheduleRestartCalls).toBe(0);
|
|
});
|
|
|
|
it("schedules a restart when killed by a signal even if code is null", () => {
|
|
const procA = fakeProc("A");
|
|
const host = makeHost({ workerdProcess: procA, healthy: true });
|
|
|
|
makeWorkerdExitHandler(host, procA)(null, "SIGKILL");
|
|
|
|
expect(host.workerdProcess).toBeNull();
|
|
expect(host.healthy).toBe(false);
|
|
expect(host.scheduleRestartCalls).toBe(1);
|
|
});
|
|
|
|
it("does not schedule a restart on a clean exit (code 0, no signal)", () => {
|
|
const procA = fakeProc("A");
|
|
const host = makeHost({ workerdProcess: procA, healthy: true });
|
|
|
|
makeWorkerdExitHandler(host, procA)(0, null);
|
|
|
|
expect(host.workerdProcess).toBeNull();
|
|
expect(host.healthy).toBe(false);
|
|
expect(host.scheduleRestartCalls).toBe(0);
|
|
});
|
|
});
|