Skip to content

Commit 3a8dee2

Browse files
authored
fix: gate worker bootstrap on explicit flag instead of process.send (#176)
* fix: gate worker bootstrap on explicit flag instead of process.send The main worker module self-initialized whenever process.send was defined, which is true for any process forked over IPC. A Vitest pool:'forks' test worker that transitively imports the engine therefore ran the bootstrap block and emitted a "ready" message the host IPC layer can't handle, breaking the test run. Pass an explicit --sidequest-worker argv flag when the engine forks the worker and gate the bootstrap on it, so the module stays inert when merely imported. Closes #175 * ci: pin Yarn via corepack instead of floating to latest The setup action ran `yarn set version berry`, which downloads the latest Yarn (4.17.0) and ignores the `packageManager: yarn@4.14.1` pin. The newer Yarn migrates the lockfile metadata from version 9 to 10, so `yarn install --frozen-lockfile` fails with YN0028 (lockfile would be modified). Corepack is already enabled and provisions the pinned 4.14.1 from package.json, so the explicit version step is redundant and harmful. Drop it to keep installs deterministic.
1 parent 9b197ec commit 3a8dee2

5 files changed

Lines changed: 69 additions & 7 deletions

File tree

.github/actions/setup/action.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ runs:
3030
shell: bash
3131
run: corepack enable
3232

33-
- name: Install Yarn Berry
34-
shell: bash
35-
run: yarn set version berry
36-
3733
- name: Install Dependencies
3834
if: steps.cache.outputs.cache-hit != 'true'
3935
shell: bash

packages/engine/src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,10 @@ import path from "path";
22

33
export const DEFAULT_WORKER_PATH = path.resolve(import.meta.dirname, "workers", "main.js");
44
export const DEFAULT_RUNNER_PATH = path.resolve(import.meta.dirname, "shared-runner", "runner.js");
5+
6+
/**
7+
* argv flag the engine passes when it forks the main worker. The worker module gates its
8+
* bootstrap on this flag instead of `!!process.send`, so it stays inert when merely imported
9+
* inside another forked process (e.g. a Vitest `pool: 'forks'` test worker).
10+
*/
11+
export const WORKER_PROCESS_FLAG = "--sidequest-worker";

packages/engine/src/engine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { existsSync } from "fs";
55
import { cpus } from "os";
66
import { fileURLToPath } from "url";
77
import { inspect } from "util";
8-
import { DEFAULT_WORKER_PATH } from "./constants";
8+
import { DEFAULT_WORKER_PATH, WORKER_PROCESS_FLAG } from "./constants";
99
import { Dependency, dependencyRegistry } from "./dependency-registry";
1010
import { JOB_BUILDER_FALLBACK } from "./job/constants";
1111
import { ScheduledJobRegistry } from "./job/cron-registry";
@@ -264,7 +264,7 @@ export class Engine {
264264
if (!this.mainWorker) {
265265
const runWorker = () => {
266266
logger("Engine").debug("Starting main worker...");
267-
this.mainWorker = fork(DEFAULT_WORKER_PATH);
267+
this.mainWorker = fork(DEFAULT_WORKER_PATH, [WORKER_PROCESS_FLAG]);
268268
logger("Engine").debug(`Worker PID: ${this.mainWorker.pid}`);
269269
this.mainWorker.on("message", (msg) => {
270270
if (msg === "ready") {

packages/engine/src/workers/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Backend } from "@sidequest/backend";
22
import { logger } from "@sidequest/core";
33
import cron from "node-cron";
4+
import { WORKER_PROCESS_FLAG } from "../constants";
45
import { Engine, EngineConfig, NonNullableEngineConfig } from "../engine";
56
import { Dispatcher } from "../execution/dispatcher";
67
import { ExecutorManager } from "../execution/executor-manager";
@@ -142,7 +143,11 @@ export class MainWorker {
142143
}
143144
}
144145

145-
const isChildProcess = !!process.send;
146+
// Gate the bootstrap on the explicit flag the engine passes when forking, not on `!!process.send`.
147+
// Any process forked over IPC (including a Vitest `pool: 'forks'` test worker that transitively
148+
// imports this module) has `process.send`, so the old heuristic would self-initialize there and
149+
// emit a "ready" message the host IPC layer can't handle. See issue #175.
150+
const isChildProcess = process.argv.includes(WORKER_PROCESS_FLAG);
146151

147152
if (isChildProcess) {
148153
const worker = new MainWorker();
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { fork } from "node:child_process";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
const here = dirname(fileURLToPath(import.meta.url));
7+
const WORKER_PATH = resolve(here, "../../packages/engine/dist/workers/main.js");
8+
const WORKER_FLAG = "--sidequest-worker";
9+
10+
/**
11+
* Forks the built worker module with the given argv and resolves with the first IPC message it
12+
* emits, or `null` if none arrives within the timeout. The worker is always killed afterwards.
13+
*/
14+
function forkWorkerAndCaptureFirstMessage(argv, timeoutMs = 2000) {
15+
return new Promise((resolvePromise, rejectPromise) => {
16+
const child = fork(WORKER_PATH, argv, { stdio: "ignore" });
17+
const timer = setTimeout(() => {
18+
child.kill();
19+
resolvePromise(null);
20+
}, timeoutMs);
21+
22+
child.once("message", (msg) => {
23+
clearTimeout(timer);
24+
child.kill();
25+
resolvePromise(msg);
26+
});
27+
child.once("error", (err) => {
28+
clearTimeout(timer);
29+
child.kill();
30+
rejectPromise(err);
31+
});
32+
});
33+
}
34+
35+
describe("worker bootstrap gating (issue #175)", () => {
36+
let lastChild;
37+
38+
afterEach(() => {
39+
lastChild?.kill();
40+
lastChild = undefined;
41+
});
42+
43+
it("does NOT emit a 'ready' message when forked without the sentinel flag", async () => {
44+
// Reproduces a Vitest `pool: 'forks'` worker that transitively imports the engine: the process
45+
// has an IPC channel (process.send) but is not a Sidequest worker, so the module must stay inert.
46+
const msg = await forkWorkerAndCaptureFirstMessage([]);
47+
expect(msg).toBeNull();
48+
});
49+
50+
it("emits 'ready' when forked with the sentinel flag", async () => {
51+
const msg = await forkWorkerAndCaptureFirstMessage([WORKER_FLAG]);
52+
expect(msg).toBe("ready");
53+
});
54+
});

0 commit comments

Comments
 (0)