@qvac/sdk 0.13.2 → 0.13.4

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.
@@ -10,8 +10,9 @@ 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, WorkerCrashedError, WorkerShutdownError, } from "../../utils/errors-client.js";
13
+ import { BareRuntimeBinaryNotFoundError, RPCInitTimeoutError, WorkerCrashedError, WorkerShutdownError, } from "../../utils/errors-client.js";
14
14
  const RPC_INIT_TIMEOUT_MS = 30_000;
15
+ const WORKER_STDERR_TAIL_CHARS = 16_384;
15
16
  const logger = getClientLogger();
16
17
  let rpcInstance = null;
17
18
  let rpcPromise = null;
@@ -144,21 +145,74 @@ function bestEffortUnlinkSocket(socketPath) {
144
145
  logger.debug("Failed to unlink IPC socket path", { socketPath, error });
145
146
  }
146
147
  }
147
- function snapshotAndResetState() {
148
- const workerToClose = bareWorkerProc;
149
- const serverToClose = ipcServer;
150
- const socketPathToClose = currentSocketPath;
148
+ function appendWorkerStderrTail(current, chunk) {
149
+ const next = current + chunk;
150
+ if (next.length <= WORKER_STDERR_TAIL_CHARS)
151
+ return next;
152
+ return next.slice(next.length - WORKER_STDERR_TAIL_CHARS);
153
+ }
154
+ function createWorkerStartupError(details, stderrTail) {
155
+ const stderr = stderrTail.trimEnd();
156
+ if (!stderr)
157
+ return new Error(details);
158
+ return new Error(`${details}\n\nWorker stderr:\n${stderr}`);
159
+ }
160
+ function resetModuleState() {
151
161
  rpcInstance = null;
152
162
  rpcPromise = null;
153
163
  bareWorkerProc = null;
154
164
  ipcServer = null;
155
165
  currentSocketPath = null;
156
166
  workerLifeController = null;
167
+ }
168
+ function snapshotAndResetState() {
169
+ const workerToClose = bareWorkerProc;
170
+ const serverToClose = ipcServer;
171
+ const socketPathToClose = currentSocketPath;
172
+ resetModuleState();
157
173
  return { workerToClose, serverToClose, socketPathToClose };
158
174
  }
175
+ // Shared cleanup for every init reject path: reset module state, stop the
176
+ // worker + IPC server, and remove the socket so a failed start never leaks
177
+ // resources. Mirrors closeSyncForExit without the planned-shutdown abort.
178
+ function teardownFailedInit() {
179
+ const { workerToClose, serverToClose, socketPathToClose } = snapshotAndResetState();
180
+ if (workerToClose) {
181
+ try {
182
+ workerToClose.kill("SIGTERM");
183
+ }
184
+ catch (error) {
185
+ logger.debug("Failed to kill bare worker after init failure", { error });
186
+ }
187
+ }
188
+ if (serverToClose) {
189
+ try {
190
+ serverToClose.close();
191
+ }
192
+ catch (error) {
193
+ logger.debug("Failed to close IPC server after init failure", { error });
194
+ }
195
+ }
196
+ bestEffortUnlinkSocket(socketPathToClose);
197
+ }
159
198
  export function getWorkerLifeSignal() {
160
199
  return workerLifeController?.signal ?? null;
161
200
  }
201
+ function getWorkerStderr(proc) {
202
+ return proc.stderr ?? null;
203
+ }
204
+ // `bare-runtime` resolves its platform binary with
205
+ // `require('bare-runtime-<platform>-<arch>')` and throws a terse
206
+ // `No binaries found for target '<platform>-<arch>'` whenever that package —
207
+ // or one of its nested deps — is absent. Under pnpm that happens even on a
208
+ // supported platform, so surface an actionable error instead of the raw throw.
209
+ function mapBareSpawnError(error) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ if (/No binar(?:y|ies) found for target/i.test(message)) {
212
+ return new BareRuntimeBinaryNotFoundError(process.platform, process.arch, error);
213
+ }
214
+ return error instanceof Error ? error : new Error(message);
215
+ }
162
216
  // `spawn` carries this listener's own captured resources. Module-level
163
217
  // state may belong to a newer ensureRPC() by the time we fire.
164
218
  function handlePostHandshakeExit(code, exitSignal, spawn) {
@@ -170,12 +224,7 @@ function handlePostHandshakeExit(code, exitSignal, spawn) {
170
224
  logger.info(`🪦 Bare worker exited post-handshake (code=${code}, signal=${exitSignal})`);
171
225
  // Only clear module state if we're still the active spawn.
172
226
  if (workerLifeController === spawn.controller) {
173
- rpcInstance = null;
174
- rpcPromise = null;
175
- bareWorkerProc = null;
176
- ipcServer = null;
177
- currentSocketPath = null;
178
- workerLifeController = null;
227
+ resetModuleState();
179
228
  }
180
229
  try {
181
230
  spawn.server.close();
@@ -223,17 +272,16 @@ async function ensureRPC() {
223
272
  workerLifeController = spawnController;
224
273
  rpcPromise = new Promise((resolve, reject) => {
225
274
  let settled = false;
275
+ let workerStderrTail = "";
226
276
  const timer = setTimeout(() => {
227
277
  if (settled)
228
278
  return;
229
279
  settled = true;
230
- rpcPromise = null;
231
- rpcInstance = null;
232
- bareWorkerProc = null;
233
- ipcServer = null;
234
- currentSocketPath = null;
235
- workerLifeController = null;
236
- reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS));
280
+ const cause = workerStderrTail
281
+ ? createWorkerStartupError("Worker did not establish IPC before the RPC initialization timeout", workerStderrTail)
282
+ : undefined;
283
+ teardownFailedInit();
284
+ reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS, cause));
237
285
  }, RPC_INIT_TIMEOUT_MS);
238
286
  ipcServer = createServer((socket) => {
239
287
  if (settled)
@@ -248,12 +296,7 @@ async function ensureRPC() {
248
296
  return;
249
297
  settled = true;
250
298
  clearTimeout(timer);
251
- rpcPromise = null;
252
- rpcInstance = null;
253
- bareWorkerProc = null;
254
- ipcServer = null;
255
- currentSocketPath = null;
256
- workerLifeController = null;
299
+ teardownFailedInit();
257
300
  reject(error);
258
301
  });
259
302
  ipcServer.listen(socketPath, () => {
@@ -262,35 +305,61 @@ async function ensureRPC() {
262
305
  server: ipcServer,
263
306
  socketPath,
264
307
  };
265
- bareWorkerProc = spawn("bare", {
266
- args: [
267
- WORKER_PATH,
268
- JSON.stringify({
269
- QVAC_IPC_SOCKET_PATH: socketPath,
270
- // Snap's HOME can be revision-scoped; SNAP_USER_COMMON is stable.
271
- HOME_DIR: process.env["SNAP_USER_COMMON"]
272
- ? String(process.env["SNAP_USER_COMMON"])
273
- : os.homedir(),
274
- }),
275
- ],
276
- stdio: ["inherit", "inherit", "inherit"],
277
- });
308
+ try {
309
+ bareWorkerProc = spawn("bare", {
310
+ args: [
311
+ WORKER_PATH,
312
+ JSON.stringify({
313
+ QVAC_IPC_SOCKET_PATH: socketPath,
314
+ // Snap's HOME can be revision-scoped; SNAP_USER_COMMON is stable.
315
+ HOME_DIR: process.env["SNAP_USER_COMMON"]
316
+ ? String(process.env["SNAP_USER_COMMON"])
317
+ : os.homedir(),
318
+ }),
319
+ ],
320
+ platform: process.platform,
321
+ arch: process.arch,
322
+ stdio: ["inherit", "inherit", "pipe"],
323
+ });
324
+ }
325
+ catch (error) {
326
+ // `spawn` resolves the bare binary synchronously and can throw before
327
+ // the worker exists, so there is no process to emit "exit". Without
328
+ // this, the throw escapes the `listen` callback as an uncaught
329
+ // exception and crashes the host process.
330
+ if (settled)
331
+ return;
332
+ settled = true;
333
+ clearTimeout(timer);
334
+ teardownFailedInit();
335
+ reject(mapBareSpawnError(error));
336
+ return;
337
+ }
278
338
  if (bareWorkerProc) {
339
+ getWorkerStderr(bareWorkerProc)?.on("data", (chunk) => {
340
+ const text = chunk.toString();
341
+ workerStderrTail = appendWorkerStderrTail(workerStderrTail, text);
342
+ process.stderr.write(chunk);
343
+ });
279
344
  bareWorkerProc.on("exit", (code, exitSignal) => {
280
345
  if (settled) {
281
346
  handlePostHandshakeExit(code, exitSignal, spawnResources);
282
347
  return;
283
348
  }
284
- // Worker died before handshake — reject the init promise.
349
+ // Pre-handshake failures are rejected from "close" so stderr has
350
+ // drained before we assemble the startup error cause.
351
+ });
352
+ bareWorkerProc.on("close", (...args) => {
353
+ if (settled)
354
+ return;
355
+ const code = typeof args[0] === "number" ? args[0] : null;
356
+ const exitSignal = typeof args[1] === "string" ? args[1] : null;
357
+ // Worker died before handshake. Use close, not exit, so piped
358
+ // stderr has drained before we build the error cause.
285
359
  settled = true;
286
360
  clearTimeout(timer);
287
- rpcPromise = null;
288
- rpcInstance = null;
289
- bareWorkerProc = null;
290
- ipcServer = null;
291
- currentSocketPath = null;
292
- workerLifeController = null;
293
- reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS, new Error(`Worker process exited with code ${code} before IPC connection was established`)));
361
+ teardownFailedInit();
362
+ reject(new RPCInitTimeoutError(RPC_INIT_TIMEOUT_MS, createWorkerStartupError(`Worker process exited with code ${code}, signal ${exitSignal} before IPC connection was established`, workerStderrTail)));
294
363
  });
295
364
  }
296
365
  });
package/dist/index.d.ts CHANGED
@@ -10,7 +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
+ export { BareRuntimeBinaryNotFoundError, WorkerCrashedError, WorkerShutdownError, } from "./utils/errors-client";
14
14
  export { getLogger, SDK_LOG_ID } from "./logging/index";
15
15
  export type { Logger, LogTransport, LoggerOptions } from "./logging/index";
16
16
  export { profiler } from "./profiling/index";
package/dist/index.js CHANGED
@@ -35,8 +35,11 @@ export { ContextOverflowError, RequestIdConflictError, RequestNotFoundError, Req
35
35
  // `WorkerCrashedError` and `WorkerShutdownError` are thrown by the
36
36
  // rpc-client life-signal race when the bare worker exits unexpectedly
37
37
  // or close()/process-exit teardown runs while a caller is in flight.
38
- // Exported so consumers can pattern-match with `instanceof`.
39
- export { WorkerCrashedError, WorkerShutdownError, } from "./utils/errors-client.js";
38
+ // `BareRuntimeBinaryNotFoundError` is thrown when the worker fails to
39
+ // spawn because the platform's `bare-runtime-<platform>-<arch>` package is
40
+ // missing (common under pnpm). Exported so consumers can pattern-match with
41
+ // `instanceof`.
42
+ export { BareRuntimeBinaryNotFoundError, WorkerCrashedError, WorkerShutdownError, } from "./utils/errors-client.js";
40
43
  // Logging exports
41
44
  export { getLogger, SDK_LOG_ID } from "./logging/index.js";
42
45
  // Profiler exports
@@ -35,6 +35,7 @@ export declare const SDK_CLIENT_ERROR_CODES: {
35
35
  readonly BARE_PACK_ERROR: 50611;
36
36
  readonly INVALID_PLUGIN_SPECIFIER: 50612;
37
37
  readonly BARE_IMPORTS_MAP_NOT_FOUND: 50613;
38
+ readonly BARE_RUNTIME_BINARY_NOT_FOUND: 50614;
38
39
  readonly PROFILER_INVALID_CAPACITY: 50800;
39
40
  };
40
41
  declare const clientErrorDefinitions: ErrorCodesMap;
@@ -40,6 +40,7 @@ export const SDK_CLIENT_ERROR_CODES = {
40
40
  BARE_PACK_ERROR: 50611,
41
41
  INVALID_PLUGIN_SPECIFIER: 50612,
42
42
  BARE_IMPORTS_MAP_NOT_FOUND: 50613,
43
+ BARE_RUNTIME_BINARY_NOT_FOUND: 50614,
43
44
  // Profiler Errors (50,800-50,899)
44
45
  PROFILER_INVALID_CAPACITY: 50800,
45
46
  };
@@ -188,6 +189,10 @@ const clientErrorDefinitions = {
188
189
  name: "BARE_IMPORTS_MAP_NOT_FOUND",
189
190
  message: (sdkName, expectedPath) => `bare-imports.json not found.\n\n Expected at: ${expectedPath}\n\n Make sure ${sdkName} is installed in your project.`,
190
191
  },
192
+ [SDK_CLIENT_ERROR_CODES.BARE_RUNTIME_BINARY_NOT_FOUND]: {
193
+ name: "BARE_RUNTIME_BINARY_NOT_FOUND",
194
+ message: (platform, arch) => `Could not load the Bare runtime binary for ${platform}-${arch}. The platform package "bare-runtime-${platform}-${arch}" (or one of its dependencies) is missing from node_modules — commonly seen with pnpm, which does not always install nested optional dependencies. Fix it by installing the platform package directly (e.g. \`pnpm add bare-runtime-${platform}-${arch}\`) or by installing with npm or bun. See https://github.com/tetherto/qvac/issues/1492`,
195
+ },
191
196
  // Profiler Errors (50,800-50,899)
192
197
  [SDK_CLIENT_ERROR_CODES.PROFILER_INVALID_CAPACITY]: {
193
198
  name: "PROFILER_INVALID_CAPACITY",
@@ -1,4 +1,26 @@
1
1
  import { generateStableToolCallId, isValidToolCall, validateToolArguments, } from "../shared.js";
2
+ // Qwen3.5/3.6 can fuse its two tool templates into one frame, embedding the
3
+ // XML `<function=NAME>` token as a bare string key inside the JSON envelope:
4
+ // {"function=NAME","arguments":{...}} (invalid JSON, not parseable as-is)
5
+ // Rewrite only that exact shape to a canonical `{"name":NAME,"arguments":...}`
6
+ // frame. Kept deliberately narrow so well-formed JSON frames are never touched.
7
+ function repairFunctionEqualsJson(candidate) {
8
+ const match = /^\{\s*"function=([^"]+)"\s*,\s*"arguments"\s*:\s*([\s\S]+)\}\s*$/.exec(candidate);
9
+ if (!match)
10
+ return undefined;
11
+ return `{"name":${JSON.stringify(match[1])},"arguments":${match[2]}}`;
12
+ }
13
+ function parseHermesJson(candidate) {
14
+ try {
15
+ return JSON.parse(candidate);
16
+ }
17
+ catch (err) {
18
+ const repaired = repairFunctionEqualsJson(candidate);
19
+ if (repaired === undefined)
20
+ throw err;
21
+ return JSON.parse(repaired);
22
+ }
23
+ }
2
24
  // Hermes-style: JSON payload wrapped in `<tool_call>...</tool_call>` tags
3
25
  export function parseHermesFormat(text, tools) {
4
26
  const toolCalls = [];
@@ -21,7 +43,7 @@ export function parseHermesFormat(text, tools) {
21
43
  const trimmedJson = callJson.trim();
22
44
  let callItem;
23
45
  try {
24
- callItem = JSON.parse(trimmedJson);
46
+ callItem = parseHermesJson(trimmedJson);
25
47
  }
26
48
  catch (error) {
27
49
  errors.push({
@@ -69,7 +91,7 @@ function recoverIncompleteHermesFrame(text, tools) {
69
91
  }
70
92
  let parsed;
71
93
  try {
72
- parsed = JSON.parse(candidate);
94
+ parsed = parseHermesJson(candidate);
73
95
  }
74
96
  catch {
75
97
  return { matched: false, toolCalls: [], errors: [] };
@@ -94,6 +94,9 @@ export declare class InvalidPluginSpecifierError extends QvacErrorBase {
94
94
  export declare class BareImportsMapNotFoundError extends QvacErrorBase {
95
95
  constructor(sdkName: string, expectedPath: string, cause?: unknown);
96
96
  }
97
+ export declare class BareRuntimeBinaryNotFoundError extends QvacErrorBase {
98
+ constructor(platform: string, arch: string, cause?: unknown);
99
+ }
97
100
  export declare class ConfigFileNotFoundError extends QvacErrorBase {
98
101
  constructor(searchPaths: string, cause?: unknown);
99
102
  }
@@ -166,6 +166,11 @@ export class BareImportsMapNotFoundError extends QvacErrorBase {
166
166
  super(createErrorOptions(SDK_CLIENT_ERROR_CODES.BARE_IMPORTS_MAP_NOT_FOUND, [sdkName, expectedPath], cause));
167
167
  }
168
168
  }
169
+ export class BareRuntimeBinaryNotFoundError extends QvacErrorBase {
170
+ constructor(platform, arch, cause) {
171
+ super(createErrorOptions(SDK_CLIENT_ERROR_CODES.BARE_RUNTIME_BINARY_NOT_FOUND, [platform, arch], cause));
172
+ }
173
+ }
169
174
  export class ConfigFileNotFoundError extends QvacErrorBase {
170
175
  constructor(searchPaths, cause) {
171
176
  super(createErrorOptions(SDK_CLIENT_ERROR_CODES.CONFIG_FILE_NOT_FOUND, [searchPaths], cause));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/sdk",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -208,7 +208,7 @@
208
208
  "dependencies": {
209
209
  "@qvac/bci-whispercpp": "^0.2.0",
210
210
  "@qvac/classification-ggml": "^0.3.1",
211
- "@qvac/decoder-audio": "^0.4.0",
211
+ "@qvac/decoder-audio": "^0.5.0",
212
212
  "@qvac/diffusion-cpp": "^0.11.2",
213
213
  "@qvac/embed-llamacpp": "^0.19.1",
214
214
  "@qvac/error": "^0.1.1",