From 414822421fb729e051dd242369eb74806d8947b9 Mon Sep 17 00:00:00 2001 From: Hypit H1 Investigation Date: Wed, 16 Sep 2026 08:37:25 +0800 Subject: [PATCH] fix: give the capture process the Distribution resolver its parent has resolve hooks registered by bin/hypit.mjs are process-local, so the HyperFrames capture child, which Node starts directly, saw neither @hypit/* nor the machine packages those modules declare. Only a contributor checkout hid this: pnpm links each workspace package into its own node_modules, while a packed Distribution ships none, so local rendering failed there with ERR_MODULE_NOT_FOUND on @hypit/hyperframes before it reached @hyperframes/engine. Preload the same resolution environment in that child and cover it with a test that starts outside the workspace. --- .../src/capture-bootstrap.ts | 25 ++++++++++++ .../src/capture-process.ts | 8 +++- .../test/capture-process.test.ts | 39 ++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/provider-hyperframes-local/src/capture-bootstrap.ts diff --git a/packages/provider-hyperframes-local/src/capture-bootstrap.ts b/packages/provider-hyperframes-local/src/capture-bootstrap.ts new file mode 100644 index 00000000..be8b1db7 --- /dev/null +++ b/packages/provider-hyperframes-local/src/capture-bootstrap.ts @@ -0,0 +1,25 @@ +/** + * Preloaded into the disposable capture process, which never enters `bin/hypit.mjs`. + * + * `module.registerHooks` is process-local, so the resolver the launcher installs does not + * reach a child that Node starts directly. This registers the same resolution environment + * in the child, so its view of `@hypit/*` and of the machine packages those modules declare + * is identical to its parent's. + * + * Only relative paths can be used here: no hook exists yet, and a Distribution ships its + * package sources without any `node_modules` link between them. + */ +import { resolve } from "node:path"; + +// `bin/hypit.mjs` publishes this for every process it starts; the fallback mirrors +// `packages/video-cli/src/distribution.ts` for callers that import a Distribution directly. +const distributionRoot = resolve( + process.env.HYPIT_DISTRIBUTION_ROOT ?? resolve(import.meta.dirname, "../../.."), +); + +const { installDistributionPackageResolution, installExternalPackageResolution } = + await import("../../package-loader-node/src/distribution-resolution.js"); +installDistributionPackageResolution([distributionRoot]); + +const { hypitHostPackageRoot } = await import("../../runtime-host-node/src/index.js"); +installExternalPackageResolution([hypitHostPackageRoot()]); diff --git a/packages/provider-hyperframes-local/src/capture-process.ts b/packages/provider-hyperframes-local/src/capture-process.ts index b932578a..ef153529 100644 --- a/packages/provider-hyperframes-local/src/capture-process.ts +++ b/packages/provider-hyperframes-local/src/capture-process.ts @@ -62,7 +62,13 @@ export async function runCaptureProcess( ): Promise { signal.throwIfAborted(); await new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), fileURLToPath(entry)], { + // The launcher's resolver hooks are process-local, so this child preloads the same + // environment before it imports any package the Distribution owns. + const child = spawn(process.execPath, [ + "--import", import.meta.resolve("tsx"), + "--import", new URL("./capture-bootstrap.ts", import.meta.url).href, + fileURLToPath(entry), + ], { detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"], }); diff --git a/packages/provider-hyperframes-local/test/capture-process.test.ts b/packages/provider-hyperframes-local/test/capture-process.test.ts index e95e9351..17ac52ab 100644 --- a/packages/provider-hyperframes-local/test/capture-process.test.ts +++ b/packages/provider-hyperframes-local/test/capture-process.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import test from "node:test"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { runCaptureProcess } from "../src/capture-process.js"; import { resolveExecutionOptions } from "../src/render.js"; import type { CaptureInput } from "../src/capture.js"; @@ -41,6 +41,43 @@ setInterval(() => {}, 1000); } }); +test("a capture process resolves Distribution packages without workspace links", async () => { + // A packed Distribution ships its package sources with no node_modules link between them, + // and the launcher's resolver hooks are process-local. Starting this entry outside the + // checkout reproduces exactly that: nothing above it declares @hypit/hyperframes. + const root = await mkdtemp(join(tmpdir(), "hypit-capture-resolution-")); + const tsx = import.meta.resolve("tsx"); + const bootstrap = new URL("../src/capture-bootstrap.ts", import.meta.url).href; + try { + const probe = join(root, "probe.mjs"); + await writeFile(probe, `const module = await import("@hypit/hyperframes/project"); +process.stdout.write("resolved:" + Object.keys(module).join(",") + "\\n");`); + + // Control: the preload is the only difference between the two arms below. + const unresolved = spawnSync(process.execPath, ["--import", tsx, probe], { encoding: "utf8", timeout: 120_000 }); + assert.notEqual(unresolved.status, 0); + assert.match(unresolved.stderr, /ERR_MODULE_NOT_FOUND/u); + + const resolved = spawnSync(process.execPath, ["--import", tsx, "--import", bootstrap, probe], + { encoding: "utf8", timeout: 120_000 }); + assert.equal(resolved.status, 0, resolved.stderr); + assert.match(resolved.stdout, /stageHyperframesProject/u); + + // The render entry point must actually pass that preload to its child. + const worker = join(root, "worker.mjs"); + await writeFile(worker, `process.once("message", async () => { + const module = await import("@hypit/hyperframes/project"); + process.stdout.write("resolved:" + Object.keys(module).join(",") + "\\n"); + process.send({ type: "completed" }); + });`); + const diagnostics: string[] = []; + await runCaptureProcess({ config: resolveExecutionOptions({}) } as CaptureInput, + new AbortController().signal, () => {}, pathToFileURL(worker), + async (message) => { diagnostics.push(message.message); }); + assert.ok(diagnostics.some((message) => message.startsWith("resolved:")), diagnostics.join("\n")); + } finally { await rm(root, { recursive: true, force: true }); } +}); + test("renderer stdout and stderr diagnostics are drained before reporting success", async () => { const root = await mkdtemp(join(tmpdir(), "hypit-render-diagnostics-")); const messages: import("@hypit/runtime").ExecutionDiagnostic[] = [];