Compare commits

..
Author SHA1 Message Date
morlutoand吴佳浩 1b36fc2d50 fix(scripts): validate package-map ownership and preserve HTML fallback (#10097)
* fix(website): keep the HTML validator when the Markdown route is absent

A caller that prefers Markdown gets the HTML probe with its conditional headers
deleted, so the Markdown route can be evaluated on its own. When the Markdown
sidecar is missing the worker answers with that probe, which never saw the
caller's `If-None-Match`, so a matching HTML ETag returned 200 instead of 304.

Ask the asset service again with the caller's own request before answering with
HTML. The decision moves into `htmlFallback` so the request handler keeps its
complexity below the repository bound.

Verified by executing `node --test website/worker.test.mjs`: without the change
the new case fails with 200 instead of 304, and all four cases pass with it.
`oxlint` reports no findings and `oxfmt --check` passes. The website worker test
is not part of a CI workflow, so this suite runs locally only.

* fix(scripts): resolve package-map imports in the module-boundary guard

The guard resolved only relative, absolute and `file:` specifiers, so every
`#`-prefixed package-map import returned no target and was skipped. A pure-layer
file importing `#javascript-analysis-worker` passed, while the same import
written relatively failed with a `pure-layer` diagnostic: the gate failed open
on the spelling the package map exists to encourage.

Resolve a package-map specifier to the source file that produces its built
output, then apply the same ownership rules. The package map is read once per
repository root.

Verified with the new `scripts/lib/module-boundaries.test.mjs` (2 pass; the
package-map case failed before the change) and by running the complete guard on
this checkout, which still reports 4138 files with no findings, so the new rule
adds no false positive. `oxfmt --check` passes; `oxlint` reports the same single
pre-existing `failedBoundary` complexity warning it reported before this change.
The script tests are not part of a CI workflow, so this suite runs locally only.

* fix(scripts): normalize package targets as file URLs

---------

Co-authored-by: 吴佳浩 <[email protected]>
2026-11-11 08:27:39 -05:00
N0zoM1z0andgensokyo-dev 40d9f1a5e8 test(kuna): cancel inspection scenarios at their owning boundary
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev fbbf94a2c4 test(kuna): cover cancellation during native ownership preparation
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 4a17bf76ed test(kuna): distinguish prelaunch expiry from captured command output
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 04042cf540 test(kuna): scope short deadlines to hanging decompile work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 2127cff82d test(kuna): acknowledge subprocess phases and join cancelled work
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
N0zoM1z0andgensokyo-dev 1dc54783ef fix(kuna): preserve cancellation through workspace admission and rollback
Co-authored-by: gensokyo-dev <[email protected]>
2026-11-11 21:19:01 +08:00
18 changed files with 822 additions and 261 deletions

No files matched your search

+55 -2
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -29,8 +30,60 @@ const CENTRALIZED_CONSTRUCTOR_OWNER =
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
const sourceTarget = (file, specifier) => {
const packageImports = new Map();
/** Read the package map once per repository root. */
const readPackageImports = (root) => {
if (!packageImports.has(root)) {
try {
const manifest = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
);
packageImports.set(root, manifest.imports ?? {});
} catch (cause) {
throw new Error(
`Cannot read package imports from ${resolve(root, "package.json")}: ${cause.message}`,
{ cause },
);
}
}
return packageImports.get(root);
};
/**
* The own source file a package-map specifier names.
*
* The map points at built output under `dist/`, so the same ownership rule is
* applied to the source file that produces it. Without this the gate silently
* accepts every `#`-prefixed import, which is the spelling the package map exists
* to encourage.
*/
const packageMapTarget = (root, specifier) => {
const target = readPackageImports(root)[specifier];
if (typeof target !== "string")
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)}: this boundary check supports exact string mappings only; use a relative source import for conditional, array, pattern, or undeclared mappings.`,
);
const source = target.startsWith("./")
? repositoryPath(
root,
fileURLToPath(
new URL(target, pathToFileURL(resolve(root, "package.json"))),
),
)
.replace(/^dist\//u, "src/")
.replace(/\.(?:js|mjs|cjs)$/u, ".ts")
: undefined;
if (source === undefined || !source.startsWith("src/"))
throw new Error(
`Cannot resolve package import ${JSON.stringify(specifier)} to a source owner from ${JSON.stringify(target)}; use a relative source import for this boundary check.`,
);
return resolve(root, source);
};
const sourceTarget = (root, file, specifier) => {
if (specifier.startsWith("file:")) return fileURLToPath(specifier);
if (specifier.startsWith("#")) return packageMapTarget(root, specifier);
if (isAbsolute(specifier)) return resolve(specifier);
return specifier.startsWith(".")
? fileURLToPath(new URL(specifier, pathToFileURL(file)))
@@ -101,7 +154,7 @@ export const inspectModuleBoundaries = (file, source, root) => {
const importer = repositoryPath(root, absoluteFile);
const violations = [];
for (const { specifier, line, typeOnly } of readImportSpecifiers(source)) {
const resolved = sourceTarget(absoluteFile, specifier);
const resolved = sourceTarget(root, absoluteFile, specifier);
if (resolved === undefined) continue;
const target = repositoryPath(root, resolved);
const boundary = failedBoundary(importer, target, typeOnly);
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { inspectModuleBoundaries } from "./module-boundaries.mjs";
const fixture = async () => {
const root = await mkdtemp(join(tmpdir(), "rea-module-boundaries-"));
await mkdir(join(root, "src/domain"), { recursive: true });
await mkdir(join(root, "src/composition"), { recursive: true });
await writeFile(
join(root, "src/composition/worker.ts"),
"export default 1;\n",
);
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": "./dist/composition/worker.js" } }),
);
return root;
};
await test("reports a pure-layer composition import written relatively", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "../composition/worker.js";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.js" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
await test("reports a pure-layer composition import written through the package map", async () => {
const root = await fixture();
try {
const violations = inspectModuleBoundaries(
"src/domain/probe.ts",
'import worker from "#worker";\nexport default worker;\n',
root,
);
assert.deepEqual(
violations.map(({ boundary, target }) => ({ boundary, target })),
[{ boundary: "pure-layer", target: "src/composition/worker.ts" }],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
for (const [name, specifier, imports] of [
[
"conditional",
"#worker",
{
"#worker": {
import: "./dist/composition/worker.js",
default: "./dist/composition/other.js",
},
},
],
["array", "#worker", { "#worker": ["./dist/composition/worker.js"] }],
["pattern", "#workers/worker", { "#workers/*": "./dist/composition/*.js" }],
]) {
await test(`refuses an unsupported valid ${name} package map rather than skipping its edge`, async () => {
const root = await fixture();
try {
await writeFile(join(root, "package.json"), JSON.stringify({ imports }));
const file = "src/domain/probe.ts";
await writeFile(
join(root, file),
`import ${JSON.stringify(specifier)};\n`,
);
// Resolve only: no worker module or authored target source is executed.
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
specifier,
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
resolved.stdout.trim(),
pathToFileURL(join(root, "dist/composition/worker.js")).href,
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(checked.stderr, /Could not inspect src\/domain\/probe.ts/u);
assert.match(checked.stderr, /Cannot resolve package import/u);
assert.match(checked.stderr, /relative source import/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
for (const target of [
"./dist/composition/worker.js",
"./dist/%63omposition/worker.js",
"./%64ist/composition/worker.js",
]) {
await test(`checks the URL-normalized source owner of ${target}`, async () => {
const root = await fixture();
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ imports: { "#worker": target } }),
);
await mkdir(join(root, "src/application"), { recursive: true });
const file = "src/application/probe.ts";
await writeFile(join(root, file), 'import "#worker";\n');
const resolved = spawnSync(
process.execPath,
[
"--experimental-import-meta-resolve",
"--input-type=module",
"-e",
"console.log(import.meta.resolve(process.argv[1], process.argv[2]))",
"#worker",
pathToFileURL(join(root, file)).href,
],
{ encoding: "utf8" },
);
if (resolved.error !== undefined) throw resolved.error;
assert.equal(resolved.status, 0, resolved.stderr);
assert.equal(
fileURLToPath(resolved.stdout.trim()),
join(root, "dist/composition/worker.js"),
);
const checked = spawnSync(
process.execPath,
[
fileURLToPath(
new URL("../verify-module-boundaries.mjs", import.meta.url),
),
file,
],
{ cwd: root, encoding: "utf8" },
);
if (checked.error !== undefined) throw checked.error;
assert.equal(checked.status, 1, checked.stdout);
assert.match(
checked.stderr,
/application-composition: #worker resolves to src\/composition\/worker.ts/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
}
+15 -9
View File
@@ -14,7 +14,10 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import {
isProcessOwnershipInspectionCancellation,
prepareProcessOwnershipInspection,
} from "../process/ProcessOwnershipObservation.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import {
copyAndHash,
@@ -353,14 +356,17 @@ export class KunaTargetCommands {
prefix: copied.prefix,
});
} catch (cause: unknown) {
const primary = new ProviderAdapterError(
KUNA_PROVIDER_ID,
"kuna:workspace",
{
cause,
userMessage: `Kuna workspace failed: ${cause instanceof Error ? cause.message : String(cause)}`,
},
);
const primary = isProcessOwnershipInspectionCancellation(
cause,
this.lifetime.signal,
)
? new AnalysisCancelledError("kuna:workspace", {
cause: this.lifetime.signal.reason,
})
: new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", {
cause,
userMessage: `Kuna workspace failed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
return this.#workspaceFailure(primary, owned);
}
}
@@ -1,13 +1,33 @@
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { writeFile } from "node:fs/promises";
import { chmod, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
afterEach(() => {
import { writeAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js";
import type { AnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js";
import {
snapshotBinding,
snapshotTarget,
} from "../../../src/domain/analysisSnapshot.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { REA_WORKFLOW_PROVIDER } from "../../../src/domain/investigationProviders.js";
import { workflowAnalysisProfile } from "../../../src/application/WorkflowAnalysisProfile.js";
import { HOPPER_PROVIDER_IDENTITY } from "../../../src/hopper/HopperProviderMetadata.js";
import { resolveHopperAnalysisProfile } from "../../../src/hopper/HopperAnalysisProfile.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
vi.unstubAllEnvs();
});
@@ -40,4 +60,55 @@ describe("direct analysis snapshot files", () => {
},
});
});
it("does not replay unbound historical Evidence from a supplied snapshot", async () => {
const directory = await createTestTempDirectory("rea-direct-snapshot-");
const snapshotPath = join(directory, "analysis.json");
const launcherPath = join(directory, "hopper-launcher");
await writeFile(launcherPath, "fixture Hopper launcher");
await chmod(launcherPath, 0o755);
const target = await parseBinaryTarget(process.execPath);
if (!target.ok) throw target.error;
const resolved = await resolveHopperAnalysisProfile(target.value, {
launcherPath,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: inspectionResources.owner,
});
if (!resolved.ok || resolved.value.profile === null)
throw new Error("fixture Hopper profile did not resolve");
const profile = resolved.value.profile;
const workflowProfile = workflowAnalysisProfile(profile);
const evidence = createEvidence(target.value, REA_WORKFLOW_PROVIDER, {
operation: "binary_overview",
parameters: {},
result: { cached: true },
analysisProfile: workflowProfile,
});
const snapshot: AnalysisSnapshot = {
target: snapshotTarget(target.value),
binding: snapshotBinding(profile),
entries: [],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([evidence]),
};
const written = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(snapshot, snapshotPath, false, resources),
);
if (!written.ok) throw written.error;
vi.stubEnv("HOPPER_LAUNCHER_PATH", launcherPath);
await expect(
createDirectAnalysis(process.env)
.runDirectAnalysis(
process.execPath,
"binary_overview",
{},
{
snapshotPath,
},
)
.then(projectCliAnalysisResult),
).resolves.not.toEqual(evidence);
});
});
@@ -150,7 +150,6 @@ const expectSourceSnapshotRetained = (snapshot: AnalysisSnapshot) => {
interface SnapshotProviderTrace {
readonly starts: string[];
readonly sources: Map<string, Evidence>;
readonly queries: Array<{
readonly operation: string;
readonly parameters: Readonly<Record<string, unknown>>;
@@ -257,24 +256,25 @@ const makeProvider = (
`Unmodeled snapshot provider operation: ${operation}`,
);
const result = results[operation];
const source = options.sourceEvidence
? createEvidence(target, profile.provider, {
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
})
: undefined;
if (source !== undefined)
trace.sources.set(source.evidence_id, source);
return ok(
createAnalysisExecution(result, profile.provider, {
analysisProfile: profile,
rawResult: result,
limitations: options.limitations ?? [],
...(source === undefined ? {} : { sourceEvidence: [source] }),
...(options.sourceEvidence
? {
sourceEvidence: [
createEvidence(target, profile.provider, {
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
}),
],
}
: {}),
}),
);
},
@@ -293,7 +293,6 @@ const createSnapshotReplayFixture = async (
const snapshotPath = join(directory, snapshotRelativePath);
await writeFile(path, "fixture");
const starts: string[] = [];
const sources = new Map<string, Evidence>();
const queries: SnapshotProviderTrace["queries"] = [];
const resolutions: string[] = [];
@@ -301,14 +300,13 @@ const createSnapshotReplayFixture = async (
path,
snapshotPath,
starts,
sources,
get calls() {
return queries.map(({ operation }) => operation);
},
queries,
resolutions,
createProvider(options: SnapshotProviderOptions = {}): AnalysisProvider {
return makeProvider({ starts, sources, queries, resolutions }, options);
return makeProvider({ starts, queries, resolutions }, options);
},
dependencies(provider: AnalysisProvider): DirectAnalysisDependencies {
return {
@@ -1077,7 +1075,7 @@ describe("Ghidra composed workflow runtime-budget admission", () => {
});
describe("binary overview snapshot replay", () => {
it("replays bound overviews without provider work and recomposes unbound history", async () => {
it("replays the identical binary overview without provider startup or calls", async () => {
const fixture = await createSnapshotReplayFixture("rea-workflow-snapshot-");
const provider = fixture.createProvider();
const dependencies = fixture.dependencies(provider);
@@ -1161,47 +1159,6 @@ describe("binary overview snapshot replay", () => {
(record) => record.evidence_id === firstEvidence.evidence_id,
)?.caller_input_path,
).toBe(fixture.path);
// Historical observations remain importable data without a replay binding.
// Retain the primitive entries and one current-profile historical overview,
// but omit its workflow entry and the bound current overview record.
const historicalOnly = withEarlierHistoricalEvidence(
loaded.value,
fixture.path,
current,
);
const unboundPath = join(dirname(fixture.snapshotPath), "unbound.json");
const unboundWritten = await withArtifactResourceScope((resources) =>
writeAnalysisSnapshot(
{
...historicalOnly,
workflow_entries: [],
evidence_bundle: createEvidenceBundle(
historicalOnly.evidence_bundle.records.filter(
(record) => record.evidence_id !== current.evidence_id,
),
),
},
unboundPath,
false,
resources,
),
);
if (!unboundWritten.ok) throw unboundWritten.error;
const recomposed = parseEvidence(
await runDirectAnalysis(
dependencies,
fixture.path,
"binary_overview",
{},
{ snapshotPath: unboundPath },
).then(projectCliAnalysisResult),
);
expect(recomposed.operation).toBe("binary_overview");
expect(recomposed.normalized_result).toEqual(
firstEvidence.normalized_result,
);
expect(recomposed.caller_input_path).toBe(fixture.path);
});
});
@@ -2322,8 +2279,6 @@ it("publishes captured function sources when completed parent admission fails",
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
const sourceIds = [...fixture.sources.keys()];
expect(sourceIds).toHaveLength(1);
const outputPath = join(dirname(fixture.snapshotPath), "refused.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
@@ -2344,9 +2299,8 @@ it("publishes captured function sources when completed parent admission fails",
(record) => record.operation === "analyze_function",
);
expect(parent).toBeDefined();
expect(parent?.evidence_links).toEqual(sourceIds);
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
[parent!.evidence_id, ...sourceIds].sort(),
[parent!.evidence_id, ...parent!.evidence_links].sort(),
);
});
@@ -107,21 +107,7 @@ mcpTest(
name: "inspect_native_initializers",
arguments: { path },
});
expect(parseMcpToolError(missing).error).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_native_initializers",
issues: [
{
path: ["architecture"],
reason: "invalid_value",
message: expect.stringMatching(
/arm64 \(subtype 0x0\).*select one architecture/u,
),
},
],
},
});
expect(missing.isError).toBe(true);
const selected = await client.callTool({
name: "inspect_native_initializers",
arguments: { path, architecture: "arm64" },
+20 -4
View File
@@ -54,6 +54,8 @@ export interface FakeKunaBehavior {
/** Extra C comment bytes inside each exported body, for buffered-read capacity. */
readonly cPadding?: number;
readonly decompile?: "ok" | "hang";
/** Hold successful work until releaseFakeKunaCommand acknowledges the test phase. */
readonly gatedCommands?: readonly ("decompile" | "decompile-project")[];
/** Milliseconds one `decompile --addr` run takes before printing. */
readonly decompileDelayMs?: number;
/** Extra comment bytes in each `decompile --addr` text, for retention budgets. */
@@ -111,6 +113,14 @@ fs.appendFileSync(__filename + ".calls", JSON.stringify({ pid: process.pid, at:
const args = process.argv.slice(2);
const functions = behavior.functions ?? [{ name: "main", addr: "0x1000" }, { name: "helper", addr: "0x1040" }];
const hang = () => setInterval(() => {}, 1000);
const whenReleased = (command, action) => {
if (!behavior.gatedCommands?.includes(command)) { action(); return; }
const waiting = setInterval(() => {
if (!fs.existsSync(__filename + ".release-" + command)) return;
clearInterval(waiting);
action();
}, 10);
};
if (args[0] === "--version") {
const version = behavior.version ?? { stdout: "kuna 1.833\n" };
if ("exit" in version) { process.stderr.write("version probe failed\n"); process.exit(version.exit); }
@@ -176,7 +186,7 @@ if (args[0] === "--version") {
const mode = behavior.project ?? "ok";
if (mode === "hang") hang();
else if (mode === "exit-failure") { process.stderr.write("error: not in recognized object file format\n"); process.exit(1); }
else setTimeout(() => {
else whenReleased("decompile-project", () => setTimeout(() => {
fs.mkdirSync(output, { recursive: true });
let c = '#include "' + stem + '.h"\n\n';
functions.forEach((fn, index) => {
@@ -197,17 +207,17 @@ if (args[0] === "--version") {
const reported = mode === "miscounted" ? functions.length + 1 : functions.length - failed;
if (mode === "noisy") process.stdout.write(Buffer.alloc(9 * 1024 * 1024, 0x20));
if (mode !== "silent") process.stdout.write("wrote " + output + ": " + stem + ".c (" + c.length + " bytes); functions: " + reported + " ok, " + failed + " failed\n");
}, behavior.delayMs ?? 0);
}, behavior.delayMs ?? 0));
} else if (args[0] === "decompile") {
// decompile <input> <0xaddr> --addr: the single-function path.
const fn = functions.find((candidate) => candidate.addr === args[2]);
if (behavior.decompile === "hang") hang();
else if (fn === undefined) { process.stderr.write("error: no function at " + args[2] + "\n"); process.exit(1); }
else setTimeout(() => {
else whenReleased("decompile", () => setTimeout(() => {
const padding = behavior.decompilePadding === undefined ? "" : "/*" + "x".repeat(behavior.decompilePadding) + "*/\n";
process.stdout.write("int " + fn.name + "(void) // single-function\n" + padding + "{\n return 0;\n}\n");
fs.appendFileSync(__filename + ".exits", JSON.stringify({ pid: process.pid, at: Date.now() }) + "\n");
}, behavior.decompileDelayMs ?? 0);
}, behavior.decompileDelayMs ?? 0));
} else {
process.stderr.write("unexpected command\n");
process.exit(2);
@@ -267,3 +277,9 @@ export const fakeKunaExits = async (
}),
);
};
/** Release a command only after the test has observed its required interleaving. */
export const releaseFakeKunaCommand = (
path: string,
command: "decompile" | "decompile-project",
): Promise<void> => writeFile(`${path}.release-${command}`, "released");
@@ -1,10 +1,11 @@
import { expect, it as test, vi } from "vitest";
import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { inspectKunaInstallationOwned as inspectKunaInstallation } from "./kunaResourceScope.js";
import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js";
import { fakeKunaCalls } from "./fakeKuna.js";
import { session } from "./kunaSessionFixture.js";
import { session, awaitKunaCommand } from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -248,3 +249,75 @@ it("does not invent a caller cause when closed without cancellation", async () =
expect(await fakeKunaCalls(kuna)).toEqual(callsBefore);
expect(await commands.close()).toEqual({ ok: true, value: null });
});
it.each(["before-launch", "after-launch"] as const)(
"keeps timeout output coverage truthful when expiry is %s",
async (phase) => {
const fixture = await session("rea-kuna-timeout-output-", {
inventory: "hang",
});
const installed = await inspectKunaInstallation({
REA_KUNA_COMMAND: fixture.kuna,
REA_KUNA_CACHE_DIR: fixture.cacheRoot,
});
if (!installed.ok) throw new Error(installed.error.reason);
const controller = new AbortController();
const waitForDeadline: KunaProcessLauncher = async (options) =>
new Promise<never>((_resolve, reject) => {
const signal = options.signal;
if (signal === undefined)
throw new Error("Missing command deadline signal");
if (signal.aborted) reject(signal.reason);
else
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
});
const commands = new KunaTargetCommands(fixture.target, {
signal: controller.signal,
...(phase === "before-launch" ? { launcher: waitForDeadline } : {}),
});
let pending: ReturnType<typeof commands.run> | undefined;
try {
expect((await commands.prepare()).ok).toBe(true);
pending = commands.run({
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
deadline: {
setting: "REA_KUNA_TIMEOUT_MS",
source: "setting",
ms: 1000,
},
outputBytes: 16384,
signal: controller.signal,
});
if (phase === "after-launch")
await awaitKunaCommand(fixture.kuna, "functions", pending);
const result = await pending;
if (result.ok) throw new Error("Expected a command timeout");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 1000 },
});
const calls = (await fakeKunaCalls(fixture.kuna)).filter(
({ argv }) => argv[0] === "functions",
);
expect(calls).toHaveLength(phase === "after-launch" ? 1 : 0);
if (phase === "after-launch") {
expect(projected.details).toMatchObject({
captured_output: { stdout: "", stderr: "", truncated: false },
});
} else {
expect(result.error.capturedOutput).toBeUndefined();
expect(projected.details).not.toHaveProperty("captured_output");
}
} finally {
controller.abort();
await commands.close();
await pending;
expect((await commands.close()).ok).toBe(true);
}
},
);
@@ -32,7 +32,7 @@ it("runs a cold independent request without starting a project export", async ()
{
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(root, "cache"),
REA_KUNA_TIMEOUT_MS: "300",
REA_KUNA_TIMEOUT_MS: "10000",
},
{ platform: process.platform },
);
@@ -67,6 +67,11 @@ it("runs a cold independent request without starting a project export", async ()
functions: FUNCTIONS,
decompile: "hang",
});
await call("update_analysis_settings", {
changes: [
{ setting: "kuna.function_timeout_ms", action: "set", value: 300 },
],
});
const timeout = await harness.client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "main", analysis: "independent_fresh" },
@@ -84,6 +89,7 @@ it("runs a cold independent request without starting a project export", async ()
limit: {
setting: "kuna.function_timeout_ms",
effective_value: 300,
source: "runtime",
update_mode: "runtime",
pending_restart: false,
minimum: 1,
@@ -96,6 +102,9 @@ it("runs a cold independent request without starting a project export", async ()
({ argv }) => argv[0] === "decompile-project",
),
).toHaveLength(0);
await call("update_analysis_settings", {
changes: [{ setting: "kuna.function_timeout_ms", action: "reset" }],
});
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
await call("procedure_assembly", { procedure: "main" });
expect(
@@ -231,7 +240,7 @@ it("retains the independent run slot until an unproven stop is resolved", async
{
REA_KUNA_COMMAND: kuna,
REA_KUNA_CACHE_DIR: join(root, "cache"),
REA_KUNA_TIMEOUT_MS: "300",
REA_KUNA_TIMEOUT_MS: "10000",
},
{
platform: process.platform,
@@ -266,6 +275,15 @@ it("retains the independent run slot until an unproven stop is resolved", async
expect(
(await call("procedure_assembly", { procedure: "broken" })).isError,
).not.toBe(true);
expect(
(
await call("update_analysis_settings", {
changes: [
{ setting: "kuna.function_timeout_ms", action: "set", value: 300 },
],
})
).isError,
).not.toBe(true);
const first = await call("procedure_pseudo_code", {
procedure: "broken",
analysis: "independent_fresh",
@@ -314,6 +332,13 @@ it("retains the independent run slot until an unproven stop is resolved", async
expect((await call("close_binary", {})).isError).toBe(true);
unproven.release();
expect((await call("close_binary", {})).isError).not.toBe(true);
expect(
(
await call("update_analysis_settings", {
changes: [{ setting: "kuna.function_timeout_ms", action: "reset" }],
})
).isError,
).not.toBe(true);
await setFakeKunaBehavior(kuna, { functions: FUNCTIONS });
expect(
(await call("open_binary", { path: target, provider_id: "kuna" }))
@@ -41,6 +41,7 @@ import {
exportsOf,
FUNCTIONS,
unprovenCleanupLauncher,
awaitKunaCommand,
} from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -827,13 +828,7 @@ it.each([false, true])(
withCallerSignal ? { signal: caller.signal } : {},
);
try {
await expect
.poll(async () =>
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile",
),
)
.toHaveLength(1);
await awaitKunaCommand(kuna, "decompile", request);
const call = (await fakeKunaCalls(kuna)).find(
({ argv }) => argv[0] === "decompile",
);
@@ -845,8 +840,8 @@ it.each([false, true])(
});
} finally {
caller.abort();
await request;
await client.close();
await request;
}
},
);
@@ -900,13 +895,7 @@ it("retains an active request's workspace when close cannot prove its stop", asy
{ signal: caller.signal },
);
try {
await expect
.poll(async () =>
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile",
),
)
.toHaveLength(1);
await awaitKunaCommand(kuna, "decompile", request);
const closed = await client.close();
expect(closed).toMatchObject({
ok: false,
@@ -922,9 +911,10 @@ it("retains an active request's workspace when close cannot prove its stop", asy
expect(await readFile(input ?? "", "utf8")).toBe("program bytes");
} finally {
caller.abort();
await request;
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
const closed = await client.close();
await request;
expect(closed).toEqual({ ok: true, value: null });
}
});
@@ -1042,25 +1032,34 @@ it("keeps reporting a failed close and succeeds once a retry proves the stop", a
const { open } = await session(
"rea-kuna-close-retry-",
{ project: "hang", decompile: "hang" },
{ REA_KUNA_TIMEOUT_MS: "300" },
{ REA_KUNA_TIMEOUT_MS: "10000" },
unproven.launcher,
);
const client = open();
expect(
await client.execute("procedure_pseudo_code", { procedure: "main" }),
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
const first = await client.close();
expect(first).toMatchObject({
ok: false,
error: { diagnostics: { reason: "decompile stop unproven" } },
});
// A retry that still cannot prove the stop keeps failing.
expect((await client.close()).ok).toBe(false);
try {
expect((await client.execute("list_procedures", {})).ok).toBe(true);
expect(
await runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 300 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
),
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
const first = await client.close();
expect(first).toMatchObject({
ok: false,
error: { diagnostics: { reason: "decompile stop unproven" } },
});
// A retry that still cannot prove the stop keeps failing.
expect((await client.close()).ok).toBe(false);
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
expect(await client.close()).toEqual({ ok: true, value: null });
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
expect(await client.close()).toEqual({ ok: true, value: null });
} finally {
unproven.release();
expect((await client.close()).ok).toBe(true);
}
});
it("reports an abandoned export's unproven cleanup from close", async () => {
@@ -1180,10 +1179,9 @@ it.each([
environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" },
cause: "AnalysisTimeoutError",
next: "To retry, set kuna.project_timeout_ms above 300 ms with update_analysis_settings. It can be changed at runtime. The launch seed is REA_KUNA_PROJECT_TIMEOUT_MS. Then reopen the target so the export runs again.",
details: {
timeout_ms: 300,
captured_output: { truncated: expect.any(Boolean) },
},
// The whole export can expire during preparation, before a child exists.
// Command-output presence is covered at the command owner below that boundary.
details: { timeout_ms: 300 },
},
{
name: "Kuna failure",
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js";
import { onTestFinished } from "vitest";
@@ -125,3 +127,32 @@ export const unprovenCleanupLauncher = (subcommand: string) => {
};
return { launcher, release: () => (proven = true) };
};
/** Wait for a real child invocation, while preserving an early operation failure. */
export const awaitKunaCommand = async (
kuna: string,
command: string,
operation: Promise<unknown>,
): Promise<void> => {
let waiting = true;
const acknowledged = (async () => {
while (waiting) {
if ((await fakeKunaCalls(kuna)).some(({ argv }) => argv[0] === command))
return;
await delay(10);
}
})();
try {
// Cold installation, workspace and inventory preparation precede decompile.
// The phase probe took over two seconds with the two-core resource bound.
await awaitAcknowledgement(
acknowledged,
operation,
`Kuna ${command} child`,
5_000,
);
} finally {
waiting = false;
await Promise.allSettled([acknowledged]);
}
};
@@ -1,4 +1,7 @@
import { expect, it as test, vi } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../../../../src/domain/analysisOperationSnapshot.js";
import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js";
import { KunaRunSlots } from "../../../../src/kuna/KunaRunSlots.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js";
@@ -8,6 +11,7 @@ import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProce
import {
fakeKunaCalls,
fakeKunaExits,
releaseFakeKunaCommand,
setFakeKunaBehavior,
} from "./fakeKuna.js";
import {
@@ -15,6 +19,7 @@ import {
run,
session,
unprovenCleanupLauncher,
awaitKunaCommand,
} from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -48,24 +53,29 @@ it("shares one single-function run among concurrent requests for one entry", asy
it("keeps a shared run for the callers still waiting when one cancels", async () => {
const { kuna, open } = await session("rea-kuna-single-cancel-", {
project: "hang",
decompileDelayMs: 500,
gatedCommands: ["decompile"],
});
const client = open();
const caller = new AbortController();
const leaving = client.execute(
"procedure_pseudo_code",
{ procedure: "main" },
{ signal: caller.signal },
);
const staying = client.execute("procedure_pseudo_code", {
procedure: "main",
});
try {
const leaving = client.execute(
"procedure_pseudo_code",
{ procedure: "main" },
{ signal: caller.signal },
);
const staying = run(client, "procedure_pseudo_code", { procedure: "main" });
await expect.poll(() => decompilesOf(kuna)).toHaveLength(1);
await awaitKunaCommand(kuna, "decompile", Promise.all([leaving, staying]));
caller.abort();
expect(await leaving).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect((await staying).rawResult).toMatchObject({
await releaseFakeKunaCommand(kuna, "decompile");
const completed = await staying;
if (!completed.ok) throw completed.error;
expect(completed.value.rawResult).toMatchObject({
source: { kind: "single-function" },
});
const [call] = await decompilesOf(kuna);
@@ -73,7 +83,10 @@ it("keeps a shared run for the callers still waiting when one cancels", async ()
expect(await decompilesOf(kuna)).toHaveLength(1);
} finally {
caller.abort();
expect(await client.close()).toEqual({ ok: true, value: null });
await releaseFakeKunaCommand(kuna, "decompile");
const closed = await client.close();
await Promise.allSettled([leaving, staying]);
expect(closed).toEqual({ ok: true, value: null });
}
});
@@ -111,25 +124,42 @@ it("admits no more concurrent single-function runs than its slots", async () =>
it("answers a queued request from the export once it finishes first", async () => {
const { kuna, open } = await session(
"rea-kuna-single-yield-",
{ delayMs: 400, decompile: "hang" },
{ gatedCommands: ["decompile-project"], decompile: "hang" },
{},
undefined,
1,
);
const client = open();
const holder = new AbortController();
try {
// This run holds the only slot until its caller leaves.
const holding = client.execute(
"procedure_pseudo_code",
{ procedure: "main" },
{ signal: holder.signal },
);
await expect.poll(() => decompilesOf(kuna)).toHaveLength(1);
const queued = await run(client, "procedure_pseudo_code", {
procedure: "parse",
const admission = deferred<void>();
const acquire = KunaRunSlots.prototype.acquire;
let arrivals = 0;
const requests = vi
.spyOn(KunaRunSlots.prototype, "acquire")
.mockImplementation(function (this: KunaRunSlots, signal) {
const pending = acquire.call(this, signal);
if (++arrivals === 2) admission.resolve();
return pending;
});
expect(queued).toMatchObject({
let queued: ReturnType<typeof client.execute> | undefined;
// This run holds the only slot until its caller leaves.
const holding = client.execute(
"procedure_pseudo_code",
{ procedure: "main" },
{ signal: holder.signal },
);
try {
await awaitKunaCommand(kuna, "decompile", holding);
queued = client.execute("procedure_pseudo_code", { procedure: "parse" });
await awaitAcknowledgement(
admission.promise,
queued,
"queued single-function admission",
);
await releaseFakeKunaCommand(kuna, "decompile-project");
const completed = await queued;
if (!completed.ok) throw completed.error;
expect(completed.value).toMatchObject({
result: "int parse(void)\n{\n return 1;\n}",
rawResult: { source: { kind: "project-export" } },
});
@@ -141,7 +171,11 @@ it("answers a queued request from the export once it finishes first", async () =
});
} finally {
holder.abort();
expect(await client.close()).toEqual({ ok: true, value: null });
await releaseFakeKunaCommand(kuna, "decompile-project");
const closed = await client.close();
await Promise.allSettled([holding, queued]);
requests.mockRestore();
expect(closed).toEqual({ ok: true, value: null });
}
});
@@ -150,29 +184,57 @@ it("keeps the slot of a run whose stop is unproven until close proves it", async
const { kuna, open } = await session(
"rea-kuna-single-retained-slot-",
{ project: "hang", decompile: "hang" },
{ REA_KUNA_TIMEOUT_MS: "300" },
{ REA_KUNA_TIMEOUT_MS: "10000" },
unproven.launcher,
1,
);
const client = open();
const waiter = new AbortController();
let queued: ReturnType<typeof client.execute> | undefined;
let restoreAcquire: (() => void) | undefined;
try {
expect((await client.execute("list_procedures", {})).ok).toBe(true);
expect(
await client.execute("procedure_pseudo_code", { procedure: "main" }),
await runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 300 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
),
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
// The timed-out tree may still run, so the next run must not start.
const queued = client.execute(
const admission = deferred<void>();
const acquire = KunaRunSlots.prototype.acquire;
let granted = false;
const requests = vi
.spyOn(KunaRunSlots.prototype, "acquire")
.mockImplementation(function (this: KunaRunSlots, signal) {
const pending = acquire.call(this, signal);
void pending.then(() => {
granted = true;
});
admission.resolve();
return pending;
});
restoreAcquire = () => requests.mockRestore();
queued = client.execute(
"procedure_pseudo_code",
{ procedure: "parse" },
{ signal: waiter.signal },
);
await new Promise((resolve) => setTimeout(resolve, 300));
await awaitAcknowledgement(
admission.promise,
queued,
"retained single-function slot",
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(granted).toBe(false);
expect(await decompilesOf(kuna)).toHaveLength(1);
waiter.abort();
expect(await queued).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
restoreAcquire();
restoreAcquire = undefined;
expect((await client.close()).ok).toBe(false);
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
@@ -192,6 +254,8 @@ it("keeps the slot of a run whose stop is unproven until close proves it", async
waiter.abort();
unproven.release();
await client.close();
await Promise.allSettled([queued]);
restoreAcquire?.();
}
});
@@ -310,13 +374,17 @@ it("does not keep a failed run, so a repeat runs again", async () => {
const { kuna, open } = await session(
"rea-kuna-single-failed-not-kept-",
{ project: "hang", decompile: "hang" },
{ REA_KUNA_TIMEOUT_MS: "200" },
{ REA_KUNA_TIMEOUT_MS: "10000" },
);
const client = open();
try {
expect((await client.execute("list_procedures", {})).ok).toBe(true);
for (let repeat = 0; repeat < 2; repeat += 1)
expect(
await client.execute("procedure_pseudo_code", { procedure: "main" }),
await runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 200 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
),
).toMatchObject({ ok: false });
expect(await decompilesOf(kuna)).toHaveLength(2);
} finally {
@@ -357,13 +425,15 @@ it("names the setting that owns a single-function deadline", async () => {
const { open } = await session(
"rea-kuna-function-deadline-",
{ project: "hang", decompile: "hang" },
{ REA_KUNA_TIMEOUT_MS: "300" },
{ REA_KUNA_TIMEOUT_MS: "10000" },
);
const client = open();
try {
const result = await client.execute("procedure_pseudo_code", {
procedure: "main",
});
expect((await client.execute("list_procedures", {})).ok).toBe(true);
const result = await runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 300 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
);
if (result.ok) throw new Error("the hanging run answered");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "provider_timeout",
@@ -2,6 +2,8 @@ import { unlink, writeFile } from "node:fs/promises";
import { expect, it as test, vi } from "vitest";
import { withVerifierCleanup } from "../../../../scripts/lib/verifier-cleanup.mjs";
import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js";
import { systemProcessOwnershipHost } from "../../../../src/process/ProcessOwnershipObservation.js";
import { ExecFileOutputCancelledError } from "../../../../src/process/ExecFileOutput.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
@@ -262,3 +264,92 @@ it("keeps caller attribution and the selected path through workspace rollback re
expect((await client.close()).ok).toBe(true);
}
});
it.each([
{ stage: "copy", mode: "clean" },
{ stage: "copy", mode: "refuse" },
{ stage: "inspection", mode: "clean" },
{ stage: "inspection", mode: "refuse" },
] as const)(
"preserves workspace $stage cancellation and retries its $mode rollback",
async ({ stage, mode }) => {
const fixture = await session("rea-kuna-workspace-cancel-");
const lifetime = new AbortController();
const reason = { caller: "cancel target copy" };
const commands = new KunaTargetCommands(fixture.target, {
signal: lifetime.signal,
});
const create = PrivateRuntimeRoot.create;
let rootClose: ReturnType<typeof vi.spyOn> | undefined;
const roots = vi
.spyOn(PrivateRuntimeRoot, "create")
.mockImplementation(async (options) => {
const root = await create(options);
if (mode === "refuse")
rootClose = vi
.spyOn(root, "close")
.mockRejectedValue(new Error("controlled rollback refusal"));
if (stage === "copy") lifetime.abort(reason);
return root;
});
let inspectionCalls = 0;
const prepare = systemProcessOwnershipHost.prepare;
const inspection = vi
.spyOn(systemProcessOwnershipHost, "prepare")
.mockImplementation(async (signal) => {
if (stage === "inspection" && signal === lifetime.signal) {
inspectionCalls += 1;
lifetime.abort(reason);
throw new ExecFileOutputCancelledError(reason);
}
await prepare?.(signal);
});
try {
const result = await commands.prepare();
if (result.ok) throw new Error("Expected cancelled workspace admission");
expect(inspectionCalls).toBe(stage === "inspection" ? 1 : 0);
const primary = result.error.cleanupPrimary ?? result.error;
expect(primary).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
expect(result.error.cleanupIncomplete).toBe(mode === "refuse");
if (mode === "refuse") {
expect((await commands.close()).ok).toBe(false);
rootClose?.mockRestore();
expect((await commands.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupPrimary).toBe(primary);
}
} finally {
inspection.mockRestore();
roots.mockRestore();
rootClose?.mockRestore();
expect((await commands.close()).ok).toBe(true);
}
},
);
it("does not relabel a physical workspace failure when cancellation also arrives", async () => {
const fixture = await session("rea-kuna-workspace-physical-");
const lifetime = new AbortController();
const physical = new Error("controlled workspace creation failure");
const commands = new KunaTargetCommands(fixture.target, {
signal: lifetime.signal,
});
const roots = vi
.spyOn(PrivateRuntimeRoot, "create")
.mockImplementation(async () => {
lifetime.abort({ caller: "concurrent shutdown" });
throw physical;
});
try {
expect(await commands.prepare()).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError", cause: physical },
});
} finally {
roots.mockRestore();
expect((await commands.close()).ok).toBe(true);
}
});
-35
View File
@@ -1,35 +0,0 @@
import spawn from "cross-spawn";
import { existsSync } from "node:fs";
import { registerHooks } from "node:module";
const supervisor = new URL(
"../../scripts/run-exclusive-command.mjs",
import.meta.url,
).href;
// Replace only the supervisor's dependency, leaving its actual source and the
// public wrapper intact. Other imports still use the real implementation.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "cross-spawn" && context.parentURL === supervisor)
return { url: import.meta.url, shortCircuit: true };
return nextResolve(specifier, context);
},
});
export default function spawnWithPostSpawnError(command, args, options) {
const child = spawn(command, args, options);
if (args.at(-1) !== "rea-runner-post-spawn-error") return child;
let timer;
child.once("close", () => clearInterval(timer));
child.once("spawn", () => {
timer = setInterval(() => {
if (!existsSync(process.env.REA_TEST_RUNNER_READY_PATH)) return;
clearInterval(timer);
process.stdout.write("controlled-post-spawn-error\n");
child.emit("error", new Error("controlled post-spawn child error"));
child.emit("error", new Error("controlled secondary child error"));
}, 5);
});
return child;
}
@@ -32,7 +32,10 @@ import {
import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js";
import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js";
import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js";
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
import {
withArtifactFileHandle,
ArtifactFileCleanupFailure,
} from "../../src/artifacts/ArtifactFileOperation.js";
import * as artifacts from "../../src/artifacts/readStableArtifact.js";
import {
recordedCrashFixture,
@@ -403,6 +406,13 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
const resources = args[4];
if (resources === undefined)
throw new Error("Reply must have an artifact resource owner");
const file = await open(reply, "r");
const close = file.close.bind(file);
emergencyCloses.push(close);
file.close = async () => {
if (!releaseAllowed) throw closeFailure;
await close();
};
const retain = resources.retain.bind(resources);
const spy = vi
.spyOn(resources, "retain")
@@ -411,25 +421,12 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
controller.abort(reason);
});
try {
return await read(
args[0],
args[1],
args[2],
{
lstat: (filePath) => lstat(filePath, { bigint: true }),
open: async (filePath, flags) => {
const file = await open(filePath, flags);
const close = file.close.bind(file);
emergencyCloses.push(close);
file.close = async () => {
if (!releaseAllowed) throw closeFailure;
await close();
};
return file;
},
},
return await withArtifactFileHandle(
reply,
file,
resources,
args[5],
controller.signal,
() => read(...args),
);
} finally {
spy.mockRestore();
+55 -36
View File
@@ -7,14 +7,19 @@ import {
utimes,
writeFile,
} from "node:fs/promises";
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import { expect, it, onTestFinished, vi } from "vitest";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const supervisorPath = fileURLToPath(
new URL("../../scripts/run-exclusive-command.mjs", import.meta.url),
);
const runnerPath = fileURLToPath(
new URL("../../scripts/run-exclusive.mjs", import.meta.url),
);
@@ -649,10 +654,39 @@ it.runIf(process.platform !== "win32")(
const readyPath = join(directory, "command-ready");
const gatePath = join(directory, "allow-command-exit");
const closedPath = join(directory, "command-closed");
const preload = new URL(
"../fixtures/runnerPostSpawnError.mjs",
import.meta.url,
const source = await readFile(supervisorPath, "utf8");
const crossSpawn = pathToFileURL(
createRequire(import.meta.url).resolve("cross-spawn"),
).href;
const spawnCall =
/child = spawn\(command\[0\], command\.slice\(1\), \{\n\s+cwd: process\.cwd\(\),\n\s+env: process\.env,\n\s+stdio: "inherit",\n\s+\}\);/u;
const postSpawnInjection = `child.once("spawn", () => {
const waitForReady = setInterval(() => {
if (!existsSync(${JSON.stringify(readyPath)})) return;
clearInterval(waitForReady);
process.stdout.write("controlled-post-spawn-error\\n");
child.emit("error", new Error("controlled post-spawn child error"));
child.emit("error", new Error("controlled secondary child error"));
}, 5);
});`;
expect(source).toMatch(spawnCall);
const injectedSource = source
.replace(
'import spawn from "cross-spawn";',
`import spawn from ${JSON.stringify(crossSpawn)};\nimport { existsSync } from "node:fs";`,
)
.replace(spawnCall, (call) => `${call}\n ${postSpawnInjection}`);
expect(injectedSource).toContain(postSpawnInjection);
expect(injectedSource).toContain("controlled post-spawn child error");
const copiedSupervisor = join(directory, "run-exclusive-command.mjs");
const copiedHelperDirectory = join(directory, "lib");
await mkdir(copiedHelperDirectory, { recursive: true });
await copyFile(
join(process.cwd(), "scripts/lib/windows-owned-command.mjs"),
join(copiedHelperDirectory, "windows-owned-command.mjs"),
);
await writeFile(copiedSupervisor, injectedSource);
const gatedCommand = `
const fs = require("node:fs");
fs.writeFileSync(${JSON.stringify(readyPath)}, "ready");
@@ -664,34 +698,17 @@ it.runIf(process.platform !== "win32")(
process.stdout.write("command-closed\\n");
}, 5);
`;
const runner = spawn(
const supervisor = spawn(
process.execPath,
[
runnerPath,
"test",
process.execPath,
"-e",
gatedCommand,
"rea-runner-post-spawn-error",
],
{
cwd: directory,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${preload}`]
.filter(Boolean)
.join(" "),
REA_TEST_RUNNER_READY_PATH: readyPath,
},
},
[copiedSupervisor, "test", process.execPath, "-e", gatedCommand],
{ cwd: directory, stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
if (runner.stdout === null || runner.stderr === null) {
runner.kill("SIGKILL");
throw new Error("runner output pipes were not created");
if (supervisor.stdout === null || supervisor.stderr === null) {
supervisor.kill("SIGKILL");
throw new Error("supervisor output pipes were not created");
}
const stdoutStream = runner.stdout;
const stderrStream = runner.stderr;
const stdoutStream = supervisor.stdout;
const stderrStream = supervisor.stderr;
let stdout = "";
let stderr = "";
const outputWaiters = new Map<string, (() => void)[]>();
@@ -713,17 +730,19 @@ it.runIf(process.platform !== "win32")(
resolveOutput,
]);
}),
`runner did not write ${marker}`,
`supervisor did not write ${marker}`,
);
};
const closed = withTimeout(
const exited = withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveExit, reject) => {
runner.once("error", reject);
runner.once("close", (code, signal) => resolveExit({ code, signal }));
supervisor.once("error", reject);
supervisor.once("exit", (code, signal) =>
resolveExit({ code, signal }),
);
},
),
"runner did not exit",
"supervisor did not exit",
);
try {
await waitForOutput("controlled-post-spawn-error");
@@ -740,7 +759,7 @@ it.runIf(process.platform !== "win32")(
await writeFile(gatePath, "open");
await waitForOutput("command-closed");
expect(await closed).toEqual({ code: 1, signal: null });
expect(await exited).toEqual({ code: 1, signal: null });
expect(stderr).toContain("controlled post-spawn child error");
expect(stderr).not.toContain("controlled secondary child error");
expect(
@@ -752,7 +771,7 @@ it.runIf(process.platform !== "win32")(
} finally {
await writeFile(gatePath, "open");
await waitForOutput("command-closed").catch(() => undefined);
await closed.catch(() => undefined);
await exited.catch(() => undefined);
}
},
);
+17 -2
View File
@@ -46,12 +46,25 @@ function advertiseMarkdown(response, path) {
return response;
}
/**
* Answer with HTML when the Markdown route is absent.
*
* Reevaluate HTML only when probing removed conditional or range headers.
* Otherwise the existing response already answers the caller's request.
*/
const htmlFallback = async (original, request, probeChanged, env) => {
if (!probeChanged) return original;
await original.body?.cancel();
return env.ASSETS.fetch(request);
};
export default {
async fetch(request, env) {
const useMarkdown = prefersMarkdown(request.headers.get("Accept"));
// HTML validators describe a different representation. Probe the route first,
// then let the asset service evaluate validators/ranges against the Markdown.
const probe = new Request(request);
let probeChanged = false;
if (useMarkdown) {
for (const name of [
"If-None-Match",
@@ -60,8 +73,10 @@ export default {
"If-Unmodified-Since",
"If-Range",
"Range",
])
]) {
probeChanged ||= probe.headers.has(name);
probe.headers.delete(name);
}
}
const original = await env.ASSETS.fetch(probe);
if (
@@ -85,7 +100,7 @@ export default {
);
if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) {
await candidate.body?.cancel();
return original;
return htmlFallback(original, request, probeChanged, env);
}
const response = new Response(
useMarkdown ? candidate.body : original.body,
+17 -2
View File
@@ -2,10 +2,11 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import worker from "./worker.mjs";
function assets({ markdownStatus = 200 } = {}) {
function assets({ markdownStatus = 200, requests = [] } = {}) {
return {
ASSETS: {
async fetch(request) {
requests.push(request);
const url = new URL(request.url);
const markdown = url.pathname.endsWith("index.md");
if (url.pathname === "/missing/")
@@ -105,13 +106,15 @@ await test("HEAD and validators use the selected representation", async () => {
});
await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => {
const requests = [];
const html = await worker.fetch(
new Request("https://rea.tools/guide/", {
headers: { Accept: "text/markdown" },
}),
assets({ markdownStatus: 404 }),
assets({ markdownStatus: 404, requests }),
);
assert.equal(await html.text(), "<h1>Guide</h1>");
assert.equal(requests.length, 2, "reuse the unchanged HTML probe");
for (const [path, status] of [
["/guide", 307],
["/missing/", 404],
@@ -125,3 +128,15 @@ await test("missing Markdown keeps HTML, and asset redirects and missing routes
assert.equal(response.status, status);
}
});
await test("a Markdown-preferring client keeps the HTML validator when the sidecar is missing", async () => {
const requests = [];
const response = await worker.fetch(
new Request("https://rea.tools/guide/", {
headers: { Accept: "text/markdown", "If-None-Match": '"html"' },
}),
assets({ markdownStatus: 404, requests }),
);
assert.equal(response.status, 304);
assert.equal(requests.length, 3, "reevaluate the original HTML validator");
});