blob: 9a46308dc5e4b51637e57a0794ebc0f936b55e7c [file]
/**
* AsyncEventQueue — single-producer, single-consumer FIFO with async iteration.
*
* Use case: backends (AiSdkBackend / FakeBackend) need to surface
* events from multiple internal callsites — the SDK stream loop AND the
* `canUseTool` callback that fires in parallel from the SDK subprocess.
* Both callsites push into the same queue; `send()` returns the queue's
* async iterator so the SessionManager can drain in order.
*
* Semantics:
* - `push(item)` is non-blocking; resolves the next waiter or buffers.
* - `close()` signals end-of-stream; the iterator drains buffered items then
* completes.
* - `error(err)` rejects the next/current waiter and marks the queue errored;
* subsequent `next()` calls re-throw.
* - One consumer only. Multiple consumers will race on `next()`.
*
* Seq-ack boundary: the consumer loop acks each event AFTER fully processing it
* via `ackConsumed()` (see AiSdkBackend.drain — the generator's pull IS the
* ack). Producers use `pushAndWaitUntilConsumed()` for one exact event, or
* `waitUntilConsumedThroughCurrent()` for everything already enqueued. Both are
* event-driven and fail closed if the consumer detaches or the queue errors
* before the boundary is consumed.
*/
export class AsyncEventQueue<T> implements AsyncIterable<T> {
private buf: T[] = [];
private waiters: Array<{
resolve: (r: IteratorResult<T>) => void;
reject: (e: Error) => void;
}> = [];
private closed = false;
private err: Error | null = null;
/** Monotonic count of events accepted by push(). */
pushedCount = 0;
/** Monotonic count of events the consumer has fully processed. */
consumedCount = 0;
/** Set when the consumer abandoned the stream; progress waiters must not block on it. */
consumerDetached = false;
private progressWaiters: Array<() => void> = [];
push(item: T): void {
if (!this.canEnqueue()) return;
this.enqueue(item);
}
/**
* Enqueue one item and resolve only after the consumer has fully processed
* that exact sequence. Unlike `push()`, an enqueue rejected by queue state is
* observable by the producer.
*/
pushAndWaitUntilConsumed(item: T): Promise<void> {
if (this.err) return Promise.reject(this.err);
if (this.consumerDetached) return Promise.reject(consumerDetachedError());
if (this.closed) return Promise.reject(queueClosedError());
const sequence = this.enqueue(item);
return this.waitUntilConsumed(sequence);
}
/**
* Wait through the producer boundary captured at call time. Items enqueued
* later do not extend the wait.
*/
waitUntilConsumedThroughCurrent(): Promise<void> {
return this.waitUntilConsumed(this.pushedCount);
}
private canEnqueue(): boolean {
return !this.closed && !this.err && !this.consumerDetached;
}
private enqueue(item: T): number {
const sequence = ++this.pushedCount;
const w = this.waiters.shift();
if (w) {
w.resolve({ value: item, done: false });
} else {
this.buf.push(item);
}
this.wake();
return sequence;
}
private async waitUntilConsumed(sequence: number): Promise<void> {
while (this.consumedCount < sequence) {
if (this.err) throw this.err;
if (this.consumerDetached) throw consumerDetachedError();
await this.waitForProgress();
}
}
/** Consumer-side ack: one event has been fully processed (not just received). */
ackConsumed(): void {
this.consumedCount += 1;
this.wake();
}
/** The consumer stopped pulling; wake waiters so they can observe it. */
noteConsumerDetached(): void {
if (this.consumerDetached) return;
this.consumerDetached = true;
this.wake();
}
/** Resolves on the next push/ack/close/error/wake — a condition-variable wait. */
waitForProgress(): Promise<void> {
return new Promise<void>((resolve) => {
this.progressWaiters.push(resolve);
});
}
/** Wake all progress waiters so they re-check their condition. */
wake(): void {
if (this.progressWaiters.length === 0) return;
const waiters = this.progressWaiters;
this.progressWaiters = [];
for (const resolve of waiters) resolve();
}
close(): void {
if (this.closed) return;
this.closed = true;
while (this.waiters.length > 0) {
const w = this.waiters.shift()!;
w.resolve({ value: undefined as unknown as T, done: true });
}
this.wake();
}
error(err: Error): void {
if (this.closed) return;
this.err = err;
this.closed = true;
while (this.waiters.length > 0) {
const w = this.waiters.shift()!;
w.reject(err);
}
this.wake();
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
if (this.err) return Promise.reject(this.err);
const item = this.buf.shift();
if (item !== undefined) {
return Promise.resolve({ value: item, done: false });
}
if (this.closed) {
return Promise.resolve({ value: undefined as unknown as T, done: true });
}
return new Promise<IteratorResult<T>>((resolve, reject) => {
this.waiters.push({ resolve, reject });
});
},
return: (): Promise<IteratorResult<T>> => {
this.close();
return Promise.resolve({ value: undefined as unknown as T, done: true });
},
};
}
}
function queueClosedError(): Error {
return new Error('cannot enqueue: async event queue is closed');
}
function consumerDetachedError(): Error {
return new Error('event consumer detached before the queue boundary was consumed');
}