@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 +4 -12
- package/dist/client/config-loader/resolve-config.bare.d.ts +1 -1
- package/dist/client/config-loader/resolve-config.bare.js +21 -35
- package/dist/client/rpc/bare-client.d.ts +1 -0
- package/dist/client/rpc/bare-client.js +4 -0
- package/dist/client/rpc/expo-rpc-client.d.ts +1 -0
- package/dist/client/rpc/expo-rpc-client.js +4 -0
- package/dist/client/rpc/node-rpc-client.d.ts +1 -0
- package/dist/client/rpc/node-rpc-client.js +93 -4
- package/dist/client/rpc/rpc-client.js +115 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/schemas/sdk-errors-client.d.ts +2 -0
- package/dist/schemas/sdk-errors-client.js +10 -0
- package/dist/server/utils/tools/parsers/qwen35.js +7 -3
- package/dist/utils/errors-client.d.ts +8 -0
- package/dist/utils/errors-client.js +14 -0
- package/package.json +6 -1
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
|
-
|
|
269
|
+
https://huggingface.co/ResembleAI/chatterbox-turbo
|
|
270
270
|
chatterbox-s3gen-mtl
|
|
271
|
-
|
|
271
|
+
https://huggingface.co/ResembleAI/chatterbox
|
|
272
272
|
chatterbox-t3-mtl
|
|
273
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
+
function loadConfigFromPath(filePath) {
|
|
26
|
+
assertBareConfigExtension(filePath);
|
|
28
27
|
try {
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
77
|
-
if (
|
|
78
|
-
const config =
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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": {
|