Fix long dictation retry backpressure

Treat Node IPC backpressure as a queue signal instead of a failed send, and pace buffered dictation replay so finish waits for the backlog to flush.
This commit is contained in:
Mohamed Boudra
2026-06-04 13:35:10 +07:00
parent e381117b1c
commit f0370a3c20
5 changed files with 197 additions and 12 deletions
@@ -137,4 +137,30 @@ describe("DictationStreamSender", () => {
expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]);
});
it("does not replay long buffered native dictation in one synchronous burst", async () => {
const client = new FakeDaemonClient();
client.isConnected = false;
const sender = new DictationStreamSender({
client: client as unknown as DaemonClient,
format: "audio/pcm;rate=16000;bits=16",
createDictationId: () => "d1",
});
for (let seq = 0; seq < 480; seq += 1) {
sender.enqueueSegment(`native-frame-${seq}`);
}
client.isConnected = true;
const finish = sender.finish(sender.getFinalSeq());
await tick();
expect(client.chunks.length).toBeLessThanOrEqual(128);
await expect(finish).resolves.toEqual({
dictationId: "d1",
text: "ok",
});
expect(client.finishes).toEqual([{ dictationId: "d1", finalSeq: 479 }]);
});
});
@@ -1,6 +1,10 @@
import { generateMessageId } from "@/types/stream";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
const MAX_CHUNKS_PER_FLUSH_TURN = 128;
const waitForNextFlushTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
export interface DictationStreamSenderParams {
client: DaemonClient | null;
format: string;
@@ -33,6 +37,8 @@ export class DictationStreamSender {
private sendSeq = 0;
private segments: string[] = [];
private streamReady = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private drainWaiters: Array<() => void> = [];
private startGeneration = 0;
private startPromise: Promise<void> | null = null;
@@ -64,6 +70,7 @@ export class DictationStreamSender {
}
clearAll(): void {
this.clearScheduledFlush();
this.dictationId = null;
this.sendSeq = 0;
this.segments = [];
@@ -73,6 +80,7 @@ export class DictationStreamSender {
}
resetStreamForReplay(): void {
this.clearScheduledFlush();
this.dictationId = null;
this.sendSeq = 0;
this.streamReady = false;
@@ -108,13 +116,18 @@ export class DictationStreamSender {
}
let sent = 0;
while (this.sendSeq < this.segments.length) {
while (this.sendSeq < this.segments.length && sent < MAX_CHUNKS_PER_FLUSH_TURN) {
const seq = this.sendSeq;
const audio = this.segments[seq];
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
this.sendSeq = seq + 1;
sent += 1;
}
if (this.hasPendingSegments()) {
this.scheduleFlush();
} else {
this.resolveDrainWaiters();
}
return sent;
}
@@ -184,6 +197,7 @@ export class DictationStreamSender {
}
this.flush();
await this.waitForFlushDrain();
return client.finishDictationStream(dictationId, finalSeq);
}
@@ -195,4 +209,47 @@ export class DictationStreamSender {
}
this.resetStreamForReplay();
}
private hasPendingSegments(): boolean {
return this.sendSeq < this.segments.length;
}
private scheduleFlush(): void {
if (this.flushTimer) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flush();
}, 0);
}
private clearScheduledFlush(): void {
if (!this.flushTimer) {
return;
}
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
private async waitForFlushDrain(): Promise<void> {
while (this.hasPendingSegments()) {
const client = this.client;
if (!client?.isConnected || !this.dictationId || !this.streamReady) {
throw new Error("Failed to flush dictation stream");
}
await new Promise<void>((resolve) => {
this.drainWaiters.push(resolve);
});
await waitForNextFlushTurn();
}
}
private resolveDrainWaiters(): void {
const waiters = this.drainWaiters;
this.drainWaiters = [];
for (const resolve of waiters) {
resolve();
}
}
}