httpapi-event-diagnostics.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. // Diagnostic suite for /event SSE delivery.
  2. //
  3. // Each test isolates ONE variable in the publisher chain while keeping the
  4. // subscriber path constant (raw `app().request` reading the SSE body — no SDK
  5. // consumer involvement). The pass/fail pattern across tests tells us where the
  6. // bug lives:
  7. //
  8. // D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
  9. // existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
  10. // works for a SOME publish path.
  11. //
  12. // D2: publish N times in quick succession via Bus.Service.use. If the bus
  13. // subscription is acquired correctly there should be no message loss.
  14. //
  15. // D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
  16. // the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
  17. // without the HTTP roundtrip. Tells us whether the sync path itself can
  18. // deliver in-process.
  19. //
  20. // D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
  21. // (mimicking what happens if a handler's layer was scoped per-request).
  22. //
  23. // D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
  24. // receive the same publish. If both receive: no bug. If only the
  25. // callback receives: the /event handler has an acquisition race.
  26. import { afterEach, describe, expect } from "bun:test"
  27. import { Deferred, Effect, Schema } from "effect"
  28. import * as Log from "@opencode-ai/core/util/log"
  29. import { Bus } from "../../src/bus"
  30. import { type AppServices, AppRuntime } from "../../src/effect/app-runtime"
  31. import { InstanceRef } from "../../src/effect/instance-ref"
  32. import { Server } from "../../src/server/server"
  33. import { Event as ServerEvent } from "../../src/server/event"
  34. import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
  35. import { MessageV2 } from "../../src/session/message-v2"
  36. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  37. import { SyncEvent } from "../../src/sync"
  38. import { resetDatabase } from "../fixture/db"
  39. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  40. import { it } from "../lib/effect"
  41. void Log.init({ print: false })
  42. const EventData = Schema.Struct({
  43. id: Schema.optional(Schema.String),
  44. type: Schema.String,
  45. properties: Schema.Record(Schema.String, Schema.Any),
  46. })
  47. type SseEvent = Schema.Schema.Type<typeof EventData>
  48. type BusEvent = { type: string; properties: unknown }
  49. afterEach(async () => {
  50. await disposeAllInstances()
  51. await resetDatabase()
  52. })
  53. const inApp = <A, E>(eff: Effect.Effect<A, E, AppServices>) =>
  54. Effect.gen(function* () {
  55. const ctx = yield* InstanceRef
  56. if (!ctx) return yield* Effect.die("InstanceRef not provided in test scope")
  57. return yield* Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
  58. })
  59. const publishConnected = inApp(Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})))
  60. const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
  61. const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
  62. return inApp(
  63. SyncEvent.use.run(MessageV2.Event.PartUpdated, {
  64. sessionID,
  65. part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
  66. time: Date.now(),
  67. }),
  68. )
  69. }
  70. const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
  71. Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
  72. Effect.sync(dispose),
  73. )
  74. const openEventStream = (directory: string) =>
  75. Effect.gen(function* () {
  76. const response = yield* Effect.promise(async () =>
  77. Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
  78. )
  79. if (!response.body) return yield* Effect.die("missing SSE response body")
  80. const reader = response.body.getReader()
  81. yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
  82. return reader
  83. })
  84. const decoder = new TextDecoder()
  85. function decodeFrame(value: Uint8Array): SseEvent[] {
  86. return decoder
  87. .decode(value)
  88. .split(/\n\n+/)
  89. .map((part) => part.trim())
  90. .filter((part) => part.length > 0)
  91. .map((part) => Schema.decodeUnknownSync(EventData)(JSON.parse(part.replace(/^data: /, ""))))
  92. }
  93. const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
  94. Effect.promise(() => reader.read()).pipe(
  95. Effect.timeoutOrElse({
  96. duration: "3 seconds",
  97. orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
  98. }),
  99. Effect.flatMap((result) => {
  100. if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
  101. const frames = decodeFrame(result.value)
  102. if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
  103. return Effect.succeed(frames[0])
  104. }),
  105. )
  106. const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
  107. Effect.gen(function* () {
  108. const events: SseEvent[] = []
  109. while (true) {
  110. const event = yield* readNextEvent(reader)
  111. events.push(event)
  112. if (predicate(event)) return events
  113. }
  114. }).pipe(
  115. Effect.timeoutOrElse({
  116. duration: "4 seconds",
  117. orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
  118. }),
  119. )
  120. const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
  121. describe("/event SSE delivery diagnostics", () => {
  122. // Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
  123. // but explicit about timing — publish happens with NO wait after reading
  124. // server.connected. If this fails we have a deeper problem than just sync.
  125. it.instance(
  126. "D1: delivers a single bus event published right after server.connected",
  127. () =>
  128. Effect.gen(function* () {
  129. const { directory } = yield* TestInstance
  130. const reader = yield* openEventStream(directory)
  131. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  132. yield* publishConnected
  133. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  134. }),
  135. { git: true, config: { formatter: false, lsp: false } },
  136. )
  137. // If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
  138. it.instance(
  139. "D2: delivers all N bus events published in rapid succession",
  140. () =>
  141. Effect.gen(function* () {
  142. const { directory } = yield* TestInstance
  143. const reader = yield* openEventStream(directory)
  144. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  145. const N = 5
  146. yield* Effect.replicateEffect(publishConnected, N)
  147. const received = yield* Effect.replicateEffect(readNextEvent(reader), N)
  148. expect(received).toHaveLength(N)
  149. for (const event of received) expect(event.type).toBe("server.connected")
  150. }),
  151. { git: true, config: { formatter: false, lsp: false } },
  152. )
  153. // The critical test. If D1 passes but this fails, the bus-identity fix is
  154. // incomplete OR the sync.run publish path doesn't reach the same bus
  155. // /event subscribes to, even within the same AppRuntime.
  156. it.instance(
  157. "D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
  158. () =>
  159. Effect.gen(function* () {
  160. const { directory } = yield* TestInstance
  161. const reader = yield* openEventStream(directory)
  162. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  163. const partID = PartID.ascending()
  164. yield* publishPartUpdated(partID)
  165. const collected = yield* collectUntilEvent(reader, isPartUpdated)
  166. const updated = collected.find(isPartUpdated)
  167. expect(updated?.properties.part.id).toBe(partID)
  168. }),
  169. { git: true, config: { formatter: false, lsp: false } },
  170. )
  171. // If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
  172. // bug is specifically in the cross-request / cross-fiber HTTP path, not in
  173. // the publish itself. If D3 also fails, the publish chain is broken.
  174. //
  175. // D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
  176. // pub/sub identity end-to-end without involving /event SSE.
  177. it.instance(
  178. "D4: SyncEvent.use.run publish reaches an in-process Bus.Service.use callback",
  179. () =>
  180. Effect.gen(function* () {
  181. const received = yield* Deferred.make<BusEvent>()
  182. yield* subscribeAllCallback((event) => {
  183. if (isPartUpdated(event)) Deferred.doneUnsafe(received, Effect.succeed(event))
  184. })
  185. const partID = PartID.ascending()
  186. yield* publishPartUpdated(partID)
  187. const event = yield* Deferred.await(received).pipe(
  188. Effect.timeoutOrElse({
  189. duration: "3 seconds",
  190. orElse: () => Effect.fail(new Error("D4 timed out waiting for callback")),
  191. }),
  192. )
  193. expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
  194. expect(event.properties).toMatchObject({ part: { id: partID } })
  195. }),
  196. { git: true, config: { formatter: false, lsp: false } },
  197. )
  198. // D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
  199. // SyncEvent.use.run. Both subscribers should receive it. If only one does
  200. // we know exactly which side of the chain is failing.
  201. it.instance(
  202. "D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback",
  203. () =>
  204. Effect.gen(function* () {
  205. const { directory } = yield* TestInstance
  206. const callbackReceived = yield* Deferred.make<BusEvent>()
  207. yield* subscribeAllCallback((event) => {
  208. if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
  209. })
  210. const reader = yield* openEventStream(directory)
  211. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  212. const partID = PartID.ascending()
  213. yield* publishPartUpdated(partID)
  214. const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
  215. Effect.map((events) => events.some(isPartUpdated)),
  216. Effect.catch(() => Effect.succeed(false)),
  217. )
  218. const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
  219. Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
  220. Effect.map((event) => event !== undefined),
  221. )
  222. // Single assert with the boolean pair so the failure message tells us
  223. // exactly which side broke.
  224. expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
  225. }),
  226. { git: true, config: { formatter: false, lsp: false } },
  227. )
  228. // D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
  229. // call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
  230. // is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
  231. // running through AppRuntime concurrently with /event SSE breaks delivery.
  232. it.instance(
  233. "D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity",
  234. () =>
  235. Effect.gen(function* () {
  236. const { directory } = yield* TestInstance
  237. yield* inApp(Effect.void)
  238. const reader = yield* openEventStream(directory)
  239. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  240. const partID = PartID.ascending()
  241. yield* publishPartUpdated(partID)
  242. const collected = yield* collectUntilEvent(reader, isPartUpdated)
  243. expect(collected.find(isPartUpdated)).toBeDefined()
  244. }),
  245. { git: true, config: { formatter: false, lsp: false } },
  246. )
  247. // D6: same as D5 but the callback subscriber is attached AFTER /event SSE
  248. // subscription is established. If D5 fails and D6 passes, the order of
  249. // subscriber setup is the determining factor.
  250. it.instance(
  251. "D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens",
  252. () =>
  253. Effect.gen(function* () {
  254. const { directory } = yield* TestInstance
  255. const reader = yield* openEventStream(directory)
  256. expect((yield* readNextEvent(reader)).type).toBe("server.connected")
  257. const callbackReceived = yield* Deferred.make<BusEvent>()
  258. yield* subscribeAllCallback((event) => {
  259. if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
  260. })
  261. const partID = PartID.ascending()
  262. yield* publishPartUpdated(partID)
  263. const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
  264. Effect.map((events) => events.some(isPartUpdated)),
  265. Effect.catch(() => Effect.succeed(false)),
  266. )
  267. const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
  268. Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
  269. Effect.map((event) => event !== undefined),
  270. )
  271. expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
  272. }),
  273. { git: true, config: { formatter: false, lsp: false } },
  274. )
  275. })