Fix libfx readiness and runtime cleanup

This commit is contained in:
Pranit committed 2026-09-04 01:02:24 -04:00
1 parent 79158e4dbd
commit ae0ad3ac53
19 files changed
+312 -168

No files matched your search

@@ -48,6 +48,8 @@ fn serveConnection(stream: std.Io.net.Stream) void {
fn serveConnectionFallible(io: std.Io, stream: std.Io.net.Stream) !void {
defer stream.close(io);
const no_delay: c_int = 1;
try std.posix.setsockopt(stream.socket.handle, std.posix.IPPROTO.TCP, std.posix.TCP.NODELAY, std.mem.asBytes(&no_delay));
var read_buffer: [64 * 1024]u8 = undefined;
var write_buffer: [64 * 1024]u8 = undefined;
var reader = stream.reader(io, &read_buffer);
+8 -5
View File
@@ -31,7 +31,7 @@ JavaScript createFxAgent()
sdk/node.js selects native backend
|
v
createCore(options, onReady) in libfx.node
createCore(options) in libfx.node
|
v
Zig Runtime thread runs acp_server.runWithTransport()
@@ -57,10 +57,12 @@ shared createFxAgent() logic in sdk/fx-sdk.js
- `exited` settles when the native runtime exits.
- `abort()` aborts Node fetch and closes native input.
The adapter supplies one readiness callback when it creates the core. The runtime schedules that callback when a fetch request becomes pending, output changes from empty to non-empty, or the core exits. The callback carries no state: JavaScript drains the bounded fetch and output queues to quiescence and then checks exit state. While a body pump exists it calls `coreFetchActive()` for that fetch handle. An inactive handle aborts its matching `AbortController` on the next readiness callback. ACP output is accumulated to newline boundaries and parsed as JSON. Gateway requests are transferred to Node as bounded request records; Node runs the configured `fetch`, streams bounded response chunks back to Zig, and owns the `AbortController`. This matches the WebAssembly host-fetch boundary and ensures the N-API core never uses the native `std.http` Gateway transport.
Each core has one nonblocking Unix socketpair for readiness. The adapter takes ownership of the reader with `takeCoreReadyFd()` and watches it through `node:net`; Bun adopts the descriptor through `connect()`. The native writer signals when a fetch request becomes pending, output changes from empty to non-empty, or the core exits. Wake bytes carry no state: JavaScript drains the bounded fetch and output queues to quiescence and then checks exit state. While a body pump exists it calls `coreFetchActive()` for that fetch handle. An inactive handle aborts its matching `AbortController` on the next readiness event. ACP output is accumulated to newline boundaries and parsed as JSON. Gateway requests are transferred to Node as bounded request records; Node runs the configured `fetch`, streams bounded response chunks back to Zig, and owns the `AbortController`. This matches the WebAssembly host-fetch boundary and ensures the N-API core never uses the native `std.http` Gateway transport.
The shared JavaScript Agent wrapper emits bounded `transport.start`, `transport.response`, and `transport.error` diagnostics around that host-owned fetch. It allowlists request, generation, model, provider, status, attempt, endpoint, and elapsed-time fields rather than exposing credentials or arbitrary headers.
The native kernel installs only the host-stream provider and static model metadata. It does not inherit CLI billing reconciliation, model discovery, credits, search, or compaction providers. Token usage comes from the response stream; generation metadata must not trigger native HTTP requests outside the host's fetch function.
## Native module ABI
`napi_register_module_v1` exports a deliberately small low-level interface:
@@ -68,7 +70,8 @@ The shared JavaScript Agent wrapper emits bounded `transport.start`, `transport.
| Export | Purpose |
| --- | --- |
| `libfxApiVersion` | Checks compatibility with the JavaScript loader. Currently `3`. Low-level `createCore` backends must declare this exact version. |
| `createCore(options, onReady)` | Allocates a runtime, installs its readiness callback, and starts its ACP thread. |
| `createCore(options)` | Allocates a runtime and readiness socketpair, then starts its ACP thread. |
| `takeCoreReadyFd(handle)` | Transfers the readiness reader descriptor to JavaScript exactly once. The caller owns its close. |
| `writeCore(handle, buffer)` | Appends bytes to the bounded input queue. |
| `closeCore(handle)` | Closes input and wakes a blocked ACP reader. |
| `drainCore(handle)` | Returns up to 1 MiB of queued output as a Node Buffer. |
@@ -103,13 +106,13 @@ Creating a core performs these steps:
5. Spawn one native thread.
6. Run `acp_server.runWithTransport()` on that thread using callback-backed ACP queues and the shared host-stream provider.
The runtime thread never reads or mutates JavaScript values. It blocks on the fetch bridge while Node owns `fetch`, response-body iteration, and `AbortController`; its only Node-API effect is scheduling the thread-safe readiness callback. Queue state remains authoritative when callbacks coalesce. An environment cleanup hook shuts down and joins every runtime before Node tears down its callback handle, including worker termination. Explicit destruction unregisters that hook. Destruction marks the bridge shutting down, wakes every wait, joins the runtime thread, and then releases the callback and native memory.
The runtime thread never reads or mutates JavaScript values or calls Node-API. It blocks on the fetch bridge while Node owns `fetch`, response-body iteration, and `AbortController`. Queue state remains authoritative when readiness writes coalesce. An environment cleanup hook shuts down and joins every runtime, including worker termination. Explicit destruction unregisters that hook. Destruction marks the bridge shutting down, wakes every wait, joins the runtime thread, and then closes the readiness writer and any unclaimed reader before freeing native memory. The JavaScript adapter destroys its reader socket and waits for its close before settling `exited`.
The addon initializes one process-wide `std.Io.Threaded` instance. Atomic state protects one-time initialization when the addon is loaded in multiple Node worker environments. The same initialization installs inherited process-environment access before any runtime thread starts. It does not configure fx product tracing from ambient `FX_TRACE_*` variables; libfx remains silent unless its JavaScript host explicitly requests SDK observability.
Input and output queues have independent `std.Io.Mutex` protection. The input queue also has a condition variable so the ACP reader sleeps while no input is available. Closing input broadcasts the condition and allows the server thread to terminate.
Readiness uses one atomic pending flag, cleared before JavaScript drains the queues. This permits one queued wake while the current callback runs without losing a producer's notification. The thread-safe function owns the notifier until its finalizer runs. Discarded teardown callbacks return without accessing that context.
Readiness has no callback queue or pending flag. A full socket already contains a wake, so a nonblocking `EAGAIN` needs no retry. Other write failures shut down the writer so the reader observes EOF and fails the runtime instead of hanging. Writes suppress `SIGPIPE`, and descriptors are close-on-exec. This avoids depending on a thread-safe-function dispatcher's drain/idle race.
The runtime handle has a separate mutex that serializes access to the runtime pointer against destruction. Destruction removes the pointer first, then closes input and joins the thread. No runtime allocation is freed while its thread is still running.
+9 -3
View File
@@ -341,6 +341,8 @@ function yieldToHostTask() {
}
function createRuntime(options) {
// Creating this inside a Wasm call would retain that instance through the error stack.
const abortReason = new DOMException("This operation was aborted", "AbortError");
const stdin = new ByteQueue();
const streams = new Map();
const workspaceExecs = new Set();
@@ -857,7 +859,7 @@ function createRuntime(options) {
}
function abortHostEffects() {
streams.forEach((state) => state.controller.abort());
streams.forEach((state) => state.controller.abort(abortReason));
workspaceExecs.forEach((state) => state.abort(-3));
}
@@ -915,7 +917,7 @@ function createRuntime(options) {
fx_http_stream_open: streamOpen,
fx_http_stream_status: new WebAssembly.Suspending(streamStatus),
fx_http_stream_next: new WebAssembly.Suspending(streamNext),
fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(); streams.delete(handle); },
fx_http_stream_close(handle) { const state = streams.get(handle); state?.controller.abort(abortReason); streams.delete(handle); },
fx_http_request: new WebAssembly.Suspending(httpRequest),
fx_host_tool_call: new WebAssembly.Suspending(hostToolCall),
fx_open_url: new WebAssembly.Suspending(openUrl),
@@ -978,8 +980,12 @@ async function instantiate(options) {
runtime.setInstance(instance);
const start = WebAssembly.promising(instance.exports._start);
start().then(
() => runtime.markExited(0),
() => {
runtime.setInstance(null);
runtime.markExited(0);
},
(error) => {
runtime.setInstance(null);
if (!String(error).includes("proc_exit")) console.error(error);
runtime.markExited(runtime.aborted ? 130 : 1);
},
+27 -2
View File
@@ -1,5 +1,7 @@
import { access, readFile } from "node:fs/promises";
import { closeSync } from "node:fs";
import { createRequire } from "node:module";
import { Socket } from "node:net";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -129,7 +131,20 @@ function createNativeCoreRuntime(addon, options) {
workspaceRoot: options.workspaceRoot ?? process.cwd(),
...(model === undefined ? {} : { model }),
...(gatewayChatUrl === undefined ? {} : { gatewayChatUrl }),
}, drainReady);
});
let readyFd;
let readySocket;
try {
readyFd = addon.takeCoreReadyFd(core);
readySocket = new Socket({ fd: readyFd, readable: true, writable: false });
} catch (error) {
if (readyFd !== undefined) {
try { closeSync(readyFd); } catch {}
}
addon.destroyCore(core);
throw error;
}
const readyClosed = new Promise((resolve) => readySocket.once("close", resolve));
let exitedResolve;
let lineHandler = null;
let lineBuffer = "";
@@ -146,7 +161,8 @@ function createNativeCoreRuntime(addon, options) {
settled = true;
abortHostEffects();
try { addon.destroyCore(core); } catch {}
exitedResolve(code);
readySocket.destroy();
void readyClosed.then(() => exitedResolve(code));
};
const pumpFetch = async (request) => {
const controller = new AbortController();
@@ -225,6 +241,15 @@ function createNativeCoreRuntime(addon, options) {
}
}
readySocket.on("data", drainReady);
readySocket.on("end", () => { drainReady(); if (!settled) finish(1); });
readySocket.on("error", () => finish(1));
readySocket.on("close", () => { if (!settled) finish(1); });
// Some runtimes defer descriptor adoption until connect().
if (readySocket.pending) {
try { readySocket.connect({ fd: readyFd }); } catch (error) { finish(1); throw error; }
}
return {
exited,
write(data) { addon.writeCore(core, Buffer.from(data)); },
+58 -30
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { closeSync } from "node:fs";
import { mkdtemp, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -36,6 +38,37 @@ await writeFile(highLevelAgentPath, `
export const libfxApiVersion = 2;
export function createFxAgent() { throw new Error("high-level createFxAgent invoked"); }
`);
// Complete the fixture directory before a runtime caches its module-resolution entries.
const coreOnlyPath = resolve(dir, "core-only.mjs");
await writeFile(coreOnlyPath, `
export const libfxApiVersion = 3;
export function createCore() { throw new Error("unused createCore"); }
`);
const incompatiblePath = resolve(dir, "incompatible.mjs");
await writeFile(incompatiblePath, `
export const libfxApiVersion = 4;
export async function createFxAgent() {}
`);
const versionFixtures = [
["missing-version", `
export function createCore() { throw new Error("missing-version createCore invoked"); }
`],
["unequal-version", `
export const libfxApiVersion = 4;
export function createCore() { throw new Error("unequal-version createCore invoked"); }
`],
];
for (const [name, source] of versionFixtures) await writeFile(resolve(dir, `${name}.mjs`), source);
const matchingVersionPath = resolve(dir, "matching-version.mjs");
await writeFile(matchingVersionPath, `
export const libfxApiVersion = 3;
export function createCore() {
const error = new Error("matching-version createCore invoked");
error.code = "MATCHING_VERSION_INVOKED";
throw error;
}
`);
await assert.rejects(
createFxAgent({
backend: "native",
@@ -123,39 +156,20 @@ try {
Object.defineProperty(WebAssembly, "promising", { configurable: true, value: savedPromising });
}
const coreOnlyPath = resolve(dir, "core-only.mjs");
await writeFile(coreOnlyPath, `
export const libfxApiVersion = 3;
export function createCore() { throw new Error("unused createCore"); }
`);
await assert.rejects(
createFxTerminal({ nativeAddon: pathToFileURL(coreOnlyPath), backend: "native" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
error.message.includes("createFxTerminal"),
);
const incompatiblePath = resolve(dir, "incompatible.mjs");
await writeFile(incompatiblePath, `
export const libfxApiVersion = 4;
export async function createFxAgent() {}
`);
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(incompatiblePath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
error.message.includes("incompatible"),
);
for (const [name, source] of [
["missing-version", `
export function createCore() { throw new Error("missing-version createCore invoked"); }
`],
["unequal-version", `
export const libfxApiVersion = 4;
export function createCore() { throw new Error("unequal-version createCore invoked"); }
`],
]) {
for (const [name] of versionFixtures) {
const modulePath = resolve(dir, `${name}.mjs`);
await writeFile(modulePath, source);
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(modulePath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "LIBFX_NATIVE_UNAVAILABLE" &&
@@ -165,19 +179,33 @@ for (const [name, source] of [
);
}
const matchingVersionPath = resolve(dir, "matching-version.mjs");
await writeFile(matchingVersionPath, `
export const libfxApiVersion = 3;
export function createCore() {
const error = new Error("matching-version createCore invoked");
error.code = "MATCHING_VERSION_INVOKED";
throw error;
}
`);
await assert.rejects(
createFxAgent({ nativeAddon: pathToFileURL(matchingVersionPath), backend: "native", apiKey: "loader-key" }),
(error) => error?.code === "MATCHING_VERSION_INVOKED",
"matching v3 low-level addon must reach createCore",
);
console.log("libfx loader passed: browser exports, native preference, fallback diagnostics, semantic errors, and strict low-level API validation");
const addon = createRequire(import.meta.url)(realNativeAddon);
let failedCore;
let failedCoreDestroyed = false;
const brokenReaderAddon = {
...addon,
createCore(options) { return failedCore = addon.createCore(options); },
takeCoreReadyFd(core) {
const fd = addon.takeCoreReadyFd(core);
closeSync(fd);
return fd;
},
destroyCore(core) {
failedCoreDestroyed = true;
return addon.destroyCore(core);
},
};
try {
await assert.rejects(createFxAgent({ backend: "native", nativeAddon: brokenReaderAddon, apiKey: "loader-key" }));
assert.ok(failedCoreDestroyed, "failed readiness adoption must still destroy the native runtime");
} finally {
if (failedCore) addon.destroyCore(failedCore);
}
console.log("libfx loader passed: browser exports, native preference, fallback diagnostics, semantic errors, strict low-level API validation, and failed-start cleanup");
@@ -14,7 +14,7 @@ const core = addon.createCore({
home: "/tmp",
workspaceRoot: "/tmp",
gatewayChatUrl: "http://127.0.0.1:31337/chat",
}, () => {});
});
let nextId = 1;
let buffered = "";
@@ -27,24 +27,34 @@ await writeFile(join(runtimeWorkspace, ".mcp.json"), `${JSON.stringify({
})}\n`);
let requestBody = "";
let unexpectedRequests = 0;
const server = createServer((request, response) => {
if (request.method !== "POST") {
unexpectedRequests++;
request.resume();
response.writeHead(404).end();
return;
}
request.setEncoding("utf8");
request.on("data", (chunk) => { requestBody += chunk; });
request.on("end", () => {
response.writeHead(200, { "content-type": "text/event-stream" });
response.write('data: {"type":"text-delta","delta":"isolated"}\n\n');
response.write('data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"},"usage":{"inputTokens":{"total":1},"outputTokens":{"total":1}}}\n\n');
response.write('data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"},"usage":{"inputTokens":{"total":1},"outputTokens":{"total":1}},"providerMetadata":{"gateway":{"generationId":"gen_01ARZ3NDEKTSV4RRFFQ69G5FAV"}}}\n\n');
response.end("data: [DONE]\n\n");
});
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const { port } = server.address();
const previousGatewayBase = process.env.FX_GATEWAY_BASE_URL;
process.env.FX_GATEWAY_BASE_URL = `http://127.0.0.1:${port}`;
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
let agent;
try {
process.chdir(processWorkspace);
const agent = await createFxAgent({
agent = await createFxAgent({
nativeAddon: addon,
backend: "native",
home: runtimeHome,
@@ -63,9 +73,14 @@ try {
await turn.result;
assert.match(requestBody, new RegExp(marker), "native startup omitted explicit host instructions");
assert.doesNotMatch(requestBody, new RegExp(workspaceMarker), "minimal kernel scanned workspace context");
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
assert.equal(await agent.close(), undefined);
console.log("native config isolation passed: explicit instructions won and workspace config was not scanned");
assert.equal(unexpectedRequests, 0, "kernel must not perform native billing lookups outside host fetch");
console.log("native config isolation passed: explicit instructions, no workspace scan, and no native billing lookup");
} finally {
await agent?.close();
if (previousGatewayBase === undefined) delete process.env.FX_GATEWAY_BASE_URL;
else process.env.FX_GATEWAY_BASE_URL = previousGatewayBase;
process.chdir(originalCwd);
server.closeAllConnections();
server.close();
+1 -1
View File
@@ -8,7 +8,7 @@ const require = createRequire(import.meta.url);
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addonPath = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
const addon = require(addonPath);
const core = addon.createCore({ apiKey: "exit-code-test-key", home: "/tmp", workspaceRoot: "/tmp" }, () => {});
const core = addon.createCore({ apiKey: "exit-code-test-key", home: "/tmp", workspaceRoot: "/tmp" });
try {
addon.writeCore(core, Buffer.from("{}\n".repeat(180_000)));
+7 -12
View File
@@ -18,14 +18,13 @@ if (!ambientTraceChild) {
delete process.env.FX_TRACE_STDERR;
}
const addon = require(addonPath);
const noop = () => {};
if (ambientTraceChild) {
const traceCore = addon.createCore({
apiKey: "trace-test-key",
home: "/tmp",
workspaceRoot: "/tmp",
}, noop);
});
try {
assert.equal(addon.pushCoreFetchResponse(traceCore, 17, Buffer.from("secret-response-payload")), 0);
} finally {
@@ -37,6 +36,7 @@ if (ambientTraceChild) {
for (const [name, args] of [
["createCore", []],
["takeCoreReadyFd", []],
["writeCore", []],
["writeCore", [{}]],
["closeCore", []],
@@ -58,18 +58,13 @@ for (const [name, args] of [
});
}
assert.throws(
() => addon.createCore({ apiKey: "key", home: "/tmp", workspaceRoot: "/tmp" }, null),
{ name: "TypeError", code: "LIBFX_INVALID_ARGUMENT", message: "ready callback must be a function" },
);
const getterError = new Error("host getter failed");
assert.throws(
() => addon.createCore(Object.defineProperty({}, "apiKey", { get() { throw getterError; } }), noop),
() => addon.createCore(Object.defineProperty({}, "apiKey", { get() { throw getterError; } })),
(error) => error === getterError,
);
assert.throws(
() => addon.createCore(new Proxy({}, { has() { throw getterError; } }), noop),
() => addon.createCore(new Proxy({}, { has() { throw getterError; } })),
(error) => error === getterError,
);
@@ -81,7 +76,7 @@ for (const [options, message] of [
[{ apiKey: "key", home: "/tmp", workspaceRoot: "/tmp", gatewayChatUrl: "https://user:pass@example.com/chat" }, /gatewayChatUrl/],
[{ apiKey: "key", home: "/tmp", workspaceRoot: "/tmp", gatewayChatUrl: "https://example.com/chat" }, /gatewayChatUrl/],
]) {
assert.throws(() => addon.createCore(options, noop), message);
assert.throws(() => addon.createCore(options), message);
}
for (const fakeHandle of [null, undefined, {}, Buffer.alloc(0), 0, "handle"]) {
@@ -91,7 +86,7 @@ for (const fakeHandle of [null, undefined, {}, Buffer.alloc(0), 0, "handle"]) {
);
}
const core = addon.createCore({ apiKey: "misuse-test-key", home: "/tmp", workspaceRoot: "/tmp" }, noop);
const core = addon.createCore({ apiKey: "misuse-test-key", home: "/tmp", workspaceRoot: "/tmp" });
assert.throws(
() => addon.writeCore(core, Buffer.alloc(8 * 1024 * 1024 + 1)),
(error) => error.code === "LIBFX_NATIVE_BACKPRESSURE",
@@ -110,7 +105,7 @@ const lifecycleCore = addon.createCore({
home: "/tmp",
workspaceRoot: "/tmp",
gatewayChatUrl: "http://127.0.0.1:31337/chat",
}, noop);
});
let nextId = 1;
let buffered = "";
const timeout = (label, ms = 5000) => new Promise((_, reject) => {
+18 -3
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env node
import { strict as assert } from "node:assert";
import { createRequire } from "node:module";
import { Socket } from "node:net";
import { once } from "node:events";
import { closeSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -33,7 +36,14 @@ const core = addon.createCore({
apiKey: "ready-test-key",
home: process.cwd(),
workspaceRoot: process.cwd(),
}, notify);
});
const reader = addon.takeCoreReadyFd(core);
assert.throws(() => addon.takeCoreReadyFd(core), /already transferred/);
const socket = new Socket({ fd: reader, readable: true, writable: false });
socket.on("data", notify);
socket.on("end", notify);
if (socket.pending) socket.connect({ fd: reader });
const socketClosed = once(socket, "close");
try {
addon.writeCore(core, Buffer.from(`${JSON.stringify({
@@ -82,14 +92,19 @@ try {
} finally {
addon.closeCore(core);
addon.destroyCore(core);
socket.destroy();
await socketClosed;
}
// Exit wakes remain queued when a core is destroyed before JavaScript yields.
const beforeFds = readdirSync("/dev/fd").length;
// Unclaimed readers and pending exit wakes are released with their runtime.
for (let index = 0; index < 32; index++) {
const closing = addon.createCore({ apiKey: "ready-close-key", home: "/tmp", workspaceRoot: "/tmp" }, () => {});
const closing = addon.createCore({ apiKey: "ready-close-key", home: "/tmp", workspaceRoot: "/tmp" });
if (index % 2 === 0) closeSync(addon.takeCoreReadyFd(closing));
addon.closeCore(closing);
addon.destroyCore(closing);
}
await new Promise((resolveReady) => setImmediate(resolveReady));
assert.equal(readdirSync("/dev/fd").length, beforeFds);
console.log("native core readiness notification passed");
+4 -5
View File
@@ -9,7 +9,6 @@ const require = createRequire(import.meta.url);
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
const addonPath = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
const addon = require(addonPath);
const noop = () => {};
function fdCount() {
try { return readdirSync("/dev/fd").length; } catch { return null; }
@@ -22,7 +21,7 @@ for (let index = 0; index < 1000; index += 1) {
model: 42,
home: process.cwd(),
workspaceRoot: process.cwd(),
}, noop));
}));
}
const afterFds = fdCount();
if (beforeFds !== null && afterFds !== null) assert.ok(afterFds - beforeFds < 4, `fd leak: ${beforeFds} -> ${afterFds}`);
@@ -31,15 +30,15 @@ const runtimeLimitProbe = Array.from({ length: 64 }, () => addon.createCore({
apiKey: "runtime-limit-key",
home: process.cwd(),
workspaceRoot: process.cwd(),
}, noop));
}));
assert.throws(
() => addon.createCore({ apiKey: "runtime-limit-key", home: process.cwd(), workspaceRoot: process.cwd() }, noop),
() => addon.createCore({ apiKey: "runtime-limit-key", home: process.cwd(), workspaceRoot: process.cwd() }),
(error) => error.code === "LIBFX_NATIVE_LIMIT",
);
for (const handle of runtimeLimitProbe) addon.closeCore(handle);
for (const handle of runtimeLimitProbe) addon.destroyCore(handle);
const core = addon.createCore({ apiKey: "security-test-key", home: process.cwd(), workspaceRoot: process.cwd() }, noop);
const core = addon.createCore({ apiKey: "security-test-key", home: process.cwd(), workspaceRoot: process.cwd() });
let nextId = 1;
function send(method, params) {
const id = nextId++;
+3 -4
View File
@@ -18,7 +18,7 @@ function runWorker(index) {
apiKey: "worker-test-key",
home: process.cwd(),
workspaceRoot: process.cwd(),
}, () => {});
});
addon.writeCore(core, Buffer.from(JSON.stringify({
jsonrpc: "2.0",
id: workerData.index + 1,
@@ -46,19 +46,18 @@ const outputs = await Promise.all(Array.from({ length: 4 }, (_, index) => runWor
for (const output of outputs) assert.match(output, /"result"/);
const addon = require(addonPath);
const noop = () => {};
const localCores = Array.from({ length: 3 }, () => addon.createCore({
apiKey: "same-env-test-key",
home: process.cwd(),
workspaceRoot: process.cwd(),
}, noop));
}));
for (const core of localCores) addon.closeCore(core);
for (const core of localCores) addon.destroyCore(core);
let finalized = false;
const registry = new FinalizationRegistry(() => { finalized = true; });
{
let abandoned = addon.createCore({ apiKey: "gc-test-key", home: process.cwd(), workspaceRoot: process.cwd() }, noop);
let abandoned = addon.createCore({ apiKey: "gc-test-key", home: process.cwd(), workspaceRoot: process.cwd() });
registry.register(abandoned, "abandoned");
abandoned = null;
}
+1 -1
View File
@@ -17,7 +17,7 @@ const commands = [
[process.execPath, [fileURLToPath(new URL("test-wasm-memory.mjs", import.meta.url))]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-bootstrap.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-instruction-limits.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-wasm-module-cache.mjs", import.meta.url))]],
[process.execPath, ["--experimental-wasm-jspi", "--expose-gc", fileURLToPath(new URL("test-wasm-module-cache.mjs", import.meta.url))]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-request-context.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-agent-transport-retry.mjs", import.meta.url)), "wasm"]],
[process.execPath, ["--experimental-wasm-jspi", fileURLToPath(new URL("test-core.mjs", import.meta.url))]],
+28 -2
View File
@@ -41,6 +41,17 @@ const server = createServer((request, response) => {
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const compileDescriptor = Object.getOwnPropertyDescriptor(WebAssembly, "compile");
const instantiateDescriptor = Object.getOwnPropertyDescriptor(WebAssembly, "instantiate");
const realInstantiate = WebAssembly.instantiate.bind(WebAssembly);
const instances = [];
Object.defineProperty(WebAssembly, "instantiate", {
...instantiateDescriptor,
value: async (...args) => {
const instance = await realInstantiate(...args);
instances.push(new WeakRef(instance));
return instance;
},
});
const realCompile = WebAssembly.compile.bind(WebAssembly);
let compileCalls = 0;
let failNextCompile = false;
@@ -58,10 +69,14 @@ Object.defineProperty(WebAssembly, "compile", {
});
const agents = [];
const signals = [];
const options = (wasm = wasmPath) => ({
backend: "wasm",
wasm,
fetch,
fetch(input, init) {
signals.push(init.signal);
return fetch(input, init);
},
apiKey: "wasm-cache-key",
gatewayChatUrl: `http://127.0.0.1:${server.address().port}/chat`,
model: "cache/model",
@@ -114,9 +129,20 @@ try {
await recoveredRead.close();
assert.equal(compileCalls, 5, "a rejected file read must be removed so the next attempt retries");
console.log(`${process.versions.bun ? "Bun" : "Node"} Wasm module cache passed: compilation reuse, retry, and Agent isolation`);
await Promise.all(agents.map((agent) => agent.close()));
assert.ok(signals.every((signal) => signal.aborted));
const collect = globalThis.gc ?? (() => globalThis.Bun?.gc(true));
const deadline = Date.now() + 5000;
do {
await new Promise((resolveGc) => setTimeout(resolveGc, 10));
collect();
} while (instances.some((instance) => instance.deref()) && Date.now() < deadline);
assert.ok(instances.every((instance) => !instance.deref()), "retained closed Agents must release their Wasm instances");
console.log(`${process.versions.bun ? "Bun" : "Node"} Wasm module cache passed: compilation reuse, retry, isolation, and closed-instance release`);
} finally {
Object.defineProperty(WebAssembly, "compile", compileDescriptor);
Object.defineProperty(WebAssembly, "instantiate", instantiateDescriptor);
await Promise.all(agents.map((agent) => agent.close().catch(() => {})));
server.closeAllConnections();
await new Promise((resolveClose) => server.close(resolveClose));
+1 -1
View File
@@ -770,7 +770,7 @@ pub fn runWithTransport(
.cfg = cfg,
.writer = writer_value,
.web_search_runtime = web_search_runtime.Runtime.init(.{
.provider = cfg.provider_set.gateway.fx_search.?,
.provider = cfg.provider_set.gateway.fx_search,
}),
.terminal_client = terminal_client_runtime.Runtime.init(
cfg.process_provider,
+26 -15
View File
@@ -178,14 +178,15 @@ const PendingReload = struct {
}
fn quiesceDetachedAuthority(self: *PendingReload) void {
if (self.superseded_reload) |task| {
self.superseded_reload = null;
task.deinit();
}
// Authentication can hold the lease that a retiring reload is waiting for.
if (self.superseded_authentication) |task| {
self.superseded_authentication = null;
cancelAndDeinitAuthentication(task, "authority_reduction");
}
if (self.superseded_reload) |task| {
self.superseded_reload = null;
task.deinit();
}
if (self.detached_runtime) |runtime| {
self.detached_runtime = null;
destroyRuntime(self.alloc, runtime);
@@ -2039,6 +2040,7 @@ pub const State = struct {
presentation_origin: PresentationOrigin,
spawn_reload: SpawnPendingReloadFn,
) !void {
self.cancelPendingAuthentication("reload");
self.cancelPendingReload();
const pending = try alloc.create(PendingReload);
@@ -2214,10 +2216,10 @@ pub const State = struct {
self.runtime = null;
self.lock.unlock(io_mod.getIo());
if (pending_reload) |task| task.deinit();
if (pending_authentication) |task| {
cancelAndDeinitAuthentication(task, "authority_reduction_sync");
}
if (pending_reload) |task| task.deinit();
if (runtime) |value| destroyRuntime(alloc, value);
}
@@ -2995,6 +2997,9 @@ test "authority reduction quiesces superseded tasks before detached runtime reti
var active_lease = state.acquire() orelse return error.TestUnexpectedResult;
var active_lease_owned = true;
defer if (active_lease_owned) active_lease.deinit();
var authentication_lease = state.acquire() orelse return error.TestUnexpectedResult;
var authentication_lease_owned = true;
defer if (authentication_lease_owned) authentication_lease.deinit();
test_reload_mode = .delayed_empty;
defer test_reload_mode = .empty;
@@ -3008,11 +3013,23 @@ test "authority reduction quiesces superseded tasks before detached runtime reti
50,
);
const authentication = try alloc.create(PendingAuthentication);
const retirement_deadline = io_mod.milliTimestamp() + 2_000;
while (!runtime.retiring.load(.acquire) and
io_mod.milliTimestamp() < retirement_deadline)
{
io_mod.sleep(std.time.ns_per_ms);
}
try std.testing.expect(runtime.retiring.load(.acquire));
const authentication_name = try alloc.dupe(u8, "workspace");
const authentication = alloc.create(PendingAuthentication) catch |err| {
alloc.free(authentication_name);
return err;
};
authentication.* = .{
.alloc = alloc,
.server_name = try alloc.dupe(u8, "workspace"),
.lease = state.acquire() orelse return error.TestUnexpectedResult,
.server_name = authentication_name,
.lease = authentication_lease,
.opener = host.unavailable_url_opener,
.done = .init(true),
.result = error.Cancelled,
@@ -3020,6 +3037,7 @@ test "authority reduction quiesces superseded tasks before detached runtime reti
state.lock.lockUncancelable(io_mod.getIo());
state.pending_authentication = authentication;
state.lock.unlock(io_mod.getIo());
authentication_lease_owned = false;
try state.beginAuthorityReduction(
alloc,
@@ -3032,13 +3050,6 @@ test "authority reduction quiesces superseded tasks before detached runtime reti
);
try std.testing.expect(state.acquire() == null);
const retirement_deadline = io_mod.milliTimestamp() + 2_000;
while (!runtime.retiring.load(.acquire) and
io_mod.milliTimestamp() < retirement_deadline)
{
io_mod.sleep(std.time.ns_per_ms);
}
try std.testing.expect(runtime.retiring.load(.acquire));
try std.testing.expect(state.takeReloadCompletion() == null);
active_lease.deinit();
+9 -1
View File
@@ -5255,7 +5255,15 @@ const TestApp = struct {
terminal_title_label_len: usize = 0,
input_runtime: core_input_runtime.Runtime = .{},
terminal_input_runtime: ui_input.Runtime = .{},
shell: transcript_runtime.TranscriptRuntime = .{},
shell: transcript_runtime.TranscriptRuntime = .{ .layout = .{
.rows = 24,
.cols = 80,
.content_bottom = 21,
.divider_top_row = 22,
.input_row = 23,
.divider_bottom_row = 24,
.hint_row = 22,
} },
metrics: types.Metrics = .{},
pending_images: std.ArrayList(types.ImageAttachment) = .empty,
next_image_id: usize = 1,
+65 -77
View File
@@ -35,64 +35,47 @@ const runtime_handle_type_tag = c.napi_type_tag{
};
const ReadyNotifier = struct {
function: c.napi_threadsafe_function,
pending: std.atomic.Value(bool) = .init(false),
reader: ?c_int,
writer: c_int,
fn init(env: c.napi_env, callback: c.napi_value) !*ReadyNotifier {
var callback_type: c.napi_valuetype = undefined;
if (c.napi_typeof(env, callback, &callback_type) != c.napi_ok or callback_type != c.napi_function) {
return error.InvalidReadyCallback;
}
var resource_name: c.napi_value = undefined;
if (c.napi_create_string_utf8(env, "libfx core ready", c.NAPI_AUTO_LENGTH, &resource_name) != c.napi_ok) {
return error.ReadyCallbackFailed;
}
const self = std.heap.c_allocator.create(ReadyNotifier) catch return error.ReadyCallbackFailed;
errdefer std.heap.c_allocator.destroy(self);
self.* = .{ .function = undefined };
// pending bounds this to one queued wake plus the callback currently running.
if (c.napi_create_threadsafe_function(
env,
callback,
null,
resource_name,
0,
1,
self,
finalize,
self,
deliver,
&self.function,
) != c.napi_ok) return error.ReadyCallbackFailed;
return self;
fn init() error{ReadyChannelFailed}!ReadyNotifier {
var pair: [2]c_int = undefined;
const flags = if (@import("builtin").os.tag == .macos) 0 else std.c.SOCK.CLOEXEC | std.c.SOCK.NONBLOCK;
const socket_type = std.c.SOCK.STREAM | flags;
if (std.c.socketpair(std.c.AF.UNIX, socket_type, 0, &pair) != 0) return error.ReadyChannelFailed;
errdefer for (pair) |fd| {
_ = std.c.close(fd);
};
// Darwin does not accept close-on-exec/nonblocking bits in socketpair's type.
if (flags == 0) for (pair) |fd| {
if (std.c.fcntl(fd, std.c.F.SETFD, @as(c_int, std.c.FD_CLOEXEC)) != 0 or
std.c.fcntl(fd, std.c.F.SETFL, @as(c_int, @bitCast(std.c.O{ .NONBLOCK = true }))) != 0)
return error.ReadyChannelFailed;
};
return .{ .reader = pair[0], .writer = pair[1] };
}
fn notify(self: *ReadyNotifier) void {
if (self.pending.swap(true, .acq_rel)) return;
if (c.napi_call_threadsafe_function(self.function, null, c.napi_tsfn_nonblocking) != c.napi_ok) {
self.pending.store(false, .release);
const byte: u8 = 1;
while (true) {
const written = std.c.send(self.writer, &byte, 1, std.c.MSG.NOSIGNAL);
if (written == 1) return;
switch (std.posix.errno(written)) {
.INTR => continue,
// A full socket already has a wake pending; queue contents are authoritative.
.AGAIN => return,
else => {
// Surface a broken notification channel as EOF instead of silently hanging.
_ = std.c.shutdown(self.writer, std.c.SHUT.WR);
return;
},
}
}
}
fn deliver(env: c.napi_env, callback: c.napi_value, context: ?*anyopaque, _: ?*anyopaque) callconv(.c) void {
// Node can discard queued calls after finalize has freed their context.
if (env == null or callback == null) return;
const self: *ReadyNotifier = @ptrCast(@alignCast(context.?));
// Disarm before draining: a producer can queue one more wake while this callback runs.
self.pending.store(false, .release);
var receiver: c.napi_value = undefined;
if (c.napi_get_undefined(env, &receiver) != c.napi_ok) return;
_ = c.napi_call_function(env, receiver, callback, 0, null, null);
}
fn finalize(_: c.napi_env, data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void {
// Teardown callbacks do not dereference this context after it is released.
const self: *ReadyNotifier = @ptrCast(@alignCast(data.?));
std.heap.c_allocator.destroy(self);
}
fn abort(self: *ReadyNotifier) void {
_ = c.napi_release_threadsafe_function(self.function, c.napi_tsfn_abort);
fn deinit(self: *ReadyNotifier) void {
if (self.reader) |fd| _ = std.c.close(fd);
_ = std.c.close(self.writer);
}
};
@@ -450,7 +433,7 @@ const Runtime = struct {
home: []u8,
workspace_root: []u8,
gateway_chat_url: []u8,
ready: *ReadyNotifier,
ready: ReadyNotifier,
thread: std.Thread,
exited: std.atomic.Value(bool) = .init(false),
exit_code: std.atomic.Value(u8) = .init(0),
@@ -473,11 +456,12 @@ const Runtime = struct {
.oauth_transport = oauth_transport.unavailable_provider,
.chat_url = builtin_gateway.provider.chat_url,
};
var gateway = builtin_gateway.provider_bundle;
gateway.agent_stream = host_stream_provider.provider(&self.stream_context);
gateway.model_catalog = null;
gateway.permission_reviewer = null;
const providers = provider_set.gateway_only(gateway);
const providers = provider_set.gateway_only(.{
.presentation = builtin_gateway.provider_bundle.presentation,
.auth_strategy = .vercel,
.fallback_model_capabilities_fn = builtin_gateway.provider_bundle.fallback_model_capabilities_fn,
.agent_stream = host_stream_provider.provider(&self.stream_context),
});
acp_server.runWithTransport(
self.alloc,
.{
@@ -529,7 +513,7 @@ const Runtime = struct {
self.closeInput();
self.fetch.shutdown();
self.thread.join();
self.ready.abort();
self.ready.deinit();
self.fetch.deinit();
self.input.deinit(self.alloc);
self.output.deinit(self.alloc);
@@ -611,11 +595,6 @@ fn throw(env: c.napi_env, code: [*:0]const u8, message: [*:0]const u8) c.napi_va
return null;
}
fn throwType(env: c.napi_env, code: [*:0]const u8, message: [*:0]const u8) c.napi_value {
_ = c.napi_throw_type_error(env, code, message);
return null;
}
fn statusOk(env: c.napi_env, status: c.napi_status, message: [*:0]const u8) bool {
if (status == c.napi_ok) return true;
_ = c.napi_throw_error(env, "LIBFX_NAPI", message);
@@ -679,9 +658,10 @@ const CreateError = error{
InvalidGatewayUrl,
OutOfMemory,
ThreadFailed,
ReadyChannelFailed,
};
fn createRuntime(env: c.napi_env, options: c.napi_value, ready: *ReadyNotifier) CreateError!*Runtime {
fn createRuntime(env: c.napi_env, options: c.napi_value) CreateError!*Runtime {
if (!claimRuntimeSlot()) return error.TooManyRuntimes;
errdefer releaseRuntimeSlot();
const alloc = std.heap.c_allocator;
@@ -724,6 +704,8 @@ fn createRuntime(env: c.napi_env, options: c.napi_value, ready: *ReadyNotifier)
const runtime = alloc.create(Runtime) catch return error.OutOfMemory;
errdefer alloc.destroy(runtime);
var ready = try ReadyNotifier.init();
errdefer ready.deinit();
runtime.* = .{
.alloc = alloc,
.credential = api_key,
@@ -734,8 +716,8 @@ fn createRuntime(env: c.napi_env, options: c.napi_value, ready: *ReadyNotifier)
.ready = ready,
.thread = undefined,
};
runtime.fetch.ready = runtime.ready;
runtime.output.ready = runtime.ready;
runtime.fetch.ready = &runtime.ready;
runtime.output.ready = &runtime.ready;
runtime.stream_context = host_stream_provider.initContext(builtin_gateway.buildAgentRequest, .{ .fixed = runtime.gateway_chat_url }, .{
.context = &runtime.fetch,
.open_fn = FetchBridge.open,
@@ -758,6 +740,7 @@ fn throwCreateError(env: c.napi_env, err: CreateError) c.napi_value {
error.InvalidGatewayUrl => throw(env, "LIBFX_INVALID_ARGUMENT", "gatewayChatUrl must be a bounded string"),
error.OutOfMemory => throw(env, "LIBFX_NATIVE_OOM", "could not allocate native runtime"),
error.ThreadFailed => throw(env, "LIBFX_NATIVE_THREAD", "could not start native runtime thread"),
error.ReadyChannelFailed => throw(env, "LIBFX_NATIVE_IO", "could not create native readiness channel"),
};
}
@@ -769,18 +752,9 @@ fn finalizeRuntimeHandle(env: c.napi_env, data: ?*anyopaque, _: ?*anyopaque) cal
}
fn createCore(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
var argv: [2]c.napi_value = undefined;
var argv: [1]c.napi_value = undefined;
if (!callbackArgs(env, info, &argv)) return null;
const ready = ReadyNotifier.init(env, argv[1]) catch |err| return switch (err) {
error.InvalidReadyCallback => throwType(env, "LIBFX_INVALID_ARGUMENT", "ready callback must be a function"),
error.ReadyCallbackFailed => throw(env, "LIBFX_NAPI", "could not create ready callback"),
};
var ready_owned = true;
defer if (ready_owned) ready.abort();
const runtime = createRuntime(env, argv[0], ready) catch |err| return throwCreateError(env, err);
ready_owned = false;
const runtime = createRuntime(env, argv[0]) catch |err| return throwCreateError(env, err);
var runtime_owned = true;
defer if (runtime_owned) runtime.deinit();
const handle = std.heap.c_allocator.create(RuntimeHandle) catch
@@ -847,6 +821,19 @@ fn unlockRuntime(handle: *RuntimeHandle) void {
handle.mutex.unlock(io_mod.getIo());
}
fn takeCoreReadyFd(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
var argv: [1]c.napi_value = undefined;
const handle = runtimeHandleArg(env, info, &argv) orelse return null;
const runtime = lockRuntime(env, handle) orelse return null;
defer unlockRuntime(handle);
const reader = runtime.ready.reader orelse
return throw(env, "LIBFX_INVALID_ARGUMENT", "readiness descriptor already transferred");
var value: c.napi_value = undefined;
if (!statusOk(env, c.napi_create_int32(env, reader, &value), "could not transfer readiness descriptor")) return null;
runtime.ready.reader = null;
return value;
}
fn fetch_handle_arg(env: c.napi_env, value: c.napi_value) ?fetch_state.Handle {
var number: f64 = 0;
if (c.napi_get_value_double(env, value, &number) != c.napi_ok or
@@ -1045,6 +1032,7 @@ export fn napi_register_module_v1(env: c.napi_env, exports: c.napi_value) callco
if (!statusOk(env, c.napi_create_uint32(env, 3, &api_version), "could not create API version")) return null;
if (!statusOk(env, c.napi_set_named_property(env, exports, "libfxApiVersion", api_version), "could not export API version")) return null;
if (!exportFunction(env, exports, "createCore", createCore)) return null;
if (!exportFunction(env, exports, "takeCoreReadyFd", takeCoreReadyFd)) return null;
if (!exportFunction(env, exports, "writeCore", writeCore)) return null;
if (!exportFunction(env, exports, "closeCore", closeCore)) return null;
if (!exportFunction(env, exports, "drainCore", drainCore)) return null;
+26 -2
View File
@@ -13221,7 +13221,15 @@ test "updateRawBytesEntry updates modal entry in place after intervening append"
test "tool status raw entry updates after command output appends" {
const alloc = std.testing.allocator;
var runtime = TranscriptRuntime{};
var runtime = TranscriptRuntime{ .layout = .{
.rows = 24,
.cols = 80,
.content_bottom = 21,
.divider_top_row = 22,
.input_row = 23,
.divider_bottom_row = 24,
.hint_row = 22,
} };
defer runtime.deinit(alloc);
var metrics = Metrics{};
@@ -13239,8 +13247,24 @@ test "tool status raw entry updates after command output appends" {
var source = try runtime.prepareTranscriptSource(alloc, null);
defer source.deinit(alloc);
try std.testing.expect(std.mem.indexOf(u8, source.bytes, "Ran npm test") != null);
try std.testing.expect(std.mem.indexOf(u8, source.bytes, "│ ok") != null);
try std.testing.expect(std.mem.indexOf(u8, source.bytes, "│ ok") == null);
try std.testing.expect(std.mem.indexOf(u8, source.bytes, "Running npm test") == null);
runtime.full_transcript.depth = .full;
var projection = try runtime.buildFullTranscriptProjection(alloc, null);
defer projection.deinit(alloc);
const full = try full_transcript_screen.renderProjectionViewportSourceInterruptible(
alloc,
&projection,
null,
runtime.layout.cols,
64,
0,
null,
);
defer alloc.free(full);
try std.testing.expect(std.mem.indexOf(u8, full, "│ ok") != null);
try std.testing.expect(std.mem.indexOf(u8, full, "Running npm test") == null);
}
test "advanceCursor row advance matches visualRowsForLine - 1 for wrap-exact content" {