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:
@@ -0,0 +1,23 @@
|
||||
process.on("message", (message) => {
|
||||
if (!message || message.type !== "session.create") {
|
||||
if (message && message.requestId && process.send) {
|
||||
process.send({ type: "response", requestId: message.requestId, ok: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.send(
|
||||
{
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
ok: true,
|
||||
result: { requiredSampleRate: 16000 },
|
||||
},
|
||||
() => {
|
||||
const lock = new Int32Array(new SharedArrayBuffer(4));
|
||||
Atomics.wait(lock, 0, 0, 500);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
setInterval(() => undefined, 1000);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { once } from "node:events";
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pino from "pino";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -54,6 +56,50 @@ class FakeLocalSpeechWorker extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
class PausedIpcWorker {
|
||||
private readonly child: ChildProcess;
|
||||
|
||||
constructor() {
|
||||
this.child = fork(
|
||||
fileURLToPath(new URL("./test-fixtures/paused-ipc-worker.cjs", import.meta.url)),
|
||||
[],
|
||||
{ serialization: "advanced", stdio: ["ignore", "ignore", "ignore", "ipc"] },
|
||||
);
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.child.connected;
|
||||
}
|
||||
|
||||
get killed(): boolean {
|
||||
return this.child.killed;
|
||||
}
|
||||
|
||||
send(message: LocalSpeechWorkerRequest, callback: (error: Error | null) => void): boolean {
|
||||
return this.child.send(message, (error) => callback(error ?? null));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.child.disconnect();
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
return this.child.kill();
|
||||
}
|
||||
|
||||
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
|
||||
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
on(
|
||||
event: "message" | "exit",
|
||||
listener:
|
||||
| ((message: LocalSpeechWorkerToParentMessage) => void)
|
||||
| ((code: number | null, signal: NodeJS.Signals | null) => void),
|
||||
): this {
|
||||
this.child.on(event, listener as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(options?: { idleTtlMs?: number }) {
|
||||
const workers: FakeLocalSpeechWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
@@ -168,6 +214,49 @@ describe("LocalSpeechWorkerClient", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not surface real IPC backpressure when replaying native-sized dictation frames", async () => {
|
||||
const workers: PausedIpcWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
config: {
|
||||
modelsDir: "/tmp/models",
|
||||
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTtsModel: "kokoro-en-v0_19",
|
||||
},
|
||||
requestTimeoutMs: 30_000,
|
||||
idleTtlMs: 30_000,
|
||||
forkWorker: () => {
|
||||
const worker = new PausedIpcWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
const provider = new WorkerBackedSpeechToTextProvider(client, "dictationStt");
|
||||
const session = provider.createSession({ logger: pino({ level: "silent" }) });
|
||||
let observedError: Error | null = null;
|
||||
(session as EventEmitter).on("error", (error: Error) => {
|
||||
observedError = error;
|
||||
});
|
||||
|
||||
try {
|
||||
await session.connect();
|
||||
const nativeFrame = Buffer.alloc(1024, 1);
|
||||
|
||||
for (let seq = 0; seq < 480; seq += 1) {
|
||||
session.appendPcm16(nativeFrame);
|
||||
}
|
||||
session.commit();
|
||||
await waitForMicrotasks();
|
||||
|
||||
expect(observedError?.message).not.toBe("Local speech worker IPC channel is not writable");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
for (const worker of workers) {
|
||||
worker.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards VAD session events through the shared worker", async () => {
|
||||
const { client, workers } = createClient();
|
||||
const provider = new WorkerBackedTurnDetectionProvider(client);
|
||||
|
||||
@@ -248,7 +248,7 @@ export class LocalSpeechWorkerClient {
|
||||
timeout,
|
||||
});
|
||||
|
||||
const sent = worker.send(message, (error) => {
|
||||
worker.send(message, (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
@@ -262,16 +262,6 @@ export class LocalSpeechWorkerClient {
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
pending.reject(error);
|
||||
});
|
||||
if (!sent) {
|
||||
const pending = this.pendingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
|
||||
this.scheduleIdleShutdownIfReady();
|
||||
pending.reject(new Error("Local speech worker IPC channel is not writable"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user