Skip to content

Commit b79e543

Browse files
test(server): prove Node HTTP abort releases EventFeed subscribers
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 49e7e02 commit b79e543

2 files changed

Lines changed: 88 additions & 76 deletions

File tree

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { NodeHttpServerRequest } from "@effect/platform-node"
22
import { EventV2 } from "@opencode-ai/core/event"
3+
import { EventSubscribeQuery } from "@opencode-ai/protocol/groups/event"
34
import { Effect, Stream } from "effect"
45
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
56
import { HttpApiBuilder } from "effect/unstable/httpapi"
67
import { Api } from "../api"
78
import { EventFeed } from "../event-feed"
89

910
/** Bun may emit IncomingMessage aborted/close without ServerResponse.close — destroy so the fiber ends. */
10-
function bridgeClientDisconnect(request: HttpServerRequest.HttpServerRequest) {
11+
export function bridgeClientDisconnect(request: HttpServerRequest.HttpServerRequest) {
1112
return Effect.sync(() => {
1213
try {
1314
const incoming = NodeHttpServerRequest.toIncomingMessage(request)
@@ -27,37 +28,43 @@ function bridgeClientDisconnect(request: HttpServerRequest.HttpServerRequest) {
2728
})
2829
}
2930

31+
/** Shared subscribe response for the production handler and ownership integration tests. */
32+
export const subscribeResponse = (
33+
feed: EventFeed.Interface,
34+
request: HttpServerRequest.HttpServerRequest,
35+
query: typeof EventSubscribeQuery.Type,
36+
) =>
37+
Effect.gen(function* () {
38+
yield* bridgeClientDisconnect(request)
39+
// handleRaw still Schema-decodes query; map into feed interest without re-parsing the URL.
40+
const interest = EventFeed.interestFromSubscribeQuery(query)
41+
const connected = {
42+
id: EventV2.ID.create(),
43+
type: "server.connected",
44+
data: {},
45+
} as const
46+
const output = Stream.unwrap(
47+
feed
48+
.subscribe(interest)
49+
.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))),
50+
)
51+
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
52+
return HttpServerResponse.stream(
53+
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
54+
{
55+
contentType: "text/event-stream",
56+
headers: {
57+
"Cache-Control": "no-cache, no-transform",
58+
"X-Accel-Buffering": "no",
59+
"X-Content-Type-Options": "nosniff",
60+
},
61+
},
62+
)
63+
})
64+
3065
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
3166
Effect.gen(function* () {
3267
const feed = yield* EventFeed.Service
33-
return handlers.handleRaw("event.subscribe", (ctx) =>
34-
Effect.gen(function* () {
35-
yield* bridgeClientDisconnect(ctx.request)
36-
// handleRaw still Schema-decodes query; map into feed interest without re-parsing the URL.
37-
const interest = EventFeed.interestFromSubscribeQuery(ctx.query)
38-
const connected = {
39-
id: EventV2.ID.create(),
40-
type: "server.connected",
41-
data: {},
42-
} as const
43-
const output = Stream.unwrap(
44-
feed
45-
.subscribe(interest)
46-
.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))),
47-
)
48-
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
49-
return HttpServerResponse.stream(
50-
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
51-
{
52-
contentType: "text/event-stream",
53-
headers: {
54-
"Cache-Control": "no-cache, no-transform",
55-
"X-Accel-Buffering": "no",
56-
"X-Content-Type-Options": "nosniff",
57-
},
58-
},
59-
)
60-
}),
61-
)
68+
return handlers.handleRaw("event.subscribe", (ctx) => subscribeResponse(feed, ctx.request, ctx.query))
6269
}),
6370
)

packages/server/test/event-feed-ownership.test.ts

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { describe, expect, test } from "bun:test"
1+
import { NodeHttpServer } from "@effect/platform-node"
22
import { EventV2 } from "@opencode-ai/core/event"
3-
import { Effect, Fiber, Stream } from "effect"
3+
import { describe, expect } from "bun:test"
4+
import { Context, Effect, Fiber, Layer, Stream } from "effect"
5+
import { HttpServer, HttpServerRequest } from "effect/unstable/http"
6+
import { createServer } from "node:http"
47
import { it } from "../../core/test/lib/effect"
58
import { EventFeed } from "../src/event-feed"
9+
import { subscribeResponse } from "../src/handlers/event"
610

711
function makeSource() {
812
let subscriber: EventV2.Subscriber | undefined
@@ -18,6 +22,14 @@ function makeSource() {
1822
}
1923
}
2024

25+
async function wait(fn: () => boolean, timeout = 3000) {
26+
const start = Date.now()
27+
while (!fn()) {
28+
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
29+
await Bun.sleep(10)
30+
}
31+
}
32+
2133
describe("EventFeed ownership", () => {
2234
it.effect("canceling a subscriber scope returns diagnostics.active to baseline", () =>
2335
Effect.gen(function* () {
@@ -41,51 +53,44 @@ describe("EventFeed ownership", () => {
4153
}),
4254
)
4355

44-
test("real network abort closes the response body stream", async () => {
45-
let closed = false
46-
const server = Bun.serve({
47-
port: 0,
48-
fetch(request) {
49-
let current: ReadableStreamDefaultController<Uint8Array> | undefined
50-
const stream = new ReadableStream<Uint8Array>({
51-
start(controller) {
52-
current = controller
53-
controller.enqueue(new TextEncoder().encode('data: {"type":"server.connected","data":{}}\n\n'))
54-
},
55-
cancel() {
56-
closed = true
57-
},
58-
})
59-
request.signal.addEventListener(
60-
"abort",
61-
() => {
62-
closed = true
63-
try {
64-
current?.close()
65-
} catch {
66-
// already closed by the client disconnect
67-
}
68-
},
69-
{ once: true },
70-
)
71-
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
72-
},
73-
})
56+
it.live(
57+
"aborting a real Node HTTP client returns EventFeed.active to baseline",
58+
() =>
59+
Effect.gen(function* () {
60+
const source = makeSource()
61+
const feed = yield* EventFeed.make(source.observe)
62+
const baseline = feed.diagnostics().active
63+
64+
const context = yield* Layer.build(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 0 }))
65+
const server = Context.get(context, HttpServer.HttpServer)
66+
yield* server
67+
.serve(HttpServerRequest.HttpServerRequest.use((request) => subscribeResponse(feed, request, {})))
68+
.pipe(Effect.forkScoped)
7469

75-
try {
76-
const controller = new AbortController()
77-
const response = await fetch(`http://127.0.0.1:${server.port}/api/event`, { signal: controller.signal })
78-
expect(response.ok).toBe(true)
79-
const reader = response.body!.getReader()
80-
await reader.read()
81-
controller.abort()
82-
await reader.closed.catch(() => undefined)
83-
await Bun.sleep(50)
84-
expect(closed).toBe(true)
85-
} finally {
86-
await server.stop(true)
87-
}
88-
})
70+
const base = HttpServer.formatAddress(server.address)
71+
const controller = new AbortController()
72+
const response = yield* Effect.promise(() => fetch(`${base}/api/event`, { signal: controller.signal }))
73+
expect(response.ok).toBe(true)
74+
expect(response.body).toBeDefined()
75+
const reader = response.body?.getReader()
76+
expect(reader).toBeDefined()
77+
if (!reader) throw new Error("expected event stream body reader")
78+
79+
yield* Effect.promise(() => reader.read())
80+
expect(feed.diagnostics().active).toBe(baseline + 1)
81+
82+
controller.abort()
83+
// Bun may never settle `reader.closed` after abort; cancel and watch feed release.
84+
yield* Effect.promise(() => reader.cancel().catch(() => undefined))
85+
yield* Effect.promise(() => wait(() => feed.diagnostics().active === baseline))
86+
87+
expect(feed.diagnostics().active).toBe(baseline)
88+
expect(feed.diagnostics().closes).toBeGreaterThanOrEqual(1)
89+
const dump = JSON.stringify(feed.diagnostics())
90+
expect(dump.includes("payload")).toBe(false)
91+
}),
92+
15_000,
93+
)
8994

9095
it.effect("eleven sequential subscribe scopes never accumulate active subscribers", () =>
9196
Effect.gen(function* () {

0 commit comments

Comments
 (0)