fix(async): own completion across test and provider boundaries

This commit is contained in:
morluto
2026-10-25 08:28:52 +08:00
parent fdf5a33fe2
commit 5938581b37
37 changed files with 1134 additions and 406 deletions
+5 -7
View File
@@ -1,5 +1,5 @@
import { createVerifierHttpServer } from "../lib/verifier-http-server.mjs";
import { once } from "node:events";
import { createServer } from "node:http";
import { gzipSync } from "node:zlib";
export const networkFixtureSecret = "私密-network-fixture";
@@ -23,7 +23,7 @@ const html = `<!doctype html><meta charset="utf-8"><body><button id="start">Run<
export async function startBrowserNetworkSite() {
const counts = new Map();
const timers = new Set();
const server = createServer(async (request, response) => {
const handleRequest = async (request, response) => {
const path = new URL(request.url, "http://fixture.test").pathname;
counts.set(path, (counts.get(path) ?? 0) + 1);
if (path === "/same") {
@@ -89,7 +89,8 @@ export async function startBrowserNetworkSite() {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
}
});
};
const { server, close } = createVerifierHttpServer(handleRequest);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
@@ -100,10 +101,7 @@ export async function startBrowserNetworkSite() {
counts,
async close() {
for (const timer of timers) clearTimeout(timer);
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
await close();
},
};
}
+5 -9
View File
@@ -1,5 +1,5 @@
import { createVerifierHttpServer } from "../lib/verifier-http-server.mjs";
import { once } from "node:events";
import { createServer } from "node:http";
/** The CSRF cookie and the stored bearer token the page holds; request steps must send both and the capture must not keep them. */
export const requestFixtureCsrf = "csrf-fixture-7c1d2e";
@@ -52,7 +52,7 @@ export async function startBrowserRequestSite() {
};
const writes = [];
const hosts = [];
const server = createServer(async (request, response) => {
const handleRequest = async (request, response) => {
const url = new URL(request.url, "http://fixture.test");
hosts.push(`${request.headers.host} ${url.pathname}`);
const chunks = [];
@@ -108,7 +108,8 @@ export async function startBrowserRequestSite() {
}
const [status, value] = answerItems(store, request.method, id, body);
json(response, status, value);
});
};
const { server, close } = createVerifierHttpServer(handleRequest);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
@@ -120,11 +121,6 @@ export async function startBrowserRequestSite() {
/** Host header and path of every request the server received. */
hosts,
items: store.items,
async close() {
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
},
close,
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { IncomingMessage, Server, ServerResponse } from "node:http";
/** Own admitted fixture requests through transport closure and report unexpected failures. */
export function createVerifierHttpServer(
handleRequest: (
request: IncomingMessage,
response: ServerResponse,
) => Promise<void>,
): {
readonly server: Server;
close(): Promise<void>;
};
+39
View File
@@ -0,0 +1,39 @@
import { createServer } from "node:http";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
/** Own admitted fixture requests through transport closure and report unexpected failures. */
export function createVerifierHttpServer(handleRequest) {
const pending = new Set();
let failure;
const server = createServer((request, response) => {
const work = handleRequest(request, response)
.catch((cause) => {
failure ??= { cause };
response.destroy();
})
.finally(() => {
pending.delete(work);
});
pending.add(work);
});
return {
server,
close() {
return withVerifierCleanup(
() =>
new Promise((resolve, reject) => {
server.close((cause) =>
cause === undefined ? resolve() : reject(cause),
);
server.closeAllConnections();
}),
[
async () => {
await Promise.all(pending);
if (failure !== undefined) throw failure.cause;
},
],
);
},
};
}
+6 -2
View File
@@ -78,12 +78,16 @@ export const resolveAppBundleExecutable = async (
try {
const layout = await presentLayout(path);
if (layout !== undefined)
return resolveLayoutExecutable(path, layout, fileSystem);
return await resolveLayoutExecutable(path, layout, fileSystem);
const wrapped = await wrappedBundle(path);
if (wrapped !== undefined) {
const wrappedLayout = await presentLayout(wrapped);
if (wrappedLayout !== undefined)
return resolveLayoutExecutable(wrapped, wrappedLayout, fileSystem);
return await resolveLayoutExecutable(
wrapped,
wrappedLayout,
fileSystem,
);
}
} catch (cause: unknown) {
if (cause instanceof BinaryTargetError) return err(cause);
+1 -1
View File
@@ -772,7 +772,7 @@ export class LinuxHopperPublisher {
{ cause },
);
}
return this.#verifyArchive(
return await this.#verifyArchive(
parsed.data.family,
release.value,
join(root, "archive"),
+2 -2
View File
@@ -6,6 +6,7 @@ import {
effectiveClientConfiguration,
readConfigurationText,
type ClientConfigurationSource,
type ClientConfigurationFileError,
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
@@ -55,7 +56,6 @@ import {
preserveCurrentRecoveryBackup,
selectCurrentRecoveryBackup,
} from "./SetupRecoveryBackup.js";
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
import type {
ClientConfigurationInspection,
ClientConfigurationResult,
@@ -376,7 +376,7 @@ const persistClientConfiguration = async ({
!clientConfigurationValuesEqual(readback.servers[name], entry),
)
) {
return rollbackOrReadbackFailure(
return await rollbackOrReadbackFailure(
publication,
original,
recoveryBackupPath,
@@ -243,7 +243,7 @@ export const environmentExportEvidenceFile = async (
const refused = headroomRefusal(operation, path, size);
if (refused !== undefined) throw refused;
});
return environmentExportFile(role, path, bytes, operation);
return await environmentExportFile(role, path, bytes, operation);
} catch (cause: unknown) {
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
throw cause;
@@ -746,18 +746,20 @@ it.each([false, true])(
release = deferred();
const handles = new Set<FileHandle>();
const stat = borrowed.stat;
const prototype: Pick<FileHandle, "stat"> =
Object.getPrototypeOf(borrowed);
const spy = vi
.spyOn(Object.getPrototypeOf(borrowed), "stat")
.spyOn(prototype, "stat")
.mockImplementation(async function (
this: FileHandle,
...args: unknown[]
...args: Parameters<FileHandle["stat"]>
) {
if (this !== borrowed) {
handles.add(this);
entered.resolve();
await release.promise;
}
return Reflect.apply(stat, this, args);
return stat.call(this, ...args);
});
const caller = new AbortController(),
reason = new Error("one admission waiter leaves");
+21 -16
View File
@@ -1,20 +1,23 @@
import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
import {
AnalysisCleanupLeaseGroup,
AnalysisCleanupLease,
} from "../artifacts/AnalysisCleanupLease.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { EventEmitter } from "node:events";
import { beforeEach, expect, it, vi } from "vitest";
import { beforeEach, expect, it, vi, type Mock } from "vitest";
import type { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
const mocks = vi.hoisted(() => ({
openBrowser: vi.fn(),
storage: undefined as
| {
initialize: ReturnType<typeof vi.fn>;
settle: ReturnType<typeof vi.fn>;
limitations: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
initialize: Mock<PlaywrightScenarioStorage["initialize"]>;
settle: Mock<PlaywrightScenarioStorage["settle"]>;
limitations: Mock<PlaywrightScenarioStorage["limitations"]>;
close: Mock<PlaywrightScenarioStorage["close"]>;
}
| undefined,
finishEvents: vi.fn(),
@@ -216,12 +219,16 @@ const fixture = () => {
profilePath: "/tmp/profile",
});
mocks.storage = {
initialize: vi.fn(async () => undefined),
settle: vi.fn(async () => undefined),
limitations: vi.fn(() => []),
close: vi.fn(async (stopLoading = false) => ({
loadingStopped: stopLoading,
})),
initialize: vi.fn<PlaywrightScenarioStorage["initialize"]>(
async () => undefined,
),
settle: vi.fn<PlaywrightScenarioStorage["settle"]>(async () => undefined),
limitations: vi.fn<PlaywrightScenarioStorage["limitations"]>(() => []),
close: vi.fn<PlaywrightScenarioStorage["close"]>(
async (stopLoading = false) => ({
loadingStopped: stopLoading,
}),
),
};
return { cleanup, page, storage: mocks.storage };
};
@@ -1328,9 +1335,7 @@ it("preserves both cleanup failures and retries retained owners", async () => {
const { cleanup, storage } = fixture();
const detachFailure = new Error("CDP detach rejected");
const transportFailure = new Error("Playwright transport close failed");
storage.close
.mockRejectedValueOnce(detachFailure)
.mockResolvedValueOnce(undefined);
storage.close.mockRejectedValueOnce(detachFailure);
cleanup.close
.mockRejectedValueOnce(transportFailure)
.mockImplementationOnce(async (finishEvents?: () => Promise<void>) => {
+4 -20
View File
@@ -1,3 +1,4 @@
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js";
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
@@ -2430,25 +2431,8 @@ export const waitForGdbCommandTurn = (
signal?: AbortSignal,
): Promise<void> => {
if (signal === undefined) return previous;
if (signal.aborted)
return Promise.reject(
new AnalysisCancelledError("gdb_console", { cause: signal.reason }),
);
return new Promise<void>((resolve, reject) => {
const onAbort = (): void =>
reject(
new AnalysisCancelledError("gdb_console", { cause: signal.reason }),
);
signal.addEventListener("abort", onAbort, { once: true });
previous.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve();
},
(cause: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(cause);
},
);
return waitForAbortable(previous, signal).then((outcome) => {
if (outcome === ABORTED)
throw new AnalysisCancelledError("gdb_console", { cause: signal.reason });
});
};
+1 -1
View File
@@ -858,7 +858,7 @@ export class GhidraClient {
try {
const result = await this.#startWithin(deadline);
return result.ok && deadline.signal.aborted
? this.#startupInterrupted(deadline)
? await this.#startupInterrupted(deadline)
: result;
} catch (cause: unknown) {
if (cause instanceof GhidraSessionError) return err(cause);
+1 -1
View File
@@ -453,7 +453,7 @@ export class HopperClient {
try {
const result = await this.#startWithin(deadline);
return result.ok && deadline.signal.aborted
? this.#startupFailure(await this.#interruptionFailure(deadline))
? await this.#startupFailure(await this.#interruptionFailure(deadline))
: result;
} finally {
deadline.signal.removeEventListener("abort", onStartupAbort);
+1 -1
View File
@@ -291,7 +291,7 @@ export class KunaTargetCommands {
this.lifetime.signal,
);
if (digest !== this.target.sha256)
return this.#workspaceFailure(
return await this.#workspaceFailure(
new AnalysisArtifactChangedError(
"kuna:workspace",
this.target.path,
+4 -11
View File
@@ -32,6 +32,7 @@ import {
type OwnedDebuggerDetachResult,
} from "../process/ownedDebuggerSession.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
const MAX_SESSIONS = 8;
const MAX_PENDING_COMMANDS = 16;
@@ -1839,19 +1840,11 @@ const waitForTurn = (
signal?: AbortSignal,
): Promise<void> => {
if (signal === undefined) return previous;
if (signal.aborted)
return Promise.reject(rizinCancellation("rizin_debug_command", signal));
return new Promise<void>((resolve, reject) => {
const onAbort = (): void =>
reject(rizinCancellation("rizin_debug_command", signal));
signal.addEventListener("abort", onAbort, { once: true });
previous.then(() => {
signal.removeEventListener("abort", onAbort);
resolve();
});
return waitForAbortable(previous, signal).then((outcome) => {
if (outcome === ABORTED)
throw rizinCancellation("rizin_debug_command", signal);
});
};
/** Translate only this transport's typed cancellation, preserving opaque/null caller reasons. */
const rizinCancellation = (
operation: string,
@@ -0,0 +1,160 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
createCliSessionFrameReader,
encodeCliSessionFrame,
} from "../../support/cli/cliCommandSessionFrames.mjs";
import {
processTest,
waitForExit,
} from "../../support/process/processFixture.js";
const worker = fileURLToPath(
new URL("../../support/cli/cliCommandSessionWorker.mjs", import.meta.url),
);
processTest(
"keeps a real CLI worker frame failure nonzero when EOF joins command completion",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-cli-worker-completion-");
const preload = join(root, "deny-response-frame.mjs");
await writeFile(
preload,
`
const write = process.stdout.write.bind(process.stdout);
const failure = new Error("selected CLI response frame write failed");
process.stdout.write = (chunk, ...arguments_) => {
const frame = Buffer.isBuffer(chunk)
? JSON.parse(chunk.subarray(4).toString("utf8"))
: undefined;
if (frame?.ready === true) return write(chunk, ...arguments_);
const callback = arguments_.find((value) => typeof value === "function");
const deny = () => callback(failure);
if (process.stdin.readableEnded) setImmediate(deny);
else process.stdin.once("end", () => setImmediate(deny));
return true;
};
`,
);
const result = await processes.run(
process.execPath,
["--import", preload, worker],
{
input: encodeCliSessionFrame({
id: 1,
arguments: ["--help"],
cwd: root,
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
}),
timeoutMs: 15_000,
},
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain("selected CLI response frame write failed");
},
);
processTest(
"settles an active CLI response and skips queued work after malformed input",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-cli-worker-malformed-");
const marker = join(root, "response-completed");
const preload = join(root, "hold-response-frame.mjs");
await writeFile(
preload,
`
import { writeFileSync } from "node:fs";
const write = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...arguments_) => {
const frame = Buffer.isBuffer(chunk)
? JSON.parse(chunk.subarray(4).toString("utf8"))
: undefined;
if (frame?.id !== 1 || frame.fatal === true) return write(chunk, ...arguments_);
const callback = arguments_.find((value) => typeof value === "function");
const continued = new Promise((resolve) => process.stdin.once("data", resolve));
return write(chunk, (error) => {
if (error) callback(error);
else continued.then(() => {
writeFileSync(${JSON.stringify(marker)}, "response completed");
callback();
});
});
};
`,
);
const child = processes.spawn(process.execPath, [
"--import",
preload,
worker,
]);
const closed = new Promise<void>((resolve) => {
child.once("close", () => resolve());
});
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
const ids: number[] = [];
const request = (id: number) =>
encodeCliSessionFrame({
id,
arguments: ["--help"],
cwd: root,
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
});
const reader = createCliSessionFrameReader((value: unknown) => {
const frame = z
.object({ ready: z.boolean().optional(), id: z.number().optional() })
.parse(value);
if (frame.ready === true) child.stdin.write(request(1));
if (frame.id === undefined) return;
ids.push(frame.id);
if (frame.id === 1) {
const malformed = Buffer.from([0, 0, 0, 1, 123]);
child.stdin.write(Buffer.concat([request(2), malformed]));
}
});
child.stdout.on("data", reader);
expect(await waitForExit(child, 15_000), stderr).toBe(true);
await closed;
expect(child.signalCode, stderr).toBeNull();
expect(child.exitCode, stderr).toBe(1);
expect(stderr).toMatch(/JSON/u);
expect(ids).toEqual([1]);
expect(await readFile(marker, "utf8")).toBe("response completed");
},
);
processTest(
"publishes one fatal CLI response and does not dispatch commands queued behind it",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-cli-worker-fatal-");
const result = await processes.run(process.execPath, [worker], {
input: Buffer.concat([
encodeCliSessionFrame({ id: 1, arguments: null, cwd: root, env: {} }),
encodeCliSessionFrame({
id: 2,
arguments: ["--help"],
cwd: root,
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
}),
]),
timeoutMs: 15_000,
});
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain(
"CLI session command is missing its arguments, cwd, or environment",
);
expect(result.stdout).toContain('"id":1');
expect(result.stdout).toContain('"fatal":true');
expect(result.stdout).not.toContain('"id":2');
},
);
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { expect } from "vitest";
import { z } from "zod";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
@@ -38,132 +39,156 @@ type StartupMode =
| "unexpected-worker"
| "success";
type StartupSupervisorState = {
readonly mode: StartupMode;
readonly calls: string[];
database: string | undefined;
input: string | undefined;
};
const handleStartupRequest = async (
state: StartupSupervisorState,
request: IncomingMessage,
response: ServerResponse,
) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
let body = "";
for await (const chunk of request) body += String(chunk);
const rpc = requestSchema.parse(JSON.parse(body));
if (rpc.id === undefined) {
response.writeHead(202).end();
return;
}
const send = (result: unknown) =>
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
if (rpc.method === "initialize") {
if (state.mode === "connect-timeout") return;
send({
protocolVersion: rpc.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-startup-fixture", version: "1" },
});
return;
}
if (rpc.method === "tools/list") {
if (state.mode === "catalog-timeout") return;
if (state.mode === "catalog-refused") {
response.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: "fixture catalog refused" },
}),
);
return;
}
send({
tools: tools
.filter(
(name) => state.mode !== "missing-profile" || name !== "list_funcs",
)
.map((name) => ({
name,
inputSchema: { type: "object", properties: {} },
})),
});
return;
}
const name = rpc.params?.name;
const args = rpc.params?.arguments;
if (name === undefined || args === undefined)
throw new Error("Unexpected RPC");
state.calls.push(name);
let result: unknown;
if (name === "idb_open") {
state.database = z.string().parse(args.preferred_session_id);
state.input = z.string().parse(args.input_path);
result = {
success: true,
session: {
session_id:
state.mode === "unexpected-worker"
? "unexpected-worker"
: state.database,
input_path: state.input,
},
};
} else if (name === "idb_list")
result = {
sessions:
state.database === undefined
? []
: [
{
session_id: state.database,
input_path: state.input,
owned: true,
backend: "worker",
is_active: true,
},
],
};
else if (name === "idb_close") {
result = {
success: true,
session_id: state.database,
saved: null,
owned: true,
backend: "worker",
};
state.database = undefined;
} else if (name === "server_health")
result = {
status: "ok",
input_path:
state.mode === "headless-input-mismatch"
? state.input + ".other"
: state.input,
idb_path: state.input + ".i64",
module: "target.elf",
imagebase: "0x1000",
auto_analysis_ready: true,
};
else if (name === "lookup_funcs")
result = z
.array(z.string())
.parse(args.queries)
.map((query) => ({
query,
fn: { addr: "0x1000", name: "main", size: "0x20" },
error: null,
}));
else if (name === "decompile")
result = {
code: "int main(void) { return 42; }",
cursor: { done: true },
};
else throw new Error("Unexpected tool: " + name);
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
};
const createStartupSupervisor = async (mode: StartupMode) => {
const calls: string[] = [];
let database: string | undefined;
let input: string | undefined;
const server = createServer(async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
let body = "";
for await (const chunk of request) body += String(chunk);
const rpc = requestSchema.parse(JSON.parse(body));
if (rpc.id === undefined) {
response.writeHead(202).end();
return;
}
const send = (result: unknown) =>
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
if (rpc.method === "initialize") {
if (mode === "connect-timeout") return;
send({
protocolVersion: rpc.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-startup-fixture", version: "1" },
});
return;
}
if (rpc.method === "tools/list") {
if (mode === "catalog-timeout") return;
if (mode === "catalog-refused") {
response.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: "fixture catalog refused" },
}),
);
return;
}
send({
tools: tools
.filter((name) => mode !== "missing-profile" || name !== "list_funcs")
.map((name) => ({
name,
inputSchema: { type: "object", properties: {} },
})),
});
return;
}
const name = rpc.params?.name;
const args = rpc.params?.arguments;
if (name === undefined || args === undefined)
throw new Error("Unexpected RPC");
calls.push(name);
let result: unknown;
if (name === "idb_open") {
database = z.string().parse(args.preferred_session_id);
input = z.string().parse(args.input_path);
result = {
success: true,
session: {
session_id:
mode === "unexpected-worker" ? "unexpected-worker" : database,
input_path: input,
},
};
} else if (name === "idb_list")
result = {
sessions:
database === undefined
? []
: [
{
session_id: database,
input_path: input,
owned: true,
backend: "worker",
is_active: true,
},
],
};
else if (name === "idb_close") {
result = {
success: true,
session_id: database,
saved: null,
owned: true,
backend: "worker",
};
database = undefined;
} else if (name === "server_health")
result = {
status: "ok",
input_path:
mode === "headless-input-mismatch" ? input + ".other" : input,
idb_path: input + ".i64",
module: "target.elf",
imagebase: "0x1000",
auto_analysis_ready: true,
};
else if (name === "lookup_funcs")
result = z
.array(z.string())
.parse(args.queries)
.map((query) => ({
query,
fn: { addr: "0x1000", name: "main", size: "0x20" },
error: null,
}));
else if (name === "decompile")
result = {
code: "int main(void) { return 42; }",
cursor: { done: true },
};
else throw new Error("Unexpected tool: " + name);
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
});
const state: StartupSupervisorState = {
mode,
calls: [],
database: undefined,
input: undefined,
};
const { server, close } = createVerifierHttpServer((request, response) =>
handleStartupRequest(state, request, response),
);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
server,
calls,
calls: state.calls,
get database() {
return database;
return state.database;
},
close,
};
};
@@ -278,12 +303,7 @@ workspaceCliTest.for([
},
});
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
);
await supervisor.close();
}
},
);
@@ -0,0 +1,67 @@
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { processTest } from "../../support/process/processFixture.js";
for (const [fixture, factory, ordinaryPath, malformedPath] of [
["browser-network-site", "startBrowserNetworkSite", "/binary", "/same"],
[
"browser-request-site",
"startBrowserRequestSite",
"/api/items",
"/api/items",
],
] as const) {
processTest(
`${fixture} retains malformed request failure through physical close`,
async ({ processes }) => {
const module = new URL(
`../../../scripts/fixtures/${fixture}.mjs`,
import.meta.url,
).href;
const cleanup = new URL(
"../../../scripts/lib/verifier-cleanup.mjs",
import.meta.url,
).href;
const program = `
import assert from "node:assert/strict";
import { withVerifierCleanup } from ${JSON.stringify(cleanup)};
const { ${factory}: start } = await import(${JSON.stringify(module)});
const site = await start();
let closed = false;
await withVerifierCleanup(async () => {
const ordinary = await fetch(site.origin + ${JSON.stringify(ordinaryPath)}, { signal: AbortSignal.timeout(2000) });
assert.equal(ordinary.status, 200);
await ordinary.arrayBuffer();
await assert.rejects(fetch(site.origin + ${JSON.stringify(malformedPath)}, {
method: "POST", body: "{", signal: AbortSignal.timeout(2000),
}));
const outcome = await site.close().then(
() => ({ ok: true }), cause => ({ ok: false, cause }),
);
closed = true;
assert.equal(outcome.ok, false);
assert(outcome.cause instanceof AggregateError);
assert.equal(outcome.cause.errors.length, 1);
assert(outcome.cause.errors[0] instanceof SyntaxError);
await assert.rejects(fetch(site.origin, { signal: AbortSignal.timeout(2000) }));
console.log("ordinary response preserved; malformed request retained; endpoint closed");
}, [async () => { if (!closed) await site.close(); }]);
`;
const result = await processes.run(
process.execPath,
["--input-type=module", "-e", program],
{
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
timeoutMs: 10_000,
},
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toBe(
"ordinary response preserved; malformed request retained; endpoint closed\n",
);
},
);
}
@@ -744,8 +744,9 @@ describe.skipIf(process.platform === "win32")(
controls.directory = (handle) => {
controls.directory = undefined;
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
const directory: { close(): Promise<void> } = handle;
const close = directory.close.bind(handle);
vi.spyOn(directory, "close").mockImplementation(async () => {
closes++;
await close();
controller.abort({ caller: "directory finalization" });
@@ -6,6 +6,7 @@ import {
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import type { Dirent } from "node:fs";
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
@@ -28,6 +29,11 @@ const openMock = vi.mocked(fsOpen);
const opendirMock = vi.mocked(fsOpendir);
let actual: typeof import("node:fs/promises");
interface DirectoryOperations {
read(): Promise<Dirent | null>;
close(): Promise<void>;
}
beforeEach(async () => {
actual =
await vi.importActual<typeof import("node:fs/promises")>(
@@ -63,13 +69,14 @@ it.each([
let readCalls = 0;
opendirMock.mockImplementation(async (...args) => {
const directory = await actual.opendir(...args);
const read = directory.read.bind(directory);
vi.spyOn(directory, "read").mockImplementation(async () => {
const operations: DirectoryOperations = directory;
const read = operations.read.bind(directory);
vi.spyOn(operations, "read").mockImplementation(async () => {
readCalls += 1;
return read();
});
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
const close = operations.close.bind(directory);
vi.spyOn(operations, "close").mockImplementation(async () => {
closeCalls += 1;
if (outcome === "refusal-late-cancel") controller.abort(reason);
await close();
@@ -296,13 +303,14 @@ it("preserves a directory read failure when closing that directory also fails",
opendirMock.mockImplementation(async (...args) => {
const directory = await actual.opendir(...args);
const read = directory.read.bind(directory);
vi.spyOn(directory, "read").mockImplementation(async () => {
const operations: DirectoryOperations = directory;
const read = operations.read.bind(directory);
vi.spyOn(operations, "read").mockImplementation(async () => {
if (readCalls++ > 0) throw readFailure;
return read();
});
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
const close = operations.close.bind(directory);
vi.spyOn(operations, "close").mockImplementation(async () => {
closeCalls += 1;
await close();
throw closeFailure;
@@ -361,15 +369,16 @@ it("keeps cancelled traversal typed when native directory close rejects after in
opendirMock.mockImplementation(async (...args) => {
const directory = await actual.opendir(...args);
const operations: DirectoryOperations = directory;
openedPath = String(args[0]);
const read = directory.read.bind(directory);
vi.spyOn(directory, "read").mockImplementation(async () => {
const read = operations.read.bind(directory);
vi.spyOn(operations, "read").mockImplementation(async () => {
const child = await read();
if (child !== null) controller.abort();
return child;
});
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
const close = operations.close.bind(directory);
vi.spyOn(operations, "close").mockImplementation(async () => {
closeCalls += 1;
await close();
throw closeFailure;
+88 -85
View File
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { inspect } from "node:util";
import { ProtocolError } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
@@ -49,23 +50,90 @@ type IdaTransportFixtureOptions = {
readonly configurationHeaders?: Readonly<Record<string, string>>;
readonly connectionDependencies?: IdaMcpConnectionDependencies;
};
const fixture = async ({
redirectUrl,
cycle = false,
native = false,
toolResult,
resourceResponseText,
toolRpcError,
toolStatus = 200,
toolStatusText,
toolContentType = "application/json",
toolResponseBody,
configurationHeaders = { Authorization: "Bearer transport-fixture-secret" },
connectionDependencies,
}: IdaTransportFixtureOptions = {}) => {
const fixtureResponse = (
input: z.infer<typeof requestSchema>,
options: IdaTransportFixtureOptions,
): unknown =>
input.method === "initialize"
? {
protocolVersion: input.params?.protocolVersion,
capabilities: {
tools: {},
...(options.native ? { resources: {} } : {}),
},
serverInfo: {
name: "ida-transport-fixture",
version: "observed-server-version",
},
}
: input.method === "tools/list"
? {
tools: [
{
name: input.params?.cursor === undefined ? "first" : "second",
inputSchema: { type: "object", properties: {} },
},
],
...(input.params?.cursor === undefined
? { nextCursor: "next-page" }
: options.cycle
? {
nextCursor:
input.params.cursor === "next-page"
? "other-page"
: "next-page",
}
: {}),
}
: input.method === "resources/read"
? {
contents: [
{
uri: "ida://idb/metadata",
mimeType: "application/json",
text:
options.resourceResponseText ??
JSON.stringify({
path: "/fixture/input.i64",
sha256: "a".repeat(64),
base: "0x1000",
module: "input",
}),
},
],
}
: options.toolResult !== undefined
? options.toolResult
: options.native
? {
content: [
{
type: "text",
text: JSON.stringify([{ observed: true }]),
},
],
structuredContent: { result: [], _output_truncated: true },
}
: { content: [], structuredContent: { observed: true } };
const fixture = async (options: IdaTransportFixtureOptions = {}) => {
const {
redirectUrl,
native = false,
toolRpcError,
toolStatus = 200,
toolStatusText,
toolContentType = "application/json",
toolResponseBody,
configurationHeaders = { Authorization: "Bearer transport-fixture-secret" },
connectionDependencies,
} = options;
const methods: string[] = [];
const headers: (string | undefined)[] = [];
const server = createServer(async (request, response) => {
const handleRequest = async (
request: IncomingMessage,
response: ServerResponse,
) => {
if (redirectUrl !== undefined) {
response.writeHead(307, { Location: redirectUrl });
response.end();
@@ -86,65 +154,7 @@ const fixture = async ({
response.end();
return;
}
const result =
input.method === "initialize"
? {
protocolVersion: input.params?.protocolVersion,
capabilities: { tools: {}, ...(native ? { resources: {} } : {}) },
serverInfo: {
name: "ida-transport-fixture",
version: "observed-server-version",
},
}
: input.method === "tools/list"
? {
tools: [
{
name: input.params?.cursor === undefined ? "first" : "second",
inputSchema: { type: "object", properties: {} },
},
],
...(input.params?.cursor === undefined
? { nextCursor: "next-page" }
: cycle
? {
nextCursor:
input.params.cursor === "next-page"
? "other-page"
: "next-page",
}
: {}),
}
: input.method === "resources/read"
? {
contents: [
{
uri: "ida://idb/metadata",
mimeType: "application/json",
text:
resourceResponseText ??
JSON.stringify({
path: "/fixture/input.i64",
sha256: "a".repeat(64),
base: "0x1000",
module: "input",
}),
},
],
}
: toolResult !== undefined
? toolResult
: native
? {
content: [
{
type: "text",
text: JSON.stringify([{ observed: true }]),
},
],
structuredContent: { result: [], _output_truncated: true },
}
: { content: [], structuredContent: { observed: true } };
const result = fixtureResponse(input, options);
if (input.method === "tools/call" && toolStatusText !== undefined)
response.statusMessage = toolStatusText;
response.writeHead(input.method === "tools/call" ? toolStatus : 200, {
@@ -162,17 +172,10 @@ const fixture = async ({
: { result }),
}),
);
});
};
const { server, close } = createVerifierHttpServer(handleRequest);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanups.push(
() =>
new Promise<void>((resolve, reject) => {
server.close((error) =>
error === undefined ? resolve() : reject(error),
);
server.closeAllConnections();
}),
);
cleanups.push(close);
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Missing HTTP fixture address");
@@ -0,0 +1,210 @@
import { copyFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
processTest(
"renderer DOM callbacks report rejected bridge actions and preserve successful actions",
async ({ processes }) => {
const cases = [
{
path: fileURLToPath(
new URL(
"../../../website/public/examples/notes-electron/renderer.js",
import.meta.url,
),
),
button: "#export",
output: "#status",
failure: "Export failed",
diagnostic: "Notes export failed",
successfulReply: "/selected/notes.csv",
success: "Saved to /selected/notes.csv",
api: "notes",
},
{
path: fileURLToPath(
new URL(
"../../conformance/readiness/electron/renderer.js",
import.meta.url,
),
),
button: "#run",
output: "#result",
failure: "Echo failed",
diagnostic: "Renderer echo failed",
successfulReply: "renderer reply",
success: "renderer reply",
api: "readiness",
},
];
// Execute the real classic scripts with controlled DOM/bridge seams. This
// covers discarded event returns, not a live Electron or browser runtime.
const source = `
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
for (const scenario of ${JSON.stringify(cases)}) {
const failure = new Error("selected bridge rejected");
const errors = [];
let action, deepLink, finish, text = "";
let reply = () => Promise.reject(failure);
const output = {
get textContent() { return text; },
set textContent(value) { text = value; finish(); },
};
const button = { addEventListener(type, callback) {
assert.equal(type, "click"); action = callback;
}};
const api = scenario.api === "notes"
? { exportCsv: () => reply() }
: { echo: (value) => { assert.equal(value, "renderer"); return reply(); },
onDeepLink: (callback) => { deepLink = callback; } };
runInNewContext(readFileSync(scenario.path, "utf8"), {
document: { querySelector(selector) {
if (selector === scenario.button) return button;
assert.equal(selector, scenario.output); return output;
}},
window: { [scenario.api]: api },
console: { error: (...arguments_) => errors.push(arguments_) },
}, { filename: scenario.path });
let completed = new Promise((resolve) => { finish = resolve; });
action(); // DOM event dispatch ignores any callback return value.
await completed;
assert.equal(text, scenario.failure);
assert.deepEqual(errors, [[scenario.diagnostic, failure]]);
reply = () => Promise.resolve(scenario.successfulReply);
completed = new Promise((resolve) => { finish = resolve; });
action();
await completed;
assert.equal(text, scenario.success);
assert.equal(errors.length, 1);
if (deepLink) {
deepLink({}, "rea://selected/link");
assert.equal(text, "rea://selected/link");
}
}
`;
const result = await processes.run(
process.execPath,
[
"--unhandled-rejections=strict",
"--input-type=module",
"--eval",
source,
],
{ timeoutMs: 10_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
},
);
processTest(
"theme translation failure preserves the applied base theme and hides the toggle",
async ({ processes }) => {
const failureRoot = await createTestTempDirectory("rea-theme-failure-");
const successRoot = await createTestTempDirectory("rea-theme-success-");
const theme = fileURLToPath(
new URL("../../../website/public/assets/theme.js", import.meta.url),
);
await Promise.all([
...[failureRoot, successRoot].map((root) =>
copyFile(theme, join(root, "theme.js")),
),
...[failureRoot, successRoot].map((root) =>
writeFile(join(root, "package.json"), '{"type":"module"}\n'),
),
writeFile(
join(failureRoot, "i18n.js"),
"throw globalThis.translationFailure;\n",
),
writeFile(
join(successRoot, "i18n.js"),
'export const t = (value) => "translated:" + value;\n',
),
]);
const source = `
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { Script } from "node:vm";
globalThis.translationFailure = new Error("selected theme translation failed");
for (const [directory, fails] of ${JSON.stringify([
[failureRoot, true],
[successRoot, false],
])}) {
const errors = [], listeners = new Map(), clicks = new Map(), writes = [];
const root = { dataset: {} }, meta = { content: "" };
let ready, finish, hidden = true;
const settled = new Promise((resolve) => { finish = resolve; });
const button = {
get hidden() { return hidden; },
set hidden(value) { hidden = value; if (!value) finish(); },
setAttribute(key, value) { this[key] = value; },
addEventListener(type, callback) { clicks.set(type, callback); },
};
const path = join(directory, "theme.js");
new Script(readFileSync(path, "utf8"), {
filename: path,
importModuleDynamically: (specifier) => import(new URL(specifier, pathToFileURL(path)).href),
}).runInNewContext({
document: {
documentElement: root,
querySelector(selector) {
if (selector === 'meta[name="theme-color"]') return meta;
assert.equal(selector, "[data-theme-toggle]"); return button;
},
addEventListener(type, callback) { assert.equal(type, "DOMContentLoaded"); ready = callback; },
},
window: {
localStorage: { getItem: () => null, setItem: (...values) => writes.push(values) },
matchMedia: () => ({ matches: true, addEventListener() {} }),
addEventListener(type, callback) { listeners.set(type, callback); },
dispatchEvent(event) { listeners.get(event.type)?.(event); },
},
Event,
console: { error: (...arguments_) => { errors.push(arguments_); finish(); } },
});
assert.equal(root.dataset.theme, "dark");
assert.equal(meta.content, "#1e1e2e");
assert.equal(hidden, true);
ready(); // No event dispatcher awaits this return value.
await settled;
if (fails) {
assert.deepEqual(errors, [["Theme toggle initialization failed", globalThis.translationFailure]]);
assert.equal(hidden, true);
assert.equal(root.dataset.theme, "dark");
assert.equal(meta.content, "#1e1e2e");
assert.equal(clicks.size, 0);
} else {
assert.deepEqual(errors, []);
assert.equal(hidden, false);
assert.equal(button["aria-label"], "translated:Switch to light mode");
clicks.get("click")();
assert.equal(root.dataset.theme, "light");
assert.equal(meta.content, "#ffffff");
assert.equal(button["aria-label"], "translated:Switch to dark mode");
assert.deepEqual(writes, [["rea-theme", "light"]]);
}
}
`;
const result = await processes.run(
process.execPath,
[
"--experimental-vm-modules",
"--unhandled-rejections=strict",
"--input-type=module",
"--eval",
source,
],
{ timeoutMs: 10_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
},
);
@@ -0,0 +1,78 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
const hopper = fileURLToPath(
new URL("../../fixtures/hopperLaunchAdmission.mjs", import.meta.url),
);
const ghidra = fileURLToPath(
new URL("../../fixtures/fakeGhidra.mjs", import.meta.url),
);
for (const blocked of [false, true]) {
processTest(
`Hopper admission completes handoff publication before denial exit (blocked=${blocked})`,
async ({ processes }) => {
const root = await createTestTempDirectory(
"rea-hopper-fixture-publication-",
);
const mode = join(root, "mode");
const receipt = join(root, "receipt");
const handoff = `${receipt}.document-created`;
await writeFile(mode, "denied-after-handoff");
if (blocked) await mkdir(handoff);
const result = await processes.run(
process.execPath,
[hopper, mode, receipt, "-Y", "controlled-bootstrap"],
{ timeoutMs: 5_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(blocked ? 1 : 2);
expect(result.stderr).toContain("Not authorized to send Apple events");
expect(await readFile(receipt, "utf8")).toBe("controlled-bootstrap");
if (blocked) {
expect(result.stderr).toContain(handoff);
expect((await stat(handoff)).isDirectory()).toBe(true);
} else {
expect(await readFile(handoff, "utf8")).toBe("unconfirmed");
}
},
);
}
processTest(
"Ghidra endpoint publication reports exclusive-file refusal without declaring readiness",
async ({ processes }) => {
const root = await createTestTempDirectory(
"rea-ghidra-fixture-publication-",
);
const endpointPath = join(root, "endpoint.json");
const pending = `${endpointPath}.pending`;
await writeFile(pending, "previous selected evidence");
const result = await processes.run(process.execPath, [ghidra], {
timeoutMs: 5_000,
env: {
...process.env,
REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({
endpointPath,
transport: "authenticated-loopback-tcp",
token: "fixture-token",
runId: "fixture-run",
providerVersion: "fixture-version",
profileDigest: "a".repeat(64),
targetSha256: "b".repeat(64),
}),
},
});
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(1);
expect(result.stderr).toContain("EEXIST");
expect(result.stderr).toContain(pending);
expect(await readFile(pending, "utf8")).toBe("previous selected evidence");
await expect(stat(endpointPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -0,0 +1,47 @@
import { copyFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
processTest(
"reports the original website translation import failure at the startup boundary",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-website-initialization-");
const main = join(root, "main.mjs");
await Promise.all([
copyFile(
fileURLToPath(
new URL("../../../website/public/assets/main.js", import.meta.url),
),
main,
),
writeFile(join(root, "package.json"), '{"type":"module"}\n'),
writeFile(
join(root, "i18n.js"),
"throw globalThis.translationFailure;\n",
),
]);
// Execute the public startup code; this does not exercise browser DOM behavior.
const source = `
import assert from "node:assert/strict";
globalThis.translationFailure = new Error("selected translation import failed");
const reported = new Promise((resolve) => {
console.error = (...arguments_) => resolve(arguments_);
});
await import(${JSON.stringify(pathToFileURL(main).href)});
const [message, cause] = await reported;
assert.equal(message, "Website initialization failed");
assert.equal(cause, globalThis.translationFailure);
`;
const result = await processes.run(
process.execPath,
["--input-type=module", "--eval", source],
{ timeoutMs: 10_000 },
);
expect(result.signal, result.stderr).toBeNull();
expect(result.exitCode, result.stderr).toBe(0);
},
);
+49 -37
View File
@@ -1,4 +1,6 @@
import { createServer } from "node:http";
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { expect, it } from "vitest";
import { z } from "zod";
@@ -12,13 +14,18 @@ const requestSchema = z.object({
params: z.object({ cursor: z.string().optional() }).passthrough().optional(),
});
it("reads every JEB catalog page and refreshes the connected engine catalog", async () => {
let includeOpen = true;
let repeatedCursor = false;
let infinitePages = false;
let pageCount = 0;
const createCatalogServer = async () => {
const controls = {
includeOpen: true,
repeatedCursor: false,
infinitePages: false,
pageCount: 0,
};
const cursors: (string | undefined)[] = [];
const server = createServer(async (request, response) => {
const handleRequest = async (
request: IncomingMessage,
response: ServerResponse,
) => {
if (request.method !== "POST") {
request.resume();
response.writeHead(request.method === "DELETE" ? 200 : 405).end();
@@ -45,12 +52,12 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
};
} else if (message.method === "tools/list") {
const cursor = message.params?.cursor;
pageCount += 1;
if (!infinitePages) cursors.push(cursor);
controls.pageCount += 1;
if (!controls.infinitePages) cursors.push(cursor);
const names =
cursor === undefined
? ["get_client_information"]
: includeOpen
: controls.includeOpen
? ["open_project"]
: ["list_units"];
result = {
@@ -58,9 +65,9 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
name,
inputSchema: { type: "object" },
})),
...(infinitePages
? { nextCursor: `fresh-page-${pageCount}` }
: cursor === undefined || repeatedCursor
...(controls.infinitePages
? { nextCursor: `fresh-page-${controls.pageCount}` }
: cursor === undefined || controls.repeatedCursor
? { nextCursor: "second-page" }
: {}),
};
@@ -69,21 +76,32 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
});
};
const { server, close } = createVerifierHttpServer(handleRequest);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected an ephemeral TCP listener");
const connection = createStreamableHttpJebMcpConnection(
new URL(`http://127.0.0.1:${address.port}/mcp`),
{ discoveryTimeoutMs: 1_000 },
);
return {
controls,
cursors,
endpoint: `http://127.0.0.1:${address.port}/mcp`,
close,
};
};
it("reads every JEB catalog page and refreshes the connected engine catalog", async () => {
const { controls, cursors, endpoint, close } = await createCatalogServer();
const connection = createStreamableHttpJebMcpConnection(new URL(endpoint), {
discoveryTimeoutMs: 1_000,
});
try {
expect(await connection.listTools()).toEqual([
"get_client_information",
"open_project",
]);
includeOpen = false;
controls.includeOpen = false;
expect(await connection.listTools()).toEqual([
"get_client_information",
"list_units",
@@ -94,29 +112,28 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
undefined,
"second-page",
]);
infinitePages = true;
const before = pageCount;
controls.infinitePages = true;
const before = controls.pageCount;
await expect(connection.listTools()).rejects.toBeInstanceOf(
AnalysisTimeoutError,
);
expect(pageCount - before).toBeGreaterThan(2);
infinitePages = false;
expect(controls.pageCount - before).toBeGreaterThan(2);
controls.infinitePages = false;
expect(await connection.listTools()).toEqual([
"get_client_information",
"list_units",
]);
// Shared initial acquisition also owns a finite deadline and can recover.
const acquiring = createStreamableHttpJebMcpConnection(
new URL(`http://127.0.0.1:${address.port}/mcp`),
{ discoveryTimeoutMs: 1_000 },
);
const acquiring = createStreamableHttpJebMcpConnection(new URL(endpoint), {
discoveryTimeoutMs: 1_000,
});
try {
infinitePages = true;
controls.infinitePages = true;
await expect(acquiring.listTools()).rejects.toBeInstanceOf(
AnalysisTimeoutError,
);
infinitePages = false;
controls.infinitePages = false;
expect(await acquiring.listTools()).toEqual([
"get_client_information",
"list_units",
@@ -124,21 +141,16 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
} finally {
await acquiring.close();
}
repeatedCursor = true;
controls.repeatedCursor = true;
await expect(connection.listTools()).rejects.toThrow(
"repeated a tool catalog cursor",
);
repeatedCursor = false;
controls.repeatedCursor = false;
expect(await connection.listTools()).toEqual([
"get_client_information",
"list_units",
]);
} finally {
await connection.close();
await new Promise<void>((resolve, reject) =>
server.close((cause) =>
cause === undefined ? resolve() : reject(cause),
),
);
await withVerifierCleanup(() => connection.close(), [close]);
}
});
@@ -1,6 +1,13 @@
document.querySelector("#run").addEventListener("click", async () => {
async function echoRenderer() {
document.querySelector("#result").textContent =
await window.readiness.echo("renderer");
}
document.querySelector("#run").addEventListener("click", () => {
echoRenderer().catch((cause) => {
document.querySelector("#result").textContent = "Echo failed";
console.error("Renderer echo failed", cause);
});
});
window.readiness.onDeepLink((_event, url) => {
document.querySelector("#result").textContent = url;
+26 -18
View File
@@ -24,7 +24,7 @@ const require = createRequire(import.meta.url);
const fs = require("node:fs/promises");
const owned = (path) => String(path).startsWith(input.prefix);
let signalled = input.signal === undefined;
const deliver = (observe) => {
const recordDelivery = () => {
writeFileSync(
input.marker,
JSON.stringify({
@@ -34,24 +34,32 @@ const deliver = (observe) => {
listeners: process.listenerCount(input.signal),
}),
);
};
const emitSignal = () => {
if (input.delivery === "os") process.kill(process.pid, input.signal);
else process.emit(input.signal);
};
const deliver = () => {
recordDelivery();
emitSignal();
};
const deliverAndObserve = async () => {
recordDelivery();
// Registered after the marker so it never stands in for the CLI handler.
// Signal watchers do not keep the event loop alive; the timer does, and
// reports a signal that was never observed. It never selects the phase.
const observed = observe
? new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`${input.signal} was not observed`)),
5_000,
);
process.once(input.signal, () => {
clearTimeout(timeout);
resolve();
});
})
: undefined;
if (input.delivery === "os") process.kill(process.pid, input.signal);
else process.emit(input.signal);
return observed;
const observed = new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`${input.signal} was not observed`)),
5_000,
);
process.once(input.signal, () => {
clearTimeout(timeout);
resolve();
});
});
emitSignal();
await observed;
};
const remove = fs.rmdir;
@@ -65,7 +73,7 @@ fs.rmdir = async (...arguments_) => {
const result = await remove(...arguments_);
if (phase === "cleanup" && !signalled && owned(arguments_[0])) {
signalled = true;
setImmediate(() => deliver(false));
setImmediate(deliver);
}
return result;
};
@@ -75,7 +83,7 @@ fs.mkdtemp = async (...arguments_) => {
const directory = await stage(...arguments_);
if (phase === "staging" && !signalled && owned(directory)) {
signalled = true;
await deliver(true);
await deliverAndObserve();
}
return directory;
};
+12 -2
View File
@@ -62,6 +62,7 @@ const cutIdentifierAtBudget = async () => {
const writeThen = (stream, text, then) =>
stream.write(text, () => setTimeout(then, 10));
let endpointPublication;
if (mode === "exit") process.exit(73);
if (mode === "flood_silent") {
await floodStdout();
@@ -102,7 +103,14 @@ else {
});
if (transport === "unix-socket") server.listen(endpointPath);
else {
server.listen(0, "127.0.0.1", async () => {
endpointPublication = (async () => {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (address === null || typeof address === "string") process.exit(65);
const pending = `${endpointPath}.pending`;
@@ -115,7 +123,7 @@ else {
{ flag: "wx" },
);
await rename(pending, endpointPath);
});
})();
}
}
@@ -509,3 +517,5 @@ const stringSearchItem = () => ({
address: "0x2000",
value: "fixture value",
});
await endpointPublication;
+13 -11
View File
@@ -7,25 +7,27 @@ const bootstrapIndex = process.argv.indexOf("-Y") + 1;
if (bootstrapIndex === 0) throw new Error("Missing Hopper bootstrap argument");
const bootstrapPath = process.argv[bootstrapIndex];
await writeFile(receiptPath, bootstrapPath);
const writeDiagnostic = (text) =>
new Promise((resolve, reject) => {
process.stderr.write(text, (cause) => (cause ? reject(cause) : resolve()));
});
if (mode === "denied" || mode === "denied-after-handoff") {
process.stderr.write(
await writeDiagnostic(
"Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
async () => {
if (mode === "denied-after-handoff") {
// A real native helper can create a document before emitting this same
// diagnostic when caller literals alter its AppleScript construction.
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
}
process.exit(2);
},
);
if (mode === "denied-after-handoff") {
// A real native helper can create a document before emitting this same
// diagnostic when caller literals alter its AppleScript construction.
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
}
process.exit(2);
} else if (mode === "not-found") {
// A custom wrapper can forward the native diagnostic after a handoff.
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
process.stderr.write(
await writeDiagnostic(
"Hopper has not been found.\nPlease make sure that Spotlight is active in order for this tool to find Hopper's executable.\n",
() => process.exit(3),
);
process.exit(3);
} else if (mode === "handoff") {
process.exit(0);
} else if (mode === "bridge") {
+31 -10
View File
@@ -1,6 +1,9 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs";
const [mode, root] = process.argv.slice(2);
@@ -15,8 +18,11 @@ if (mode === "settlement") {
const child = spawn(process.execPath, [process.argv[1], "workload", root], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
child.on("message", async (message) => {
if (message !== "ready" || child.pid === undefined) return;
let handedOff = false;
await withVerifierCleanup(async () => {
const [message] = await once(child, "message");
if (message !== "ready" || child.pid === undefined)
throw new Error("Owned capture workload did not report ready");
await publishReadyMarker({
rootPid: process.pid,
childPid: child.pid,
@@ -24,17 +30,28 @@ if (mode === "settlement") {
});
child.disconnect();
child.unref();
process.stdout.write("root exiting; owned workload remains\n");
process.exit(0);
});
handedOff = true;
}, [
async () => {
if (
!handedOff &&
child.pid !== undefined &&
child.exitCode === null &&
child.signalCode === null
) {
const stopped = once(child, "exit");
child.kill("SIGTERM");
await stopped;
}
},
]);
process.stdout.write("root exiting; owned workload remains\n");
process.exit(0);
} else {
// A closed PTY must not masquerade as capture cancellation in this fixture.
process.on("SIGHUP", () => undefined);
setTimeout(
() => void writeFile(join(root, "late-write"), "still running"),
6_000,
);
setTimeout(() => process.exit(0), 12_000);
const lateWrite = delay(6_000);
const lifetime = delay(12_000);
if (mode === "workload") process.send?.("ready");
else {
await publishReadyMarker({
@@ -43,4 +60,8 @@ if (mode === "settlement") {
});
process.stdout.write("owned workload ready\n");
}
await lateWrite;
await writeFile(join(root, "late-write"), "still running");
await lifetime;
process.exit(0);
}
@@ -89,8 +89,9 @@ vi.mock("node:fs/promises", async (importOriginal) => {
if (args[0] === race.target && race.mode === "directory_missing")
race.opened = true;
if (args[0] === race.target && race.mode === "cancel_directory_close") {
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
const closing: { close(): Promise<void> } = directory;
const close = closing.close.bind(directory);
vi.spyOn(closing, "close").mockImplementation(async () => {
await close();
race.closeCompleted = true;
race.controller?.abort(race.reason);
@@ -182,8 +182,8 @@ it("snapshots the runtime-selected environment before optional loading and reuse
return server;
},
serve: (factory) => {
factory({ era: "modern" });
factory({ era: "legacy" });
expect(factory({ era: "modern" })).toBe(observed[0]);
expect(factory({ era: "legacy" })).toBe(observed[1]);
return {
close: async () => {
await Promise.all(observed.map((server) => server.close()));
@@ -16,8 +16,9 @@ vi.mock("node:fs/promises", async (importOriginal) => {
opendir: async (...args: Parameters<typeof filesystem.opendir>) => {
const directory = await filesystem.opendir(...args);
if (String(args[0]) === race.directory) {
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
const closing: { close(): Promise<void> } = directory;
const close = closing.close.bind(directory);
vi.spyOn(closing, "close").mockImplementation(async () => {
await close();
const hook = race.afterDirectoryClose;
race.afterDirectoryClose = undefined;
+40 -16
View File
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { prepareCliProcessEnvironment } from "../../../scripts/lib/cli-process-environment.mjs";
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
import {
createCliSessionFrameReader,
encodeCliSessionFrame,
@@ -64,28 +65,51 @@ const writeFrame = (value) =>
await writeFrame({ ready: true });
let chain = Promise.resolve();
// EOF drains admitted commands; failure seals input and skips commands not yet started.
/** @type {{ kind: "open" } | { kind: "ended" } | { kind: "failed", cause: unknown }} */
let completion = { kind: "open" };
/** @type {(() => void) | undefined} */
let resolveInput;
/** @type {Promise<void>} */
const inputStopped = new Promise((resolve) => {
resolveInput = resolve;
});
const failInput = (cause) => {
if (completion.kind !== "failed") completion = { kind: "failed", cause };
process.stdin.pause();
resolveInput?.();
};
process.stdin.once("end", () => {
if (completion.kind === "open") completion = { kind: "ended" };
resolveInput?.();
});
const reader = createCliSessionFrameReader((value) => {
chain = chain.then(
() => handle(value),
() => handle(value),
);
if (completion.kind !== "open") return;
chain = chain
.then(async () => {
if (completion.kind !== "failed") await handle(value);
})
.catch(failInput);
});
process.stdin.on("data", (chunk) => {
if (completion.kind !== "open") return;
try {
reader(chunk);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exit(1);
} catch (cause) {
failInput(cause);
}
});
process.stdin.on("end", () => {
chain.finally(async () => {
await closeCliJavaScriptResources();
process.exit(0);
});
});
try {
await withVerifierCleanup(async () => {
await inputStopped;
await chain;
if (completion.kind === "failed") throw completion.cause;
}, [closeCliJavaScriptResources]);
} catch (cause) {
console.error(cause);
process.exit(1);
}
process.exit(0);
async function handle(value) {
const request = value;
@@ -120,6 +144,7 @@ async function handle(value) {
...result,
});
} catch (error) {
failInput(error);
const id =
request !== null && typeof request === "object" ? request.id : undefined;
await writeFrame({
@@ -131,7 +156,6 @@ async function handle(value) {
error instanceof Error ? (error.stack ?? error.message) : String(error),
fatal: true,
});
process.exit(1);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, savedEnv);
+4 -2
View File
@@ -165,7 +165,9 @@ async function initializeWebsite() {
}
}
initializeGitHubStars();
return initializeGitHubStars();
}
initializeWebsite();
initializeWebsite().catch((cause) => {
console.error("Website initialization failed", cause);
});
+6 -1
View File
@@ -42,7 +42,7 @@
}
});
document.addEventListener("DOMContentLoaded", async () => {
const initializeToggle = async () => {
const button = document.querySelector("[data-theme-toggle]");
if (button === null) return;
const { t } = await import("./i18n.js");
@@ -66,5 +66,10 @@
});
updateLabel();
button.hidden = false;
};
document.addEventListener("DOMContentLoaded", () => {
initializeToggle().catch((cause) => {
console.error("Theme toggle initialization failed", cause);
});
});
})();
@@ -1,4 +1,11 @@
document.querySelector("#export").addEventListener("click", async () => {
async function exportNotes() {
const destination = await window.notes.exportCsv();
document.querySelector("#status").textContent = `Saved to ${destination}`;
}
document.querySelector("#export").addEventListener("click", () => {
exportNotes().catch((cause) => {
document.querySelector("#status").textContent = "Export failed";
console.error("Notes export failed", cause);
});
});