test(bus): fix flaky subscriber races with readiness latch (#27625)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
import { Deferred, Effect, Fiber, Latch, Layer, Schema, Stream } from "effect"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { BusEvent } from "../../src/bus/bus-event"
|
import { BusEvent } from "../../src/bus/bus-event"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
@@ -9,6 +9,7 @@ import { testEffect } from "../lib/effect"
|
|||||||
const TestEvent = {
|
const TestEvent = {
|
||||||
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
|
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
|
||||||
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
|
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
|
||||||
|
Warmup: BusEvent.define("test.effect.warmup", Schema.Struct({})),
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = CrossSpawnSpawner.defaultLayer
|
const node = CrossSpawnSpawner.defaultLayer
|
||||||
@@ -17,21 +18,44 @@ const live = Layer.mergeAll(Bus.layer, node)
|
|||||||
|
|
||||||
const it = testEffect(live)
|
const it = testEffect(live)
|
||||||
|
|
||||||
|
// Publishes warmup events until the latch opens, proving the forked subscriber
|
||||||
|
// fiber has actually wired up its PubSub subscription.
|
||||||
|
const awaitSubscriberReady = Effect.fn("test.awaitSubscriberReady")(function* (
|
||||||
|
ready: Latch.Latch,
|
||||||
|
warmup: Effect.Effect<void>,
|
||||||
|
) {
|
||||||
|
const pump = yield* Effect.forkScoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
yield* warmup
|
||||||
|
yield* Effect.sleep("5 millis")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* ready.await.pipe(Effect.timeout("2 seconds"))
|
||||||
|
yield* Fiber.interrupt(pump)
|
||||||
|
})
|
||||||
|
|
||||||
describe("Bus (Effect-native)", () => {
|
describe("Bus (Effect-native)", () => {
|
||||||
it.instance("publish + subscribe stream delivers events", () =>
|
it.instance("publish + subscribe stream delivers events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const received: number[] = []
|
const received: number[] = []
|
||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.properties.value < 0) {
|
||||||
|
yield* ready.open
|
||||||
|
return
|
||||||
|
}
|
||||||
received.push(evt.properties.value)
|
received.push(evt.properties.value)
|
||||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||||
yield* Deferred.await(done)
|
yield* Deferred.await(done)
|
||||||
@@ -45,15 +69,20 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const pings: number[] = []
|
const pings: number[] = []
|
||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.properties.value < 0) {
|
||||||
|
yield* ready.open
|
||||||
|
return
|
||||||
|
}
|
||||||
pings.push(evt.properties.value)
|
pings.push(evt.properties.value)
|
||||||
Deferred.doneUnsafe(done, Effect.void)
|
Deferred.doneUnsafe(done, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||||
yield* Deferred.await(done)
|
yield* Deferred.await(done)
|
||||||
@@ -67,15 +96,20 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const types: string[] = []
|
const types: string[] = []
|
||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.type === TestEvent.Warmup.type) {
|
||||||
|
yield* ready.open
|
||||||
|
return
|
||||||
|
}
|
||||||
types.push(evt.type)
|
types.push(evt.type)
|
||||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||||
yield* Deferred.await(done)
|
yield* Deferred.await(done)
|
||||||
@@ -92,22 +126,33 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const b: number[] = []
|
const b: number[] = []
|
||||||
const doneA = yield* Deferred.make<void>()
|
const doneA = yield* Deferred.make<void>()
|
||||||
const doneB = yield* Deferred.make<void>()
|
const doneB = yield* Deferred.make<void>()
|
||||||
|
const readyA = yield* Latch.make()
|
||||||
|
const readyB = yield* Latch.make()
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.properties.value < 0) {
|
||||||
|
yield* readyA.open
|
||||||
|
return
|
||||||
|
}
|
||||||
a.push(evt.properties.value)
|
a.push(evt.properties.value)
|
||||||
Deferred.doneUnsafe(doneA, Effect.void)
|
Deferred.doneUnsafe(doneA, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.properties.value < 0) {
|
||||||
|
yield* readyB.open
|
||||||
|
return
|
||||||
|
}
|
||||||
b.push(evt.properties.value)
|
b.push(evt.properties.value)
|
||||||
Deferred.doneUnsafe(doneB, Effect.void)
|
Deferred.doneUnsafe(doneB, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* awaitSubscriberReady(readyA, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||||
|
yield* awaitSubscriberReady(readyB, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||||
yield* Deferred.await(doneA)
|
yield* Deferred.await(doneA)
|
||||||
yield* Deferred.await(doneB)
|
yield* Deferred.await(doneB)
|
||||||
@@ -123,20 +168,25 @@ describe("Bus (Effect-native)", () => {
|
|||||||
const types: string[] = []
|
const types: string[] = []
|
||||||
const seen = yield* Deferred.make<void>()
|
const seen = yield* Deferred.make<void>()
|
||||||
const disposed = yield* Deferred.make<void>()
|
const disposed = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Latch.make()
|
||||||
|
|
||||||
// Set up subscriber inside the instance
|
// Set up subscriber inside the instance
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
|
if (evt.type === TestEvent.Warmup.type) {
|
||||||
|
yield* ready.open
|
||||||
|
return
|
||||||
|
}
|
||||||
types.push(evt.type)
|
types.push(evt.type)
|
||||||
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
|
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
|
||||||
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
|
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.forkScoped)
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
|
||||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
yield* Deferred.await(seen)
|
yield* Deferred.await(seen)
|
||||||
}).pipe(provideInstance(dir))
|
}).pipe(provideInstance(dir))
|
||||||
|
|||||||
Reference in New Issue
Block a user