Compare commits

...
Author SHA1 Message Date
kitlangton aafdd86889 fix(sdk): release completed embedded request signals 2026-10-04 04:59:58 +00:00
2 changed files with 73 additions and 1 deletions

No files matched your search

+10 -1
View File
@@ -10,9 +10,18 @@ export function make(handler: (request: Request) => Promise<Response>, 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<void>()
const finish = () => {
source.signal.removeEventListener("abort", abortSource)
shutdown.signal.removeEventListener("abort", abortShutdown)
requests.delete(lifetime.promise)
lifetime.resolve()
}
@@ -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<void>((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)])
}),
)