Compare commits

..
18 changed files with 259 additions and 820 deletions

No files matched your search

+2 -55
View File
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -30,60 +29,8 @@ const CENTRALIZED_CONSTRUCTOR_OWNER =
const repositoryPath = (root, path) =>
relative(root, path).replaceAll("\\", "/");
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) => {
const sourceTarget = (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)))
@@ -154,7 +101,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(root, absoluteFile, specifier);
const resolved = sourceTarget(absoluteFile, specifier);
if (resolved === undefined) continue;
const target = repositoryPath(root, resolved);
const boundary = failedBoundary(importer, target, typeOnly);
-176
View File
@@ -1,176 +0,0 @@
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 });
}
});
}
+9 -15
View File
@@ -14,10 +14,7 @@ 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 {
isProcessOwnershipInspectionCancellation,
prepareProcessOwnershipInspection,
} from "../process/ProcessOwnershipObservation.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import {
copyAndHash,
@@ -356,17 +353,14 @@ export class KunaTargetCommands {
prefix: copied.prefix,
});
} catch (cause: unknown) {
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)}`,
});
const primary = 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,33 +1,13 @@
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { chmod, writeFile } from "node:fs/promises";
import { 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 { 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();
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -60,55 +40,4 @@ 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,6 +150,7 @@ 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>>;
@@ -256,25 +257,24 @@ 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 ?? [],
...(options.sourceEvidence
? {
sourceEvidence: [
createEvidence(target, profile.provider, {
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
}),
],
}
: {}),
...(source === undefined ? {} : { sourceEvidence: [source] }),
}),
);
},
@@ -293,6 +293,7 @@ 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[] = [];
@@ -300,13 +301,14 @@ const createSnapshotReplayFixture = async (
path,
snapshotPath,
starts,
sources,
get calls() {
return queries.map(({ operation }) => operation);
},
queries,
resolutions,
createProvider(options: SnapshotProviderOptions = {}): AnalysisProvider {
return makeProvider({ starts, queries, resolutions }, options);
return makeProvider({ starts, sources, queries, resolutions }, options);
},
dependencies(provider: AnalysisProvider): DirectAnalysisDependencies {
return {
@@ -1075,7 +1077,7 @@ describe("Ghidra composed workflow runtime-budget admission", () => {
});
describe("binary overview snapshot replay", () => {
it("replays the identical binary overview without provider startup or calls", async () => {
it("replays bound overviews without provider work and recomposes unbound history", async () => {
const fixture = await createSnapshotReplayFixture("rea-workflow-snapshot-");
const provider = fixture.createProvider();
const dependencies = fixture.dependencies(provider);
@@ -1159,6 +1161,47 @@ 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);
});
});
@@ -2279,6 +2322,8 @@ 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,
@@ -2299,8 +2344,9 @@ 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, ...parent!.evidence_links].sort(),
[parent!.evidence_id, ...sourceIds].sort(),
);
});
@@ -107,7 +107,21 @@ mcpTest(
name: "inspect_native_initializers",
arguments: { path },
});
expect(missing.isError).toBe(true);
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,
),
},
],
},
});
const selected = await client.callTool({
name: "inspect_native_initializers",
arguments: { path, architecture: "arm64" },
+4 -20
View File
@@ -54,8 +54,6 @@ 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. */
@@ -113,14 +111,6 @@ 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); }
@@ -186,7 +176,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 whenReleased("decompile-project", () => setTimeout(() => {
else setTimeout(() => {
fs.mkdirSync(output, { recursive: true });
let c = '#include "' + stem + '.h"\n\n';
functions.forEach((fn, index) => {
@@ -207,17 +197,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 whenReleased("decompile", () => setTimeout(() => {
else 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);
@@ -277,9 +267,3 @@ 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,11 +1,10 @@
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, awaitKunaCommand } from "./kunaSessionFixture.js";
import { session } from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -249,75 +248,3 @@ 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: "10000",
REA_KUNA_TIMEOUT_MS: "300",
},
{ platform: process.platform },
);
@@ -67,11 +67,6 @@ 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" },
@@ -89,7 +84,6 @@ 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,
@@ -102,9 +96,6 @@ 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(
@@ -240,7 +231,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: "10000",
REA_KUNA_TIMEOUT_MS: "300",
},
{
platform: process.platform,
@@ -275,15 +266,6 @@ 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",
@@ -332,13 +314,6 @@ 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,7 +41,6 @@ import {
exportsOf,
FUNCTIONS,
unprovenCleanupLauncher,
awaitKunaCommand,
} from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -828,7 +827,13 @@ it.each([false, true])(
withCallerSignal ? { signal: caller.signal } : {},
);
try {
await awaitKunaCommand(kuna, "decompile", request);
await expect
.poll(async () =>
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile",
),
)
.toHaveLength(1);
const call = (await fakeKunaCalls(kuna)).find(
({ argv }) => argv[0] === "decompile",
);
@@ -840,8 +845,8 @@ it.each([false, true])(
});
} finally {
caller.abort();
await client.close();
await request;
await client.close();
}
},
);
@@ -895,7 +900,13 @@ it("retains an active request's workspace when close cannot prove its stop", asy
{ signal: caller.signal },
);
try {
await awaitKunaCommand(kuna, "decompile", request);
await expect
.poll(async () =>
(await fakeKunaCalls(kuna)).filter(
({ argv }) => argv[0] === "decompile",
),
)
.toHaveLength(1);
const closed = await client.close();
expect(closed).toMatchObject({
ok: false,
@@ -911,10 +922,9 @@ 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();
unproven.release();
const closed = await client.close();
await request;
expect(closed).toEqual({ ok: true, value: null });
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
@@ -1032,34 +1042,25 @@ 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: "10000" },
{ REA_KUNA_TIMEOUT_MS: "300" },
unproven.launcher,
);
const client = open();
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);
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);
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);
}
unproven.release();
expect(await client.close()).toEqual({ ok: true, value: null });
expect(await client.close()).toEqual({ ok: true, value: null });
});
it("reports an abandoned export's unproven cleanup from close", async () => {
@@ -1179,9 +1180,10 @@ 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.",
// 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 },
details: {
timeout_ms: 300,
captured_output: { truncated: expect.any(Boolean) },
},
},
{
name: "Kuna failure",
@@ -1,8 +1,6 @@
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";
@@ -127,32 +125,3 @@ 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,7 +1,4 @@
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";
@@ -11,7 +8,6 @@ import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProce
import {
fakeKunaCalls,
fakeKunaExits,
releaseFakeKunaCommand,
setFakeKunaBehavior,
} from "./fakeKuna.js";
import {
@@ -19,7 +15,6 @@ import {
run,
session,
unprovenCleanupLauncher,
awaitKunaCommand,
} from "./kunaSessionFixture.js";
const it = test.skipIf(process.platform === "win32");
@@ -53,29 +48,24 @@ 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",
gatedCommands: ["decompile"],
decompileDelayMs: 500,
});
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 {
await awaitKunaCommand(kuna, "decompile", Promise.all([leaving, staying]));
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);
caller.abort();
expect(await leaving).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
await releaseFakeKunaCommand(kuna, "decompile");
const completed = await staying;
if (!completed.ok) throw completed.error;
expect(completed.value.rawResult).toMatchObject({
expect((await staying).rawResult).toMatchObject({
source: { kind: "single-function" },
});
const [call] = await decompilesOf(kuna);
@@ -83,10 +73,7 @@ it("keeps a shared run for the callers still waiting when one cancels", async ()
expect(await decompilesOf(kuna)).toHaveLength(1);
} finally {
caller.abort();
await releaseFakeKunaCommand(kuna, "decompile");
const closed = await client.close();
await Promise.allSettled([leaving, staying]);
expect(closed).toEqual({ ok: true, value: null });
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
@@ -124,42 +111,25 @@ 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-",
{ gatedCommands: ["decompile-project"], decompile: "hang" },
{ delayMs: 400, decompile: "hang" },
{},
undefined,
1,
);
const client = open();
const holder = new AbortController();
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;
});
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",
// This run holds the only slot until its caller leaves.
const holding = client.execute(
"procedure_pseudo_code",
{ procedure: "main" },
{ signal: holder.signal },
);
await releaseFakeKunaCommand(kuna, "decompile-project");
const completed = await queued;
if (!completed.ok) throw completed.error;
expect(completed.value).toMatchObject({
await expect.poll(() => decompilesOf(kuna)).toHaveLength(1);
const queued = await run(client, "procedure_pseudo_code", {
procedure: "parse",
});
expect(queued).toMatchObject({
result: "int parse(void)\n{\n return 1;\n}",
rawResult: { source: { kind: "project-export" } },
});
@@ -171,11 +141,7 @@ it("answers a queued request from the export once it finishes first", async () =
});
} finally {
holder.abort();
await releaseFakeKunaCommand(kuna, "decompile-project");
const closed = await client.close();
await Promise.allSettled([holding, queued]);
requests.mockRestore();
expect(closed).toEqual({ ok: true, value: null });
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
@@ -184,57 +150,29 @@ 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: "10000" },
{ REA_KUNA_TIMEOUT_MS: "300" },
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 runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 300 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
),
await 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 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(
const queued = client.execute(
"procedure_pseudo_code",
{ procedure: "parse" },
{ signal: waiter.signal },
);
await awaitAcknowledgement(
admission.promise,
queued,
"retained single-function slot",
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(granted).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 300));
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 });
@@ -254,8 +192,6 @@ 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?.();
}
});
@@ -374,17 +310,13 @@ 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: "10000" },
{ REA_KUNA_TIMEOUT_MS: "200" },
);
const client = open();
try {
expect((await client.execute("list_procedures", {})).ok).toBe(true);
for (let repeat = 0; repeat < 2; repeat += 1)
expect(
await runWithAnalysisOperationSnapshot(
{ revision: 1, overrides: { "kuna.function_timeout_ms": 200 } },
() => client.execute("procedure_pseudo_code", { procedure: "main" }),
),
await client.execute("procedure_pseudo_code", { procedure: "main" }),
).toMatchObject({ ok: false });
expect(await decompilesOf(kuna)).toHaveLength(2);
} finally {
@@ -425,15 +357,13 @@ 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: "10000" },
{ REA_KUNA_TIMEOUT_MS: "300" },
);
const client = open();
try {
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" }),
);
const result = await 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,8 +2,6 @@ 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";
@@ -264,92 +262,3 @@ 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
@@ -0,0 +1,35 @@
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,10 +32,7 @@ import {
import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js";
import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js";
import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js";
import {
withArtifactFileHandle,
ArtifactFileCleanupFailure,
} from "../../src/artifacts/ArtifactFileOperation.js";
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
import * as artifacts from "../../src/artifacts/readStableArtifact.js";
import {
recordedCrashFixture,
@@ -406,13 +403,6 @@ 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")
@@ -421,12 +411,25 @@ it.runIf(supportedHost).each(["decoder", "debugger"])(
controller.abort(reason);
});
try {
return await withArtifactFileHandle(
reply,
file,
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;
},
},
resources,
controller.signal,
() => read(...args),
args[5],
);
} finally {
spy.mockRestore();
+36 -55
View File
@@ -7,19 +7,14 @@ import {
utimes,
writeFile,
} from "node:fs/promises";
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } 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),
);
@@ -654,39 +649,10 @@ 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 source = await readFile(supervisorPath, "utf8");
const crossSpawn = pathToFileURL(
createRequire(import.meta.url).resolve("cross-spawn"),
const preload = new URL(
"../fixtures/runnerPostSpawnError.mjs",
import.meta.url,
).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");
@@ -698,17 +664,34 @@ it.runIf(process.platform !== "win32")(
process.stdout.write("command-closed\\n");
}, 5);
`;
const supervisor = spawn(
const runner = spawn(
process.execPath,
[copiedSupervisor, "test", process.execPath, "-e", gatedCommand],
{ cwd: directory, stdio: ["ignore", "pipe", "pipe", "ipc"] },
[
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,
},
},
);
if (supervisor.stdout === null || supervisor.stderr === null) {
supervisor.kill("SIGKILL");
throw new Error("supervisor output pipes were not created");
if (runner.stdout === null || runner.stderr === null) {
runner.kill("SIGKILL");
throw new Error("runner output pipes were not created");
}
const stdoutStream = supervisor.stdout;
const stderrStream = supervisor.stderr;
const stdoutStream = runner.stdout;
const stderrStream = runner.stderr;
let stdout = "";
let stderr = "";
const outputWaiters = new Map<string, (() => void)[]>();
@@ -730,19 +713,17 @@ it.runIf(process.platform !== "win32")(
resolveOutput,
]);
}),
`supervisor did not write ${marker}`,
`runner did not write ${marker}`,
);
};
const exited = withTimeout(
const closed = withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveExit, reject) => {
supervisor.once("error", reject);
supervisor.once("exit", (code, signal) =>
resolveExit({ code, signal }),
);
runner.once("error", reject);
runner.once("close", (code, signal) => resolveExit({ code, signal }));
},
),
"supervisor did not exit",
"runner did not exit",
);
try {
await waitForOutput("controlled-post-spawn-error");
@@ -759,7 +740,7 @@ it.runIf(process.platform !== "win32")(
await writeFile(gatePath, "open");
await waitForOutput("command-closed");
expect(await exited).toEqual({ code: 1, signal: null });
expect(await closed).toEqual({ code: 1, signal: null });
expect(stderr).toContain("controlled post-spawn child error");
expect(stderr).not.toContain("controlled secondary child error");
expect(
@@ -771,7 +752,7 @@ it.runIf(process.platform !== "win32")(
} finally {
await writeFile(gatePath, "open");
await waitForOutput("command-closed").catch(() => undefined);
await exited.catch(() => undefined);
await closed.catch(() => undefined);
}
},
);
+2 -17
View File
@@ -46,25 +46,12 @@ 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",
@@ -73,10 +60,8 @@ 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 (
@@ -100,7 +85,7 @@ export default {
);
if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) {
await candidate.body?.cancel();
return htmlFallback(original, request, probeChanged, env);
return original;
}
const response = new Response(
useMarkdown ? candidate.body : original.body,
+2 -17
View File
@@ -2,11 +2,10 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import worker from "./worker.mjs";
function assets({ markdownStatus = 200, requests = [] } = {}) {
function assets({ markdownStatus = 200 } = {}) {
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/")
@@ -106,15 +105,13 @@ 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, requests }),
assets({ markdownStatus: 404 }),
);
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],
@@ -128,15 +125,3 @@ 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");
});