@qvac/sdk 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE CHANGED
@@ -266,21 +266,13 @@ Third-Party Model Licenses
266
266
  chatterbox-multilingual-ONNX
267
267
  https://huggingface.co/onnx-community/chatterbox-multilingual-ONNX
268
268
  chatterbox-s3gen
269
- s3:///qvac_models_compiled/chatterbox/2026-05-08/chatterbox-s3gen.gguf
269
+ https://huggingface.co/ResembleAI/chatterbox-turbo
270
270
  chatterbox-s3gen-mtl
271
- s3:///qvac_models_compiled/chatterbox/2026-05-08/chatterbox-s3gen-mtl.gguf
271
+ https://huggingface.co/ResembleAI/chatterbox
272
272
  chatterbox-t3-mtl
273
- s3:///qvac_models_compiled/chatterbox/2026-05-08/chatterbox-t3-mtl.gguf
274
- chatterbox-t3-mtl-q4_0
275
- s3:///qvac_models_compiled/ggml/chatterbox/2026-05-18/chatterbox-t3-mtl-q4_0.gguf
276
- chatterbox-t3-mtl-q8_0
277
- s3:///qvac_models_compiled/ggml/chatterbox/2026-05-18/chatterbox-t3-mtl-q8_0.gguf
273
+ https://huggingface.co/ResembleAI/chatterbox
278
274
  chatterbox-t3-turbo
279
- s3:///qvac_models_compiled/chatterbox/2026-05-08/chatterbox-t3-turbo.gguf
280
- chatterbox-t3-turbo-q4_0
281
- s3:///qvac_models_compiled/ggml/chatterbox/2026-05-18/chatterbox-t3-turbo-q4_0.gguf
282
- chatterbox-t3-turbo-q8_0
283
- s3:///qvac_models_compiled/ggml/chatterbox/2026-05-18/chatterbox-t3-turbo-q8_0.gguf
275
+ https://huggingface.co/ResembleAI/chatterbox-turbo
284
276
  chatterbox-turbo-ONNX
285
277
  https://huggingface.co/ResembleAI/chatterbox-turbo-ONNX
286
278
  de-base-ggml-model-f16
@@ -2,7 +2,7 @@ import { type QvacConfig } from "./config-utils";
2
2
  /**
3
3
  * Resolution order for Bare:
4
4
  * 1. QVAC_CONFIG_PATH environment variable
5
- * 2. Config file in project root (qvac.config.ts, qvac.config.js, qvac.config.json)
5
+ * 2. Config file in project root (qvac.config.js, qvac.config.json)
6
6
  * 3. SDK defaults
7
7
  */
8
8
  export declare function resolveConfig(): Promise<QvacConfig | undefined>;
@@ -5,9 +5,10 @@
5
5
  import fs from "bare-fs";
6
6
  import path from "bare-path";
7
7
  import process from "bare-process";
8
- import { validateConfig, parseJsonConfig, } from "./config-utils.js";
9
- import { ConfigFileParseFailedError } from "../../utils/errors-client.js";
8
+ import { validateConfig } from "./config-utils.js";
9
+ import { ConfigFileInvalidError, ConfigFileParseFailedError, } from "../../utils/errors-client.js";
10
10
  import { getClientLogger } from "../../logging/index.js";
11
+ const SUPPORTED_CONFIG_FILE_EXTS = [".js", ".json"];
11
12
  const logger = getClientLogger();
12
13
  function findProjectRoot() {
13
14
  return process.cwd();
@@ -15,19 +16,16 @@ function findProjectRoot() {
15
16
  function fileExists(filePath) {
16
17
  return fs.existsSync(filePath);
17
18
  }
18
- function readFile(filePath) {
19
- const result = fs.readFileSync(filePath, "utf-8");
20
- return typeof result === "string" ? result : result.toString("utf-8");
21
- }
22
- function loadJsonConfig(filePath) {
23
- const content = readFile(filePath);
24
- const parsed = parseJsonConfig(content, filePath);
25
- return validateConfig(parsed);
19
+ function assertBareConfigExtension(filePath) {
20
+ const ext = path.extname(filePath).toLowerCase();
21
+ if (!SUPPORTED_CONFIG_FILE_EXTS.includes(ext)) {
22
+ throw new ConfigFileInvalidError(filePath, "Given config file format unsupported on this platform. Use qvac.config.js or qvac.config.json in the project root, or set QVAC_CONFIG_PATH to a .js or .json file.");
23
+ }
26
24
  }
27
- async function loadJsConfig(filePath) {
25
+ function loadConfigFromPath(filePath) {
26
+ assertBareConfigExtension(filePath);
28
27
  try {
29
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
30
- const configModule = await import(filePath);
28
+ const configModule = require(filePath);
31
29
  return validateConfig(configModule.default || configModule);
32
30
  }
33
31
  catch (error) {
@@ -35,15 +33,11 @@ async function loadJsConfig(filePath) {
35
33
  }
36
34
  }
37
35
  function findConfigFile(searchDir) {
38
- const configFiles = [
39
- { name: "qvac.config.ts", type: "ts" },
40
- { name: "qvac.config.js", type: "js" },
41
- { name: "qvac.config.json", type: "json" },
42
- ];
43
- for (const { name, type } of configFiles) {
36
+ const configFiles = SUPPORTED_CONFIG_FILE_EXTS.map((ext) => `qvac.config${ext}`);
37
+ for (const name of configFiles) {
44
38
  const filePath = path.resolve(searchDir, name);
45
39
  if (fileExists(filePath)) {
46
- return { path: filePath, type };
40
+ return filePath;
47
41
  }
48
42
  }
49
43
  return undefined;
@@ -51,34 +45,26 @@ function findConfigFile(searchDir) {
51
45
  /**
52
46
  * Resolution order for Bare:
53
47
  * 1. QVAC_CONFIG_PATH environment variable
54
- * 2. Config file in project root (qvac.config.ts, qvac.config.js, qvac.config.json)
48
+ * 2. Config file in project root (qvac.config.js, qvac.config.json)
55
49
  * 3. SDK defaults
56
50
  */
51
+ // eslint-disable-next-line @typescript-eslint/require-await -- matches Node/Expo resolver signature
57
52
  export async function resolveConfig() {
58
53
  const configPath = process.env["QVAC_CONFIG_PATH"];
59
54
  if (configPath) {
60
55
  const normalizedPath = path.resolve(configPath);
61
56
  if (fileExists(normalizedPath)) {
62
- const ext = normalizedPath.endsWith(".json")
63
- ? "json"
64
- : normalizedPath.endsWith(".ts")
65
- ? "ts"
66
- : "js";
67
- const config = ext === "json"
68
- ? loadJsonConfig(normalizedPath)
69
- : await loadJsConfig(normalizedPath);
57
+ const config = loadConfigFromPath(normalizedPath);
70
58
  logger.info(`✅ Loaded config from: ${normalizedPath}`);
71
59
  return config;
72
60
  }
73
61
  }
74
62
  const projectRoot = findProjectRoot();
75
63
  if (projectRoot) {
76
- const configFile = findConfigFile(projectRoot);
77
- if (configFile) {
78
- const config = configFile.type === "json"
79
- ? loadJsonConfig(configFile.path)
80
- : await loadJsConfig(configFile.path);
81
- logger.info(`✅ Loaded config from: ${configFile.path}`);
64
+ const configFilePath = findConfigFile(projectRoot);
65
+ if (configFilePath) {
66
+ const config = loadConfigFromPath(configFilePath);
67
+ logger.info(`✅ Loaded config from: ${configFilePath}`);
82
68
  return config;
83
69
  }
84
70
  }
@@ -1,5 +1,6 @@
1
1
  import type { Request, Response } from "../../schemas/index";
2
2
  export declare function send<T extends Request>(request: T): Promise<Response>;
3
+ export declare function getWorkerLifeSignal(): AbortSignal | null;
3
4
  export declare function getRPC(): Promise<{
4
5
  request(): {
5
6
  send(payload: string): void;
@@ -193,6 +193,10 @@ function createMockRPCRequest() {
193
193
  };
194
194
  }
195
195
  let configInitialized = false;
196
+ // No child worker on Bare-direct — `#rpc` interface stub.
197
+ export function getWorkerLifeSignal() {
198
+ return null;
199
+ }
196
200
  export async function getRPC() {
197
201
  await ensureWorkerReady();
198
202
  const mockRPC = {
@@ -1,4 +1,5 @@
1
1
  import RPC from "bare-rpc";
2
+ export declare function getWorkerLifeSignal(): AbortSignal | null;
2
3
  export declare function getRPC(): Promise<RPC>;
3
4
  export declare function close(): Promise<void>;
4
5
  export declare function createDuplexSession(payload: string, commandId: number): Promise<{
@@ -28,6 +28,10 @@ async function getRuntimeContext() {
28
28
  };
29
29
  return cachedRuntimeContext;
30
30
  }
31
+ // No child-process exit on Expo (Worklet lifecycle) — `#rpc` interface stub.
32
+ export function getWorkerLifeSignal() {
33
+ return null;
34
+ }
31
35
  export async function getRPC() {
32
36
  if (rpcInstance) {
33
37
  return rpcInstance;
@@ -1,4 +1,5 @@
1
1
  import RPC from "bare-rpc";
2
+ export declare function getWorkerLifeSignal(): AbortSignal | null;
2
3
  export declare function getRPC(): Promise<RPC>;
3
4
  export declare function createDuplexSession(payload: string, commandId: number): Promise<{
4
5
  requestStream: RPC.OutgoingStream;
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { initializeConfig } from "../init-hooks.js";
11
11
  import { resolveConfig } from "../config-loader/resolve-config.node.js";
12
12
  import { getClientLogger } from "../../logging/index.js";
13
- import { RPCInitTimeoutError } from "../../utils/errors-client.js";
13
+ import { RPCInitTimeoutError, WorkerCrashedError, WorkerShutdownError, } from "../../utils/errors-client.js";
14
14
  const RPC_INIT_TIMEOUT_MS = 30_000;
15
15
  const logger = getClientLogger();
16
16
  let rpcInstance = null;
@@ -19,6 +19,11 @@ let bareWorkerProc = null;
19
19
  let ipcServer = null;
20
20
  let currentSocketPath = null;
21
21
  let closePromise = null;
22
+ // Aborted when the worker dies (crash) or close() runs (planned). Used
23
+ // to unblock in-flight `req.reply()` callers — bare-rpc's `_onerror`
24
+ // does not iterate `_outgoingRequests`, so without this signal they
25
+ // would hang on the dead socket.
26
+ let workerLifeController = null;
22
27
  /**
23
28
  * Find project root by looking for package.json (sync version)
24
29
  * Safe to call at module load time
@@ -148,9 +153,42 @@ function snapshotAndResetState() {
148
153
  bareWorkerProc = null;
149
154
  ipcServer = null;
150
155
  currentSocketPath = null;
156
+ workerLifeController = null;
151
157
  return { workerToClose, serverToClose, socketPathToClose };
152
158
  }
159
+ export function getWorkerLifeSignal() {
160
+ return workerLifeController?.signal ?? null;
161
+ }
162
+ // `spawn` carries this listener's own captured resources. Module-level
163
+ // state may belong to a newer ensureRPC() by the time we fire.
164
+ function handlePostHandshakeExit(code, exitSignal, spawn) {
165
+ if (spawn.controller.signal.aborted) {
166
+ // close() already handled teardown.
167
+ logger.debug(`Bare worker exited after planned shutdown (code=${code}, signal=${exitSignal})`);
168
+ return;
169
+ }
170
+ logger.info(`🪦 Bare worker exited post-handshake (code=${code}, signal=${exitSignal})`);
171
+ // Only clear module state if we're still the active spawn.
172
+ if (workerLifeController === spawn.controller) {
173
+ rpcInstance = null;
174
+ rpcPromise = null;
175
+ bareWorkerProc = null;
176
+ ipcServer = null;
177
+ currentSocketPath = null;
178
+ workerLifeController = null;
179
+ }
180
+ try {
181
+ spawn.server.close();
182
+ }
183
+ catch (err) {
184
+ logger.debug("Failed to close IPC server after worker crash", { err });
185
+ }
186
+ spawn.controller.abort(new WorkerCrashedError(code, exitSignal));
187
+ bestEffortUnlinkSocket(spawn.socketPath);
188
+ }
153
189
  function closeSyncForExit() {
190
+ // Abort before kill so the exit handler sees planned intent.
191
+ workerLifeController?.abort(new WorkerShutdownError());
154
192
  const { workerToClose, serverToClose, socketPathToClose } = snapshotAndResetState();
155
193
  if (workerToClose) {
156
194
  try {
@@ -180,6 +218,9 @@ async function ensureRPC() {
180
218
  }
181
219
  const socketPath = createSocketPath();
182
220
  currentSocketPath = socketPath;
221
+ // Allocated here so the init race and exit handler share one controller.
222
+ const spawnController = new AbortController();
223
+ workerLifeController = spawnController;
183
224
  rpcPromise = new Promise((resolve, reject) => {
184
225
  let settled = false;
185
226
  const timer = setTimeout(() => {
@@ -191,6 +232,7 @@ async function ensureRPC() {
191
232
  bareWorkerProc = null;
192
233
  ipcServer = null;
193
234
  currentSocketPath = null;
235
+ workerLifeController = null;
194
236
  reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS));
195
237
  }, RPC_INIT_TIMEOUT_MS);
196
238
  ipcServer = createServer((socket) => {
@@ -211,9 +253,15 @@ async function ensureRPC() {
211
253
  bareWorkerProc = null;
212
254
  ipcServer = null;
213
255
  currentSocketPath = null;
256
+ workerLifeController = null;
214
257
  reject(error);
215
258
  });
216
259
  ipcServer.listen(socketPath, () => {
260
+ const spawnResources = {
261
+ controller: spawnController,
262
+ server: ipcServer,
263
+ socketPath,
264
+ };
217
265
  bareWorkerProc = spawn("bare", {
218
266
  args: [
219
267
  WORKER_PATH,
@@ -228,9 +276,12 @@ async function ensureRPC() {
228
276
  stdio: ["inherit", "inherit", "inherit"],
229
277
  });
230
278
  if (bareWorkerProc) {
231
- bareWorkerProc.on("exit", (code) => {
232
- if (settled)
279
+ bareWorkerProc.on("exit", (code, exitSignal) => {
280
+ if (settled) {
281
+ handlePostHandshakeExit(code, exitSignal, spawnResources);
233
282
  return;
283
+ }
284
+ // Worker died before handshake — reject the init promise.
234
285
  settled = true;
235
286
  clearTimeout(timer);
236
287
  rpcPromise = null;
@@ -238,6 +289,7 @@ async function ensureRPC() {
238
289
  bareWorkerProc = null;
239
290
  ipcServer = null;
240
291
  currentSocketPath = null;
292
+ workerLifeController = null;
241
293
  reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS, new Error(`Worker process exited with code ${code} before IPC connection was established`)));
242
294
  });
243
295
  }
@@ -248,9 +300,25 @@ async function ensureRPC() {
248
300
  runtime: "node",
249
301
  platform: process.platform,
250
302
  };
251
- await initializeConfig(rpc, resolveConfig, runtimeContext);
303
+ // init-hooks calls bare-rpc directly, bypassing rpc-client's race.
304
+ await Promise.race([
305
+ initializeConfig(rpc, resolveConfig, runtimeContext),
306
+ rejectOnAbort(spawnController.signal),
307
+ ]);
252
308
  return rpc;
253
309
  }
310
+ function rejectOnAbort(signal) {
311
+ return new Promise((_, reject) => {
312
+ const fail = () => reject(signal.reason instanceof Error
313
+ ? signal.reason
314
+ : new WorkerCrashedError(null, null));
315
+ if (signal.aborted) {
316
+ fail();
317
+ return;
318
+ }
319
+ signal.addEventListener("abort", fail, { once: true });
320
+ });
321
+ }
254
322
  export async function getRPC() {
255
323
  return ensureRPC();
256
324
  }
@@ -260,6 +328,24 @@ export async function createDuplexSession(payload, commandId) {
260
328
  const requestStream = req.createRequestStream();
261
329
  const responseStream = req.createResponseStream({ encoding: "utf-8" });
262
330
  requestStream.write(payload, "utf-8");
331
+ // Destroy on worker death so consumers' for-await throws instead of hangs.
332
+ const lifeSignal = workerLifeController?.signal;
333
+ if (lifeSignal && !lifeSignal.aborted) {
334
+ const onAbort = () => {
335
+ const err = lifeSignal.reason instanceof Error
336
+ ? lifeSignal.reason
337
+ : new WorkerCrashedError(null, null);
338
+ try {
339
+ requestStream.destroy?.(err);
340
+ }
341
+ catch { }
342
+ try {
343
+ responseStream.destroy?.(err);
344
+ }
345
+ catch { }
346
+ };
347
+ lifeSignal.addEventListener("abort", onAbort, { once: true });
348
+ }
263
349
  return { requestStream, responseStream };
264
350
  }
265
351
  export async function close() {
@@ -270,6 +356,9 @@ export async function close() {
270
356
  if (!rpcInstance && !rpcPromise && !bareWorkerProc && !ipcServer)
271
357
  return;
272
358
  logger.info("🧹 Closing RPC client");
359
+ // Abort before kill: exit handler sees planned intent; any in-flight
360
+ // caller (contract violators) rejects with WorkerShutdownError.
361
+ workerLifeController?.abort(new WorkerShutdownError());
273
362
  const { workerToClose, serverToClose, socketPathToClose } = snapshotAndResetState();
274
363
  closePromise = (async () => {
275
364
  if (workerToClose) {
@@ -2,7 +2,8 @@ import { requestSchema, responseSchema, PROFILING_TRAILER_KEY, } from "../../sch
2
2
  import { reconstructError } from "./rpc-error.js";
3
3
  import { withTimeout, withTimeoutStream } from "../../utils/withTimeout.js";
4
4
  import { getClientLogger, summarizeRequest } from "../../logging/index.js";
5
- import { getRPC, close as closeRPC, createDuplexSession } from "#rpc";
5
+ import { getRPC, close as closeRPC, createDuplexSession, getWorkerLifeSignal, } from "#rpc";
6
+ import { WorkerCrashedError } from "../../utils/errors-client.js";
6
7
  import { nowMs, shouldProfile, shouldIncludeServerBreakdown, generateId as createProfileId, createProfilingMeta, createProfilingDisabledMeta, injectProfilingMetaIntoObject, extractProfilingMeta, stripProfilingMeta, recordFailure, } from "../../profiling/index.js";
7
8
  import { createClientTimings, createClientStreamTimings, recordClientEvents, recordClientStreamEvents, cacheConnectionTime, flushConnectionTime, resetConnectionTracking, } from "./profiling/index.js";
8
9
  const logger = getClientLogger();
@@ -13,6 +14,98 @@ function getNextCommandId() {
13
14
  commandCounter = (commandCounter + 1) % Number.MAX_SAFE_INTEGER;
14
15
  return commandCounter;
15
16
  }
17
+ // Race in-flight reply/stream pulls against the worker-life signal —
18
+ // bare-rpc's `_onerror` does not iterate `_outgoingRequests`, so without
19
+ // this they hang on a dead socket.
20
+ function lifeSignalAbortError(signal) {
21
+ if (signal.reason instanceof Error)
22
+ return signal.reason;
23
+ return new WorkerCrashedError(null, null);
24
+ }
25
+ async function awaitWithLifeSignal(p) {
26
+ // Null on Bare-direct / Expo and pre-spawn.
27
+ const lifeSignal = getWorkerLifeSignal();
28
+ if (!lifeSignal)
29
+ return p;
30
+ if (lifeSignal.aborted)
31
+ throw lifeSignalAbortError(lifeSignal);
32
+ return await new Promise((resolve, reject) => {
33
+ let settled = false;
34
+ const onAbort = () => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ lifeSignal.removeEventListener("abort", onAbort);
39
+ reject(lifeSignalAbortError(lifeSignal));
40
+ };
41
+ lifeSignal.addEventListener("abort", onAbort, { once: true });
42
+ p.then((value) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ lifeSignal.removeEventListener("abort", onAbort);
47
+ resolve(value);
48
+ }, (err) => {
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ lifeSignal.removeEventListener("abort", onAbort);
53
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- forwarding upstream rejection as-is
54
+ reject(err);
55
+ });
56
+ });
57
+ }
58
+ async function* iterateWithLifeSignal(source) {
59
+ // Null on Bare-direct / Expo and pre-spawn.
60
+ const lifeSignal = getWorkerLifeSignal();
61
+ if (!lifeSignal) {
62
+ yield* source;
63
+ return;
64
+ }
65
+ if (lifeSignal.aborted)
66
+ throw lifeSignalAbortError(lifeSignal);
67
+ try {
68
+ while (true) {
69
+ const nextP = source.next();
70
+ const result = await new Promise((resolve, reject) => {
71
+ let settled = false;
72
+ const onAbort = () => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ lifeSignal.removeEventListener("abort", onAbort);
77
+ reject(lifeSignalAbortError(lifeSignal));
78
+ };
79
+ lifeSignal.addEventListener("abort", onAbort, { once: true });
80
+ nextP.then((value) => {
81
+ if (settled)
82
+ return;
83
+ settled = true;
84
+ lifeSignal.removeEventListener("abort", onAbort);
85
+ resolve(value);
86
+ }, (err) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ lifeSignal.removeEventListener("abort", onAbort);
91
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- forwarding upstream rejection as-is
92
+ reject(err);
93
+ });
94
+ });
95
+ if (result.done)
96
+ return;
97
+ yield result.value;
98
+ }
99
+ }
100
+ finally {
101
+ // Fire-and-forget cleanup. If the source is suspended on a never-
102
+ // settling read (worker dead), `.return()` propagates an abrupt
103
+ // completion through the for-await chain so inner streams get
104
+ // destroyed. The `.catch` swallows the case where the underlying
105
+ // stream cannot honour `.return()` cleanly.
106
+ void source.return?.(undefined).catch(() => { });
107
+ }
108
+ }
16
109
  function checkAndThrowError(response) {
17
110
  if (response.type === "error") {
18
111
  // Use the typed-error reconstructor map in `rpc-error.ts` so the
@@ -23,12 +116,28 @@ function checkAndThrowError(response) {
23
116
  throw reconstructError(response);
24
117
  }
25
118
  }
119
+ function invalidateRPCInstance() {
120
+ rpcInstance = null;
121
+ firstConnectionPending = true;
122
+ resetConnectionTracking();
123
+ }
26
124
  async function getRPCInstance() {
27
125
  if (rpcInstance)
28
126
  return { rpc: await rpcInstance };
29
127
  const connectionStart = firstConnectionPending ? nowMs() : null;
30
128
  rpcInstance = getRPC();
31
129
  const rpc = await rpcInstance;
130
+ // Drop the cache on worker death so the next call respawns and
131
+ // records connectionMs again as a "first" connection.
132
+ const lifeSignal = getWorkerLifeSignal();
133
+ if (lifeSignal?.aborted) {
134
+ invalidateRPCInstance();
135
+ }
136
+ else if (lifeSignal) {
137
+ lifeSignal.addEventListener("abort", invalidateRPCInstance, {
138
+ once: true,
139
+ });
140
+ }
32
141
  if (connectionStart !== null && firstConnectionPending) {
33
142
  firstConnectionPending = false;
34
143
  return { rpc, connectionMs: nowMs() - connectionStart };
@@ -63,7 +172,7 @@ async function sendBase(request, rpc, options, signalDisable = false) {
63
172
  : parsedRequest;
64
173
  const payload = JSON.stringify(payloadObj);
65
174
  req.send(payload, "utf-8");
66
- const response = await withTimeout(req.reply("utf-8"), options?.timeout);
175
+ const response = await awaitWithLifeSignal(withTimeout(req.reply("utf-8"), options?.timeout));
67
176
  const resPayload = responseSchema.parse(JSON.parse(response?.toString() || "{}"));
68
177
  logger.debug("ResPayload", { type: resPayload.type });
69
178
  checkAndThrowError(resPayload);
@@ -87,7 +196,7 @@ async function sendProfiled(request, rpc, options) {
87
196
  timings.requestStringifyMs = nowMs() - stringifyStart;
88
197
  timings.sendStart = nowMs();
89
198
  req.send(payload, "utf-8");
90
- const response = await withTimeout(req.reply("utf-8"), options?.timeout);
199
+ const response = await awaitWithLifeSignal(withTimeout(req.reply("utf-8"), options?.timeout));
91
200
  timings.firstResponseAt = nowMs();
92
201
  const parseStart = nowMs();
93
202
  const rawParsed = JSON.parse(response?.toString() || "{}");
@@ -140,7 +249,7 @@ async function* streamBase(request, rpc, options = {}, signalDisable = false) {
140
249
  }
141
250
  }
142
251
  const streamWithTimeout = withTimeoutStream(processStream(), options?.timeout);
143
- for await (const chunk of streamWithTimeout) {
252
+ for await (const chunk of iterateWithLifeSignal(streamWithTimeout)) {
144
253
  buffer += chunk.toString();
145
254
  // Process complete lines (newline-delimited JSON)
146
255
  const lines = buffer.split("\n");
@@ -181,7 +290,7 @@ async function* streamProfiled(request, rpc, options = {}) {
181
290
  }
182
291
  }
183
292
  const streamWithTimeout = withTimeoutStream(processStream(), options?.timeout);
184
- for await (const chunk of streamWithTimeout) {
293
+ for await (const chunk of iterateWithLifeSignal(streamWithTimeout)) {
185
294
  const chunkTime = nowMs();
186
295
  if (timings.firstChunkAt === undefined) {
187
296
  timings.firstChunkAt = chunkTime;
@@ -361,8 +470,6 @@ async function duplexProfiled(request, options = {}) {
361
470
  export async function close() {
362
471
  if (!rpcInstance)
363
472
  return;
364
- rpcInstance = null;
365
- firstConnectionPending = true;
366
- resetConnectionTracking();
473
+ invalidateRPCInstance();
367
474
  await closeRPC();
368
475
  }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { SUPPORTED_AUDIO_FORMATS } from "./constants/audio";
10
10
  export { InferenceCancelledError } from "./utils/errors-server";
11
11
  export type { InferenceCancelledPartial } from "./utils/errors-server";
12
12
  export { ContextOverflowError, RequestIdConflictError, RequestNotFoundError, RequestRejectedByPolicyError, } from "./utils/errors-server";
13
+ export { WorkerCrashedError, WorkerShutdownError, } from "./utils/errors-client";
13
14
  export { getLogger, SDK_LOG_ID } from "./logging/index";
14
15
  export type { Logger, LogTransport, LoggerOptions } from "./logging/index";
15
16
  export { profiler } from "./profiling/index";
package/dist/index.js CHANGED
@@ -30,6 +30,11 @@ export { SUPPORTED_AUDIO_FORMATS } from "./constants/audio.js";
30
30
  // the worker side.
31
31
  export { InferenceCancelledError } from "./utils/errors-server.js";
32
32
  export { ContextOverflowError, RequestIdConflictError, RequestNotFoundError, RequestRejectedByPolicyError, } from "./utils/errors-server.js";
33
+ // `WorkerCrashedError` and `WorkerShutdownError` are thrown by the
34
+ // rpc-client life-signal race when the bare worker exits unexpectedly
35
+ // or close()/process-exit teardown runs while a caller is in flight.
36
+ // Exported so consumers can pattern-match with `instanceof`.
37
+ export { WorkerCrashedError, WorkerShutdownError, } from "./utils/errors-client.js";
33
38
  // Logging exports
34
39
  export { getLogger, SDK_LOG_ID } from "./logging/index.js";
35
40
  // Profiler exports
@@ -14,6 +14,8 @@ export declare const SDK_CLIENT_ERROR_CODES: {
14
14
  readonly RPC_RESPONSE_STREAM_NOT_CREATED: 50202;
15
15
  readonly RPC_CONNECTION_FAILED: 50203;
16
16
  readonly RPC_INIT_TIMEOUT: 50204;
17
+ readonly WORKER_CRASHED: 50205;
18
+ readonly WORKER_SHUTDOWN: 50206;
17
19
  readonly PROVIDER_START_FAILED: 50400;
18
20
  readonly PROVIDER_STOP_FAILED: 50401;
19
21
  readonly DELEGATE_NO_FINAL_RESPONSE: 50402;
@@ -17,6 +17,8 @@ export const SDK_CLIENT_ERROR_CODES = {
17
17
  RPC_RESPONSE_STREAM_NOT_CREATED: 50202,
18
18
  RPC_CONNECTION_FAILED: 50203,
19
19
  RPC_INIT_TIMEOUT: 50204,
20
+ WORKER_CRASHED: 50205,
21
+ WORKER_SHUTDOWN: 50206,
20
22
  // Provider/Delegation Errors (50,400-50,599)
21
23
  PROVIDER_START_FAILED: 50400,
22
24
  PROVIDER_STOP_FAILED: 50401,
@@ -100,6 +102,14 @@ const clientErrorDefinitions = {
100
102
  name: "RPC_INIT_TIMEOUT",
101
103
  message: (timeoutMs) => `RPC initialization timed out after ${timeoutMs}ms — the worker process may have failed to start`,
102
104
  },
105
+ [SDK_CLIENT_ERROR_CODES.WORKER_CRASHED]: {
106
+ name: "WORKER_CRASHED",
107
+ message: (code, signal) => `Bare worker exited mid-request (code=${code}, signal=${signal}) — in-flight calls were aborted`,
108
+ },
109
+ [SDK_CLIENT_ERROR_CODES.WORKER_SHUTDOWN]: {
110
+ name: "WORKER_SHUTDOWN",
111
+ message: () => `SDK is shutting down — in-flight RPC call aborted`,
112
+ },
103
113
  // Provider/Delegation Errors (50,400-50,599)
104
114
  [SDK_CLIENT_ERROR_CODES.PROVIDER_START_FAILED]: {
105
115
  name: "PROVIDER_START_FAILED",
@@ -22,12 +22,16 @@ function coerceParamValue(raw, schema) {
22
22
  throw new Error(`invalid integer value: "${trimmed}"`);
23
23
  return n;
24
24
  }
25
- case "boolean":
26
- if (trimmed === "true")
25
+ case "boolean": {
26
+ // Qwen3.5/3.6 intermittently emit Python-style capitalised booleans
27
+ // (`True`/`False`); accept any casing so a valid call isn't dropped.
28
+ const v = trimmed.toLowerCase();
29
+ if (v === "true")
27
30
  return true;
28
- if (trimmed === "false")
31
+ if (v === "false")
29
32
  return false;
30
33
  throw new Error(`invalid boolean value: "${trimmed}"`);
34
+ }
31
35
  case "array":
32
36
  case "object":
33
37
  return JSON.parse(trimmed);
@@ -41,6 +41,14 @@ export declare class RPCConnectionFailedError extends QvacErrorBase {
41
41
  export declare class RPCInitTimeoutError extends QvacErrorBase {
42
42
  constructor(timeoutMs: number, cause?: unknown);
43
43
  }
44
+ export declare class WorkerCrashedError extends QvacErrorBase {
45
+ readonly exitCode: number | null;
46
+ readonly exitSignal: NodeJS.Signals | null;
47
+ constructor(exitCode: number | null, exitSignal: NodeJS.Signals | null, cause?: unknown);
48
+ }
49
+ export declare class WorkerShutdownError extends QvacErrorBase {
50
+ constructor(cause?: unknown);
51
+ }
44
52
  export declare class ProviderStartFailedError extends QvacErrorBase {
45
53
  constructor(details?: string, cause?: unknown);
46
54
  }
@@ -74,6 +74,20 @@ export class RPCInitTimeoutError extends QvacErrorBase {
74
74
  super(createErrorOptions(SDK_CLIENT_ERROR_CODES.RPC_INIT_TIMEOUT, [timeoutMs], cause));
75
75
  }
76
76
  }
77
+ export class WorkerCrashedError extends QvacErrorBase {
78
+ exitCode;
79
+ exitSignal;
80
+ constructor(exitCode, exitSignal, cause) {
81
+ super(createErrorOptions(SDK_CLIENT_ERROR_CODES.WORKER_CRASHED, [String(exitCode), String(exitSignal)], cause));
82
+ this.exitCode = exitCode;
83
+ this.exitSignal = exitSignal;
84
+ }
85
+ }
86
+ export class WorkerShutdownError extends QvacErrorBase {
87
+ constructor(cause) {
88
+ super(createErrorOptions(SDK_CLIENT_ERROR_CODES.WORKER_SHUTDOWN, undefined, cause));
89
+ }
90
+ }
77
91
  // ============== Provider/Delegation Errors ==============
78
92
  export class ProviderStartFailedError extends QvacErrorBase {
79
93
  constructor(details, cause) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/sdk",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -131,6 +131,11 @@
131
131
  "types": "./dist/commands/index.d.ts",
132
132
  "import": "./dist/commands/index.js"
133
133
  },
134
+ "./models": {
135
+ "types": "./dist/models/registry/index.d.ts",
136
+ "import": "./dist/models/registry/index.js",
137
+ "require": "./dist/models/registry/index.js"
138
+ },
134
139
  "./pear-pre": "./dist/pear/pre.js"
135
140
  },
136
141
  "imports": {