Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions packages/iroh-http-deno/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@ function createYieldFn(): {
// the -1 shutdown sentinel without a timing-dependent delay.
const serveCancellers = new Map<number, () => void>();

// `serveStart` crosses the async JSON dispatch bridge. Keep its registration
// promise so an immediate self-fetch or stop cannot overtake native setup.
const serveStartOps = new Map<number, Promise<Record<never, never>>>();

async function waitForServeStart(endpointHandle: number): Promise<void> {
await serveStartOps.get(endpointHandle)?.catch(() => {});
}

async function waitForServeStop(endpointHandle: number): Promise<void> {
await serveStopOps.get(endpointHandle);
}

// #245: The `stopServe` FFI call is a non-blocking op (`iroh_http_call`).
// If it is left floating it can still be in flight when a test's `sanitizeOps`
// snapshot is taken, which Deno's leak sanitizer reports as a leaked op. This
Expand Down Expand Up @@ -649,6 +661,11 @@ export const rawFetch: RawFetchFn = async (
directAddrs: string[] | null,
fetchOptions?: FetchOptions,
) => {
// A serve call made immediately before this fetch may still be registering
// its in-process service on the Rust side. Preserve call order across the
// async FFI bridge so self-fetch observes the server that JS just started.
await waitForServeStart(endpointHandle);

// #126: Callback-based fetch — Rust notifies via UnsafeCallback when done,
// then we call poll_fetch once to retrieve the result. This avoids both
// spin-polling (which blocks the JS event loop, starving same-process
Expand Down Expand Up @@ -795,10 +812,24 @@ export const rawServe: RawServeFn = (
): Promise<void> => {
// FFI handle as BigInt — endpoint handles are u64 on the Rust side.
const eh = BigInt(endpointHandle);
return call<Record<never, never>>("serveStart", {
const serveStart = call<Record<never, never>>("serveStart", {
endpointHandle,
...options.serveOptions,
}).then(
});
serveStartOps.set(endpointHandle, serveStart);
void serveStart.then(
() => {
if (serveStartOps.get(endpointHandle) === serveStart) {
serveStartOps.delete(endpointHandle);
}
},
() => {
if (serveStartOps.get(endpointHandle) === serveStart) {
serveStartOps.delete(endpointHandle);
}
},
);
return serveStart.then(
() => {
// Start connection event polling loop if a callback was supplied.
if (options.onConnectionEvent) {
Expand Down Expand Up @@ -916,7 +947,11 @@ export const rawServe: RawServeFn = (
}
})();
},
).catch((err) => {
).catch(async (err) => {
// A stop requested while registration was failing still owns an async FFI
// op. Drain it even though the polling loop (and its finally block) never
// existed, so `finished` cannot outlive a Deno op-sanitizer boundary.
await waitForServeStop(endpointHandle);
// close_all / closeEndpoint can win the race before serveStart returns.
if (isEndpointGoneError(err)) return;
throw err;
Expand Down Expand Up @@ -1012,6 +1047,8 @@ export async function closeEndpoint(
handle: number,
force?: boolean,
): Promise<void> {
await waitForServeStart(handle);
await waitForServeStop(handle);
await call<Record<never, never>>("closeEndpoint", {
endpointHandle: handle,
force: force ?? null,
Expand All @@ -1025,8 +1062,12 @@ export function stopServe(handle: number): void {
// #245: Register the in-flight op so the serve loop can await it before
// resolving `finished`; otherwise this non-blocking FFI op can outlive the
// test boundary and trip Deno's `sanitizeOps` leak detector under load.
const op = call<Record<never, never>>("stopServe", { endpointHandle: handle })
.catch(() => {});
const op = (async () => {
// Do not let stop overtake an in-flight serve registration. This also
// guarantees the polling loop has installed its cancellation hook first.
await waitForServeStart(handle);
await call<Record<never, never>>("stopServe", { endpointHandle: handle });
})().catch(() => {});
serveStopOps.set(handle, op);
void op.finally(() => {
if (serveStopOps.get(handle) === op) serveStopOps.delete(handle);
Expand Down Expand Up @@ -1394,6 +1435,8 @@ export class DenoAdapter extends IrohAdapter {
// ── Lifecycle ───────────────────────────────────────────────────────────────

async closeEndpoint(handle: number, force?: boolean): Promise<void> {
await waitForServeStart(handle);
await waitForServeStop(handle);
await call<Record<never, never>>("closeEndpoint", {
endpointHandle: handle,
force: force ?? null,
Expand Down
2 changes: 1 addition & 1 deletion tests/suites/stress.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export function stressTests({ createNode, test, assert, assertEqual }) {
await handle.finished;
}

assertEqual(errors, [], `stale-handle errors: ${errors.join(" | ")}`);
assertEqual(errors.length, 0, `stale-handle errors: ${errors.join(" | ")}`);
} finally {
console.error = originalError;
await server.close();
Expand Down
Loading