fix(bus): acquire PubSub subscription eagerly to close /event race (#27959)

This commit is contained in:
Kit Langton
2026-05-18 11:38:05 -04:00
committed by GitHub
parent 5bfd7fd16c
commit cb35493242
9 changed files with 659 additions and 43 deletions

View File

@@ -44,7 +44,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -71,7 +71,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* ready.open
@@ -98,7 +98,7 @@ describe("Bus (Effect-native)", () => {
const done = yield* Deferred.make<void>()
const ready = yield* Latch.make()
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
const readyA = yield* Latch.make()
const readyB = yield* Latch.make()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyA.open
@@ -140,7 +140,7 @@ describe("Bus (Effect-native)", () => {
}),
).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
Effect.gen(function* () {
if (evt.properties.value < 0) {
yield* readyB.open
@@ -162,6 +162,92 @@ describe("Bus (Effect-native)", () => {
}),
)
// RACE 1: eager subscription means publishing immediately after yield*
// bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap`
// shape where PubSub.subscribe ran on first pull and missed any publish
// in the hand-off window.
it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribe(TestEvent.Ping)
// Hand-off window: subscription is alive (we yielded). Publish goes
// straight into the subscription queue, even with no consumer running.
yield* bus.publish(TestEvent.Ping, { value: 99 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].properties.value).toBe(99)
}
}),
)
// RACE 2: same property for subscribeAll.
it.instance("eager subscribeAll: publish after yield* is delivered", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const stream = yield* bus.subscribeAll()
yield* bus.publish(TestEvent.Ping, { value: 42 })
const collected = yield* stream.pipe(
Stream.take(1),
Stream.runCollect,
Effect.timeout("400 millis"),
Effect.option,
)
expect(collected._tag).toBe("Some")
if (collected._tag === "Some") {
const arr = Array.from(collected.value)
expect(arr[0].type).toBe(TestEvent.Ping.type)
}
}),
)
// RACE 3: the /event-handler shape exactly. With eager subscription, the
// bus subscription is alive before Stream.concat ever starts. Publishes
// during the prefix consumption window are queued and delivered.
it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const sawInitial = yield* Deferred.make<void>()
const sawPublish = yield* Deferred.make<number>()
type Frame = { marker?: "initial"; value?: number }
const subscriptionStream = yield* bus.subscribe(TestEvent.Ping)
const handlerStream: Stream.Stream<Frame> = Stream.make({ marker: "initial" } as Frame).pipe(
Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))),
)
yield* Stream.runForEach(handlerStream, (frame) =>
Effect.gen(function* () {
if (frame.marker === "initial") {
Deferred.doneUnsafe(sawInitial, Effect.void)
return
}
if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value))
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second"))
yield* bus.publish(TestEvent.Ping, { value: 7 })
const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option)
expect(got._tag).toBe("Some")
if (got._tag === "Some") expect(got.value).toBe(7)
}),
)
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -174,7 +260,7 @@ describe("Bus (Effect-native)", () => {
yield* Effect.gen(function* () {
const bus = yield* Bus.Service
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
Effect.gen(function* () {
if (evt.type === TestEvent.Warmup.type) {
yield* ready.open