mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 03:31:35 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b36fc2d50 | ||
|
|
40d9f1a5e8 | ||
|
|
fbbf94a2c4 | ||
|
|
4a17bf76ed | ||
|
|
04042cf540 | ||
|
|
2127cff82d | ||
|
|
1dc54783ef |
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+17
-2
@@ -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
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user