From aafdd8688908916062935884eda7c8041ec57b38 Mon Sep 17 00:00:00 2001 From: kitlangton <7587245+kitlangton@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:59:58 +0000 Subject: [PATCH] fix(sdk): release completed embedded request signals --- packages/sdk/src/internal/fetch.ts | 11 +++- packages/sdk/test/request-retention.test.ts | 63 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/test/request-retention.test.ts diff --git a/packages/sdk/src/internal/fetch.ts b/packages/sdk/src/internal/fetch.ts index 5fab62ff822d..502805b9a623 100644 --- a/packages/sdk/src/internal/fetch.ts +++ b/packages/sdk/src/internal/fetch.ts @@ -10,9 +10,18 @@ export function make(handler: (request: Request) => Promise, dispose: if (closePromise) return Promise.reject(closed) const source = new Request(input, init) if (source.signal.aborted) return Promise.reject(source.signal.reason) - const request = new Request(source, { signal: AbortSignal.any([source.signal, shutdown.signal]) }) + // Detach both signals when the response finishes: a long-lived shutdown signal + // can otherwise retain completed embedded Web requests through AbortSignal.any. + const controller = new AbortController() + const abortSource = () => controller.abort(source.signal.reason) + const abortShutdown = () => controller.abort(shutdown.signal.reason) + source.signal.addEventListener("abort", abortSource, { once: true }) + shutdown.signal.addEventListener("abort", abortShutdown, { once: true }) + const request = new Request(source, { signal: controller.signal }) const lifetime = Promise.withResolvers() const finish = () => { + source.signal.removeEventListener("abort", abortSource) + shutdown.signal.removeEventListener("abort", abortShutdown) requests.delete(lifetime.promise) lifetime.resolve() } diff --git a/packages/sdk/test/request-retention.test.ts b/packages/sdk/test/request-retention.test.ts new file mode 100644 index 000000000000..a963c085bce9 --- /dev/null +++ b/packages/sdk/test/request-retention.test.ts @@ -0,0 +1,63 @@ +import { expect } from "bun:test" +import { fullGC, heapStats } from "bun:jsc" +import { Effect, Layer } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { testEffect } from "../../core/test/lib/effect" +import { OpenCode } from "../src/effect" +import { OwnedFetch } from "../src/internal/fetch" + +testEffect(Layer.empty).live("releases completed embedded requests while the host stays open", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const client = yield* OpenCode.create({ + app: { version: "request-retention-test" }, + config: { directory: directory.path, project: false, content: "{}" }, + events: { persist: true }, + models: { fetch: false }, + fs: { filewatcher: false }, + }) + + fullGC() + const before = heapStats().objectTypeCounts.Request ?? 0 + for (let index = 0; index < 200; index++) { + expect((yield* client.server.info()).version).toBe("request-retention-test") + } + + // Completed response cleanup crosses an event-loop turn in the embedded transport. + yield* Effect.promise(() => Bun.sleep(100)) + fullGC() + expect((heapStats().objectTypeCounts.Request ?? 0) - before).toBeLessThan(20) + }), +) + +testEffect(Layer.empty).live("keeps caller abort and host shutdown wired to active requests", () => + Effect.promise(async () => { + const seen: unknown[] = [] + const transport = OwnedFetch.make( + async (request) => { + await new Promise((resolve) => { + request.signal.addEventListener( + "abort", + () => { + seen.push(request.signal.reason) + resolve() + }, + { once: true }, + ) + }) + return new Response("aborted") + }, + async () => {}, + ) + const controller = new AbortController() + const first = transport.fetch("http://opencode.local/", { signal: controller.signal }) + controller.abort("caller") + expect(await first.catch((error: unknown) => error)).toBe("caller") + + const second = transport.fetch("http://opencode.local/") + const closed = transport.close() + expect(await second.catch((error: unknown) => error)).toBeInstanceOf(Error) + await closed + expect(seen).toEqual(["caller", expect.any(Error)]) + }), +)