Merge PR #2926: fix Turbopack Docker build — plugin loader fork→spawn (+ IPC test)

URGENT: Fix Docker release build failure for v3.8.7
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-29 21:47:09 -03:00
committed by GitHub
2 changed files with 64 additions and 11 deletions

View File

@@ -1,14 +1,14 @@
/**
* Plugin loader — loads plugins in isolated child processes.
*
* Uses child_process.fork() for process-level isolation. Each plugin
* Uses a child Node.js process with IPC for process-level isolation. Each plugin
* runs in a separate Node.js process with restricted environment.
* Complies with Rule 3 (no eval/new Function/implied eval).
*
* @module plugins/loader
*/
import { fork } from "child_process";
import { spawn } from "child_process";
import { writeFile, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
@@ -29,7 +29,7 @@ export interface LoadedPlugin {
cleanup: () => void;
}
// ── Plugin host script (runs in child process via fork) ──
// ── Plugin host script (runs in child process over IPC) ──
// Uses process.send()/process.on("message") — NOT worker_threads.
// Written as .mjs to force ESM execution regardless of package.json.
@@ -83,10 +83,9 @@ export async function loadPlugin(
PLUGIN_NAME: manifest.name,
};
const child = fork(hostScriptPath, [entryPoint], {
const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], {
env,
stdio: ["pipe", "pipe", "pipe", "ipc"],
execArgv: ["--no-warnings"],
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
// Track pending calls with timeout support

View File

@@ -1,10 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Loader uses child_process.fork() — we test the module exports and types,
// not actual fork behavior (that requires integration tests with real plugins).
import type { LoadedPlugin } from "../../src/lib/plugins/loader.ts";
import { loadPlugin, type LoadedPlugin } from "../../src/lib/plugins/loader.ts";
import type { Plugin, PluginContext, PluginResult } from "../../src/lib/plugins/index.ts";
// ── Type checks ──
@@ -39,7 +39,7 @@ test("Plugin interface supports lifecycle hooks", () => {
onRequest: async (_ctx: PluginContext): Promise<PluginResult | void> => {
return { blocked: false };
},
onResponse: async (_ctx: PluginContext, response: any) => response,
onResponse: async (_ctx: PluginContext, response: unknown) => response,
onError: async (_ctx: PluginContext, _error: Error) => null,
};
assert.equal(typeof plugin.onRequest, "function");
@@ -76,3 +76,57 @@ test("PluginResult supports body modification", () => {
assert.equal(modified.body.model, "gpt-4-turbo");
assert.equal(modified.metadata?.plugin, "model-switcher");
});
test(
"loadPlugin runs hooks in an isolated child process over IPC",
{ timeout: 5_000 },
async (t) => {
const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-loader-"));
const entryPoint = join(pluginDir, "index.mjs");
let loaded: LoadedPlugin | undefined;
t.after(async () => {
loaded?.cleanup();
await rm(pluginDir, { recursive: true, force: true });
});
await writeFile(
entryPoint,
`
export async function onRequest(ctx) {
return {
body: { ...ctx.body, touchedByPlugin: true },
metadata: { pluginHook: "onRequest" },
};
}
`,
"utf-8"
);
loaded = await loadPlugin(entryPoint, {
name: "ipc-test",
version: "1.0.0",
license: "MIT",
main: "index.mjs",
source: "local",
tags: [],
requires: { permissions: [] },
hooks: { onRequest: true, onResponse: false, onError: false },
skills: [],
enabledByDefault: false,
configSchema: {},
});
const result = await loaded.plugin.onRequest?.({
requestId: "test-request",
body: { model: "gpt-4" },
model: "gpt-4",
metadata: {},
});
assert.deepEqual(result, {
body: { model: "gpt-4", touchedByPlugin: true },
metadata: { pluginHook: "onRequest" },
});
}
);