fix: retire SDK declaration compiler before semantic rendering

This commit is contained in:
Peter Steinberger
2026-09-27 03:25:19 -07:00
parent 47e26d3289
commit 5e731c1f64
4 changed files with 177 additions and 11 deletions
@@ -0,0 +1,135 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { deserialize, serialize } from "node:v8";
import {
resolveRuntimeWorkerArgv,
resolveRuntimeWorkerUrl,
} from "../../src/infra/runtime-worker-url.js";
import { hasUnjoinedWork, runManagedCommand } from "./managed-child-process.mts";
import { emitNativeDeclarations, type NativeDeclaration } from "./native-declaration-emitter.mts";
import { isRecord } from "./record-shared.mjs";
type DeclarationRequest = Pick<
Parameters<typeof emitNativeDeclarations>[0],
"cwd" | "configFile" | "roots" | "compilerOptions" | "diagnostics"
>;
function readRequest(file: string): DeclarationRequest {
const value: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
if (
!isRecord(value) ||
typeof value.cwd !== "string" ||
typeof value.configFile !== "string" ||
!Array.isArray(value.roots) ||
!value.roots.every((root): root is string => typeof root === "string") ||
(value.compilerOptions !== undefined && !isRecord(value.compilerOptions)) ||
(value.diagnostics !== undefined &&
value.diagnostics !== "declarations" &&
value.diagnostics !== "all")
) {
throw new Error("Invalid native declaration subprocess request");
}
return {
cwd: value.cwd,
configFile: value.configFile,
roots: value.roots,
compilerOptions: value.compilerOptions,
diagnostics: value.diagnostics,
};
}
function isDeclaration(value: unknown): value is NativeDeclaration {
if (!isRecord(value) || typeof value.code !== "string" || !isRecord(value.map)) {
return false;
}
const map = value.map;
return (
map.version === 3 &&
typeof map.file === "string" &&
typeof map.mappings === "string" &&
Array.isArray(map.sources) &&
map.sources.every((source) => typeof source === "string") &&
Array.isArray(map.names) &&
map.names.every((name) => typeof name === "string")
);
}
function readResult(file: string): Awaited<ReturnType<typeof emitNativeDeclarations>> {
const value: unknown = deserialize(fs.readFileSync(file));
if (
!isRecord(value) ||
!Array.isArray(value.inputs) ||
!value.inputs.every((input): input is string => typeof input === "string") ||
!(value.declarations instanceof Map)
) {
throw new Error("Invalid native declaration subprocess result");
}
const declarations = new Map<string, NativeDeclaration>();
for (const [source, declaration] of value.declarations) {
if (typeof source !== "string" || !isDeclaration(declaration)) {
throw new Error("Invalid native declaration subprocess output");
}
declarations.set(source, declaration);
}
return { inputs: value.inputs, declarations };
}
/** Join native emission before synchronous semantic work can prevent its shutdown. */
export async function emitNativeDeclarationsInSubprocess(options: DeclarationRequest) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-declarations-"));
const request = path.join(directory, "request.json");
let joined = true;
try {
fs.writeFileSync(request, JSON.stringify(options));
const worker = resolveRuntimeWorkerUrl({
currentModuleUrl: import.meta.url,
sourceWorkerName: "native-declaration-subprocess",
distWorkerPath: "legacy-finalizer/scripts/lib/native-declaration-subprocess.js",
});
const heapArgs = process.execArgv.flatMap((arg, index, args) =>
/^--max[-_]old[-_]space[-_]size=/.test(arg)
? [arg]
: /^--max[-_]old[-_]space[-_]size$/.test(arg)
? [arg, args[index + 1]!]
: [],
);
let errorOutput = "";
const code = await runManagedCommand({
bin: process.execPath,
args: [...heapArgs, ...resolveRuntimeWorkerArgv(worker), request],
cwd: options.cwd,
shell: false,
stdio: ["ignore", "ignore", "pipe"],
requireProcessTreeExit: process.platform !== "win32",
onReady(child) {
child.stderr!.setEncoding("utf8");
child.stderr!.on("data", (chunk: string) => {
errorOutput += chunk.slice(0, Math.max(0, 64 * 1024 - errorOutput.length));
});
},
});
if (code !== 0) {
throw new Error(
errorOutput.trim() || `Native declaration subprocess exited with code ${code}`,
);
}
return readResult(path.join(directory, "result.bin"));
} catch (error) {
joined = !hasUnjoinedWork(error);
throw error;
} finally {
if (joined) {
fs.rmSync(directory, { recursive: true, force: true });
}
}
}
if (import.meta.main) {
const request = process.argv[2];
if (!request || process.argv.length !== 3) {
throw new Error("Expected one native declaration request path");
}
const result = await emitNativeDeclarations(readRequest(request));
fs.writeFileSync(path.join(path.dirname(request), "result.bin"), serialize(result));
}
@@ -132,6 +132,7 @@ export const preservedModuleBuildSources = [
"scripts/run-additional-boundary-checks.mts",
"scripts/run-with-env.mts",
"scripts/plugin-sdk-api-diff.mts",
"scripts/lib/native-declaration-subprocess.mts",
"scripts/test-projects.mts",
"scripts/lib/vitest-build-prerequisites.mts",
"scripts/lib/vitest-batch-runner.mts",
+39 -9
View File
@@ -6,8 +6,8 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import * as ts from "typescript/unstable/ast";
import { Program } from "typescript/unstable/async";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as nativeDeclarations from "../../scripts/lib/native-declaration-subprocess.mts";
import * as nativeTypeScript from "../../scripts/lib/native-typescript.mts";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { createDeclarationClosureRenderer } from "./api-baseline-declaration-closure.js";
@@ -577,6 +577,38 @@ describe("Plugin SDK API baseline", () => {
expect(fixtureError).not.toContain("return this.status");
});
it.skipIf(process.platform === "win32")(
"joins the native declaration compiler before opening semantic overlays",
async () => {
const createProject = nativeTypeScript.createNativeTypeScriptProject;
let observedOverlay = false;
const create = vi
.spyOn(nativeTypeScript, "createNativeTypeScriptProject")
.mockImplementation((options) => {
if (Object.keys(options.files ?? {}).some((file) => file.endsWith("fixture.ts"))) {
observedOverlay = true;
const processes = spawnSync("ps", ["-axo", "pid=,ppid=,command="], {
encoding: "utf8",
});
expect(processes.status).toBe(0);
expect(
processes.stdout
.split("\n")
.filter((line) => line.includes("--api --async") && line.includes(options.cwd)),
).toEqual([]);
}
return createProject(options);
});
try {
const rendered = await renderSourceFixture({ "fixture.ts": "export const value = 1;" });
expect(observedOverlay).toBe(true);
expect(rendered.modules[0]?.exports[0]?.declaration).toBe("export const value: 1;");
} finally {
create.mockRestore();
}
},
);
it.each(
["source project creation", "declaration emission"].flatMap((timing) =>
[0, 60_000].map((clockSkewMs) => ({ timing, clockSkewMs })),
@@ -644,7 +676,8 @@ describe("Plugin SDK API baseline", () => {
const createProject = nativeTypeScript.createNativeTypeScriptProject;
const create = vi.spyOn(nativeTypeScript, "createNativeTypeScriptProject");
// Declaration errors come from the emit result, the last compiler stage before publication.
const emit = vi.spyOn(Program.prototype, "emitToString");
const emitDeclarations = nativeDeclarations.emitNativeDeclarationsInSubprocess;
const emit = vi.spyOn(nativeDeclarations, "emitNativeDeclarationsInSubprocess");
if (timing === "source project creation") {
create.mockImplementationOnce(function intercept(options) {
const native = createProject(options);
@@ -657,14 +690,11 @@ describe("Plugin SDK API baseline", () => {
return native;
});
} else {
emit.mockImplementationOnce(async function (this: Program, ...args) {
emit.mockImplementationOnce(async function (options) {
emit.mockRestore();
const result = await this.emitToString(...args);
expect(
(await this.getSourceFileNames()).some((file) => path.resolve(file) === entry),
).toBe(true);
expect(result.emitSkipped).toBe(false);
expect(result.diagnostics).toEqual([]);
const result = await emitDeclarations(options);
expect(result.inputs).toContain(entry);
expect(result.declarations.get(entry)?.code).toContain('"checked"');
changeSource();
return result;
});
+2 -2
View File
@@ -10,8 +10,8 @@ import {
type Symbol as CompilerSymbol,
} from "typescript/unstable/sync";
import { CompilerInputSnapshot } from "../../scripts/lib/compiler-input-snapshot.mts";
import { emitNativeDeclarations } from "../../scripts/lib/native-declaration-emitter.mts";
import { createDeclarationFileSystem } from "../../scripts/lib/native-declaration-filesystem.mts";
import { emitNativeDeclarationsInSubprocess } from "../../scripts/lib/native-declaration-subprocess.mts";
import {
createNativeTypeScriptProject,
resolveInstalledNativeTypeScriptCompiler,
@@ -146,7 +146,7 @@ async function createCompilerContext(
fs: view.filesystem,
});
view.assertValid();
const emitted = await emitNativeDeclarations({
const emitted = await emitNativeDeclarationsInSubprocess({
cwd: repoRoot,
configFile: configPath,
roots: fileNames,