From 4f542e511386c2c617987e8dc9575bdb4d54f42a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:17:31 +0000 Subject: [PATCH 1/3] test(server): reproduce speech worker exit teardown during inference (#33616) These tests reproduce #33616: when the backend dies while a native feed is in flight, the speech worker's disconnect handler calls process.exit(0), which runs exit teardown under the still-running inference call. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/speech/native.test.ts | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/server/src/speech/native.test.ts b/apps/server/src/speech/native.test.ts index 8712c63ed347d..71e4fa9a0a2e0 100644 --- a/apps/server/src/speech/native.test.ts +++ b/apps/server/src/speech/native.test.ts @@ -1,4 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off - verifies isolated native worker processes and their exit events. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { expect, it } from "vite-plus/test"; import { loadNativeSpeechModel } from "./native.ts"; @@ -155,6 +159,36 @@ it("kills a hung streaming feed without waiting for native cleanup", async () => await model.dispose(); await rejected; }); + +it("skips process exit teardown when the backend dies during native inference", async () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-speech-orphan-")); + const marker = NodePath.join(directory, "exit-teardown"); + try { + // The exit hook stands in for native atexit teardown; the interval for the threadpool call. + const model = await loadNativeSpeechModel( + "unused.gguf", + new AbortController().signal, + streamingFixture(`async () => { + const { writeFileSync } = process.getBuiltinModule("node:fs"); + process.once("exit", () => writeFileSync(${JSON.stringify(marker)}, "ran")); + setInterval(() => {}, 1 << 30); + process.disconnect(); + return await new Promise(() => {}); + }`), + ); + await model.begin({ timestamps: "none" }, false); + await expect(model.feed(new Float32Array(512).fill(0.25))).rejects.toThrow( + "Speech process exited", + ); + expect( + NodeFS.existsSync(marker), + "orphaned worker ran exit teardown while native inference was in flight", + ).toBe(false); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + const vadFixture = ({ streaming = false, failVad = false, From 7a40bb738ec0315b5cbf258f6797e21c877c7a9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:20:04 +0000 Subject: [PATCH 2/3] fix(server): speech worker skips exit teardown when the backend dies When the backend went away mid-inference, the speech worker's disconnect handler called process.exit(0). That ran Node and native exit teardown while a ggml inference call was still running on a libuv threadpool thread, which then segfaulted and left a coredump. The worker now SIGKILLs itself on disconnect, so the OS reclaims it without running teardown. Fixes pingdotgg/t3code#33616 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/speech/native.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/speech/native.ts b/apps/server/src/speech/native.ts index 8331e1b5e0ec1..70c5d21c6038d 100644 --- a/apps/server/src/speech/native.ts +++ b/apps/server/src/speech/native.ts @@ -182,7 +182,8 @@ process.on("message", async (message) => { error: error instanceof Error ? error.stack ?? error.message : String(error) }); } }); -process.on("disconnect", () => process.exit(0)); +// Exit teardown under in-flight native inference segfaults, so terminate without it. +process.on("disconnect", () => process.kill(process.pid, "SIGKILL")); `; type Reply = { From 09323d96ce065e81a05dd96a9efaa3e5557f135a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 01:24:38 +0000 Subject: [PATCH 3/3] test(server): dispose the orphan-teardown test worker on failure Co-Authored-By: Claude Opus 5.5 --- apps/server/src/speech/native.test.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/server/src/speech/native.test.ts b/apps/server/src/speech/native.test.ts index 71e4fa9a0a2e0..a41f917b2a682 100644 --- a/apps/server/src/speech/native.test.ts +++ b/apps/server/src/speech/native.test.ts @@ -163,19 +163,19 @@ it("kills a hung streaming feed without waiting for native cleanup", async () => it("skips process exit teardown when the backend dies during native inference", async () => { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-speech-orphan-")); const marker = NodePath.join(directory, "exit-teardown"); + // The exit hook stands in for native atexit teardown; the interval for the threadpool call. + const model = await loadNativeSpeechModel( + "unused.gguf", + new AbortController().signal, + streamingFixture(`async () => { + const { writeFileSync } = process.getBuiltinModule("node:fs"); + process.once("exit", () => writeFileSync(${JSON.stringify(marker)}, "ran")); + setInterval(() => {}, 1 << 30); + process.disconnect(); + return await new Promise(() => {}); + }`), + ); try { - // The exit hook stands in for native atexit teardown; the interval for the threadpool call. - const model = await loadNativeSpeechModel( - "unused.gguf", - new AbortController().signal, - streamingFixture(`async () => { - const { writeFileSync } = process.getBuiltinModule("node:fs"); - process.once("exit", () => writeFileSync(${JSON.stringify(marker)}, "ran")); - setInterval(() => {}, 1 << 30); - process.disconnect(); - return await new Promise(() => {}); - }`), - ); await model.begin({ timestamps: "none" }, false); await expect(model.feed(new Float32Array(512).fill(0.25))).rejects.toThrow( "Speech process exited", @@ -185,6 +185,7 @@ it("skips process exit teardown when the backend dies during native inference", "orphaned worker ran exit teardown while native inference was in flight", ).toBe(false); } finally { + await model.dispose(); NodeFS.rmSync(directory, { recursive: true, force: true }); } });