@@ -271,3 +271,186 @@ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/t
271271 it.instance(
272272 "cancel propagates from running task tool to child session",
273273 () =>
274+ @@ -1682,6 +1682,9 @@
275+ const childID = SessionID.make(sessionID)
276+ expect((yield* status.get(childID)).type).toBe("busy")
277+
278+ + // #173:并发双 cancel——fork 的 cancel 与串行 cancel 竞争同一 per-session 锁,验证排队不丢弃
279+ + const cancel = yield* prompt.cancel(chat.id).pipe(Effect.forkChild)
280+ yield* prompt.cancel(chat.id)
281+ + yield* Fiber.await(cancel)
282+ const exit = yield* Fiber.await(fiber)
283+ expect(Exit.isSuccess(exit)).toBe(true)
284+ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
285+ index fd4cb74..0510167 100644
286+ --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
287+ +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
288+ @@ -5,21 +5,16 @@ import { EventV2 } from "@opencode-ai/core/event"
289+ import { Installation } from "@/installation"
290+ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
291+ import { InstallationVersion } from "@opencode-ai/core/installation/version"
292+ - import { Effect, Queue, Schema } from "effect"
293+ - import * as Stream from "effect/Stream"
294+ + import { Effect, Schema, Stream } from "effect"
295+ import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
296+ import { HttpApiBuilder } from "effect/unstable/httpapi"
297+ - import * as Sse from "effect/unstable/encoding/Sse"
298+ import { RootHttpApi } from "../api"
299+ + import { PassThrough, Readable } from "node:stream"
300+ import { GlobalUpgradeInput } from "../groups/global"
301+
302+ - function eventData(data: unknown): Sse.Event {
303+ - return {
304+ - _tag: "Event",
305+ - event: "message",
306+ - id: undefined,
307+ - data: JSON.stringify(data),
308+ - }
309+ + function eventData(data: unknown) {
310+ + // SSE 帧格式:data: 前缀 + 空行结尾,替代 v3 时代的 Sse.encode 通道
311+ + return `data: ${JSON.stringify(data)}\n\n`
312+ }
313+
314+ function parseBody(body: string) {
315+ @@ -38,48 +33,84 @@ export function onSseClientCountChange(cb: (n: number) => void) {
316+ onSseCountChange = cb
317+ }
318+
319+ - function eventResponse() {
320+ - return Effect.gen(function* () {
321+ - // SSE 客户端连接计数 +1,通知守护进程空闲计时器
322+ - sseClientCount++
323+ + type AbortSource = {
324+ + once: (event: "aborted", listener: () => void) => unknown
325+ + off: (event: "aborted", listener: () => void) => unknown
326+ + }
327+ +
328+ + function abortSource(source: object): AbortSource | undefined {
329+ + // 兼容 Web Request 与 Node IncomingMessage 两种 source 形态,双路保证 abort 可达
330+ + const candidate = source as { once?: unknown; off?: unknown }
331+ + if (typeof candidate.once !== "function" || typeof candidate.off !== "function") return
332+ + return candidate as AbortSource
333+ + }
334+ +
335+ + function onRequestAbort(request: HttpServerRequest.HttpServerRequest, close: () => void) {
336+ + if (request.source instanceof Request) {
337+ + // Web Request 路径:signal abort 即客户端断连,once 防止 close 重复进入
338+ + request.source.signal.addEventListener("abort", close, { once: true })
339+ + return () => request.source instanceof Request && request.source.signal.removeEventListener("abort", close)
340+ + }
341+ +
342+ + const source = abortSource(request.source)
343+ + if (!source) return () => {}
344+ + source.once("aborted", close)
345+ + return () => source.off("aborted", close)
346+ + }
347+ + function eventResponse(request: HttpServerRequest.HttpServerRequest) {
348+ + // SSE 客户端连接计数 +1,通知守护进程空闲计时器
349+ + sseClientCount++
350+ + onSseCountChange?.(sseClientCount)
351+ + const stream = new PassThrough()
352+ + const write = (event: GlobalBusEvent) => {
353+ + // abort 竞态下 close 可能已跑但 GlobalBus 仍派发,此时静默丢弃写入
354+ + if (stream.destroyed) return
355+ + stream.write(eventData(event))
356+ + }
357+ + const handler = (event: GlobalBusEvent) => write(event)
358+ + const heartbeat = setInterval(
359+ + () => write({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } }),
360+ + 10_000,
361+ + )
362+ + let closed = false
363+ + let unsubscribeRequestAbort = () => {}
364+ + // #173:close 确定性清理——客户端中断不保证触发 Stream finalizer
365+ + const close = () => {
366+ + if (closed) return
367+ + closed = true
368+ + clearInterval(heartbeat)
369+ + // 清理责任全在此处:停止心跳、退订总线、解除 abort 监听、收流,全部确定性执行
370+ + GlobalBus.off("event", handler)
371+ + unsubscribeRequestAbort()
372+ + if (!stream.destroyed) stream.end()
373+ + // 下限保护:close 幂等触发(abort 与 stream close 双路)时不得把计数扣成负数
374+ + sseClientCount = Math.max(0, sseClientCount - 1)
375+ onSseCountChange?.(sseClientCount)
376+ - yield* Effect.logInfo("global event connected")
377+ - const events = Stream.callback<GlobalBusEvent>((queue) => {
378+ - const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
379+ - return Effect.acquireRelease(
380+ - Effect.sync(() => GlobalBus.on("event", handler)),
381+ - () => Effect.sync(() => GlobalBus.off("event", handler)),
382+ - )
383+ - })
384+ - const heartbeat = Stream.tick("10 seconds").pipe(
385+ - Stream.drop(1),
386+ - Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
387+ - )
388+ -
389+ - return HttpServerResponse.stream(
390+ - Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
391+ - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
392+ - Stream.map(eventData),
393+ - Stream.pipeThroughChannel(Sse.encode()),
394+ - Stream.encodeText,
395+ - Stream.ensuring(Effect.logInfo("global event disconnected")),
396+ - // SSE 客户端断开时计数 -1,通知守护进程可能需要启动空闲计时器
397+ - Stream.ensuring(
398+ - Effect.sync(() => {
399+ - sseClientCount--
400+ - onSseCountChange?.(sseClientCount)
401+ - }),
402+ - ),
403+ - ),
404+ - {
405+ - contentType: "text/event-stream",
406+ - headers: {
407+ - "Cache-Control": "no-cache, no-transform",
408+ - "X-Accel-Buffering": "no",
409+ - "X-Content-Type-Options": "nosniff",
410+ - },
411+ - },
412+ - )
413+ + }
414+ +
415+ + // unref 让心跳计时器不阻止守护进程自然退出(idle 退出依赖进程无强引用)
416+ + heartbeat.unref?.()
417+ + // 与 source 同位单次订阅;先于 connected 写入,避免订阅空窗期丢失首帧事件
418+ + GlobalBus.on("event", handler)
419+ + unsubscribeRequestAbort = onRequestAbort(request, close)
420+ + // 断连侧兜底:close 幂等(closed 标志)保证 abort 与流关闭双触发只清理一次
421+ + stream.once("close", close)
422+ + stream.once("error", close)
423+ + write({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } })
424+ +
425+ + // 目标 v4 合同(httpapi AGENTS)要求 SSE 返回 HttpServerResponse.stream;PassThrough 经 toWeb 桥接,
426+ + // 终止仍由 close() 确定性拥有(abort/断连双路触发),不回退到不可靠的 Stream finalizer
427+ + const body = Stream.fromReadableStream({
428+ + evaluate: () => Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>,
429+ + onError: (cause) => new Error(`sse body error: ${String(cause)}`),
430+ + })
431+ + return HttpServerResponse.stream(body, {
432+ + contentType: "text/event-stream",
433+ + headers: {
434+ + "Cache-Control": "no-cache, no-transform",
435+ + "X-Accel-Buffering": "no",
436+ + "X-Content-Type-Options": "nosniff",
437+ + },
438+ })
439+ }
440+
441+ @@ -94,7 +125,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
442+ })
443+
444+ const event = Effect.fn("GlobalHttpApi.event")(function* () {
445+ - return yield* eventResponse()
446+ + return eventResponse(yield* HttpServerRequest.HttpServerRequest)
447+ })
448+
449+ const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
450+ @@ -171,4 +202,4 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
451+ .handle("dispose", dispose)
452+ .handleRaw("upgrade", upgradeRaw)
453+ }),
454+ - )
455+ + )
456+ \ No newline at end of file
0 commit comments