@juspay/neurolink 11.30.0 → 12.0.1

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.
@@ -15,6 +15,7 @@ import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifec
15
15
  import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { interleaveTTSStream } from "../utils/ttsStream.js";
18
+ import { attachStreamCancel, cancelStream, releaseIterator, } from "../utils/streamCancellation.js";
18
19
  import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
19
20
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
20
21
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
@@ -383,11 +384,20 @@ export class BaseProvider {
383
384
  // Arrow, like `safeFire` above: the generator is a plain function expression, so
384
385
  // `this` is not bound inside it.
385
386
  const classifyStreamError = (e) => this.classifyStreamError(e);
387
+ // Hold the upstream iterator rather than letting `for await` create one
388
+ // internally, so the cancel hook below can close it directly. Iterating
389
+ // `upstreamIterable` is equivalent to iterating `originalStream` — same
390
+ // iterator, same early-exit `return()` semantics — it just leaves a handle
391
+ // reachable from outside the generator.
392
+ const upstreamIterator = originalStream[Symbol.asyncIterator]();
393
+ const upstreamIterable = {
394
+ [Symbol.asyncIterator]: () => upstreamIterator,
395
+ };
386
396
  const wrappedStream = (async function* () {
387
397
  let accumulated = "";
388
398
  let seq = 0;
389
399
  try {
390
- for await (const chunk of originalStream) {
400
+ for await (const chunk of upstreamIterable) {
391
401
  const textPart = chunk &&
392
402
  typeof chunk === "object" &&
393
403
  "content" in chunk &&
@@ -434,6 +444,15 @@ export class BaseProvider {
434
444
  throw classifyStreamError(err);
435
445
  }
436
446
  })();
447
+ // A consumer that breaks out of the stream cannot reach this generator
448
+ // through `.return()` while it is parked awaiting the provider — that
449
+ // request queues behind the in-flight `next()`. The hook closes the
450
+ // upstream directly and forwards the request to any wrapper below, so
451
+ // abandoning a stream really does release the provider connection.
452
+ attachStreamCancel(wrappedStream, () => {
453
+ cancelStream(originalStream);
454
+ releaseIterator(upstreamIterator);
455
+ });
437
456
  return { ...result, stream: wrappedStream };
438
457
  }
439
458
  /**
package/dist/neurolink.js CHANGED
@@ -1229,9 +1229,23 @@ export class NeuroLink {
1229
1229
  });
1230
1230
  });
1231
1231
  // Fire-and-forget: registrations complete before any generate/stream call
1232
- // because those calls await initializeMCP() which is slower
1232
+ // because those calls await initializeMCP() which is slower.
1233
+ //
1234
+ // The rejection handler is not decoration. registerTool() throws on a
1235
+ // failed name/description validation (mcp/toolRegistry.ts), and that throw
1236
+ // is not inside a try — so a rejected registration on a `void`-detached
1237
+ // one-argument `.then()` would be an unhandled rejection, which terminates
1238
+ // the process. Today the tool names come from createFileTools(), which are
1239
+ // internal constants that pass validation, so this is latent rather than
1240
+ // live; it stops being latent the moment a name is derived from anything
1241
+ // outside this file. Losing one tool registration is the intended failure
1242
+ // mode here — losing the host process is not.
1233
1243
  void Promise.all(registrations).then(() => {
1234
1244
  logger.debug(`[NeuroLink] Registered ${Object.keys(fileTools).length} file reference tools`);
1245
+ }, (error) => {
1246
+ logger.warn("[NeuroLink] File tool registration failed", {
1247
+ error: error instanceof Error ? error.message : String(error),
1248
+ });
1235
1249
  });
1236
1250
  }
1237
1251
  /**
@@ -1253,7 +1267,15 @@ export class NeuroLink {
1253
1267
  // registerTool is async but its core logic is synchronous (Map.set).
1254
1268
  // We fire-and-forget here but tools are available immediately after
1255
1269
  // the synchronous validation + map insertion completes.
1256
- void this.toolRegistry.registerTool(toolId, toolInfo, {
1270
+ //
1271
+ // The .catch() at the end of this call is required for the same reason
1272
+ // as in registerFileTools() above: registerTool() throws on failed
1273
+ // validation, and an unhandled rejection off a `void`-detached call
1274
+ // terminates the process rather than failing this one registration.
1275
+ // Latent today (createTaskTools() supplies internal, valid names), and
1276
+ // cheap to keep correct.
1277
+ void this.toolRegistry
1278
+ .registerTool(toolId, toolInfo, {
1257
1279
  execute: async (params) => {
1258
1280
  try {
1259
1281
  const result = await toolDef.execute(params, {
@@ -1276,6 +1298,12 @@ export class NeuroLink {
1276
1298
  },
1277
1299
  description: toolDef.description,
1278
1300
  inputSchema: {},
1301
+ })
1302
+ .catch((error) => {
1303
+ logger.warn("[NeuroLink] Task tool registration failed", {
1304
+ toolId,
1305
+ error: error instanceof Error ? error.message : String(error),
1306
+ });
1279
1307
  });
1280
1308
  }
1281
1309
  logger.debug(`[NeuroLink] Registered ${Object.keys(taskTools).length} task tools`);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Out-of-band cancellation for chained async streams.
3
+ *
4
+ * `AsyncGenerator.prototype.return()` is the obvious way to tell a stream its
5
+ * consumer has gone away, and it does not work through a chain of generators.
6
+ * The request is *queued* behind whatever `next()` is already in flight; it
7
+ * cannot interrupt an `await`. A provider stream is wrapped several times
8
+ * (lifecycle in `baseProvider`, MCP and pool wrappers in `neurolink`), and
9
+ * during a pull every one of those layers is parked inside an `await` on the
10
+ * layer below. So none of them can unwind, the innermost source is never
11
+ * closed, and a teardown that waits for that to happen waits forever.
12
+ *
13
+ * This module is the side channel that does work. A wrapper registers a
14
+ * cancel callback on the stream object it returns; the callback closes its own
15
+ * upstream iterator and forwards the request further down. Because these are
16
+ * ordinary function calls rather than generator protocol, they run immediately
17
+ * — no queue, no dependence on any pending `next()` settling.
18
+ *
19
+ * Registration is optional and reading is defensive, so a stream that knows
20
+ * nothing about this behaves exactly as it did before.
21
+ */
22
+ /**
23
+ * Register `cancel` as `stream`'s teardown hook and return the same object.
24
+ *
25
+ * Non-enumerable so the property never shows up in spreads, `JSON.stringify`
26
+ * or logging of a stream handle.
27
+ */
28
+ export declare function attachStreamCancel<S extends object>(stream: S, cancel: () => void): S;
29
+ /**
30
+ * Invoke `stream`'s cancel hook if it has one.
31
+ *
32
+ * Never throws. This runs from `finally` blocks during teardown, where the
33
+ * consumer has already stopped listening — an exception here would replace
34
+ * whatever outcome the caller was actually returning with a cleanup error.
35
+ */
36
+ export declare function cancelStream(stream: unknown): void;
37
+ /**
38
+ * Close `iterator` without waiting for it and without surfacing a rejection.
39
+ *
40
+ * Deliberately not awaited: on a generator that is parked mid-`await` this
41
+ * promise may never settle, which is the whole failure this module exists to
42
+ * avoid. The call still propagates whenever the iterator can act on it.
43
+ */
44
+ export declare function releaseIterator(iterator: {
45
+ return?: (value?: unknown) => unknown;
46
+ }): void;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Out-of-band cancellation for chained async streams.
3
+ *
4
+ * `AsyncGenerator.prototype.return()` is the obvious way to tell a stream its
5
+ * consumer has gone away, and it does not work through a chain of generators.
6
+ * The request is *queued* behind whatever `next()` is already in flight; it
7
+ * cannot interrupt an `await`. A provider stream is wrapped several times
8
+ * (lifecycle in `baseProvider`, MCP and pool wrappers in `neurolink`), and
9
+ * during a pull every one of those layers is parked inside an `await` on the
10
+ * layer below. So none of them can unwind, the innermost source is never
11
+ * closed, and a teardown that waits for that to happen waits forever.
12
+ *
13
+ * This module is the side channel that does work. A wrapper registers a
14
+ * cancel callback on the stream object it returns; the callback closes its own
15
+ * upstream iterator and forwards the request further down. Because these are
16
+ * ordinary function calls rather than generator protocol, they run immediately
17
+ * — no queue, no dependence on any pending `next()` settling.
18
+ *
19
+ * Registration is optional and reading is defensive, so a stream that knows
20
+ * nothing about this behaves exactly as it did before.
21
+ */
22
+ const STREAM_CANCEL = Symbol.for("neurolink.streamCancel");
23
+ /**
24
+ * Swallow a rejection from a thenable teardown result.
25
+ *
26
+ * An unhandled rejection **terminates the process** — a caller doing everything
27
+ * right still dies, with the stack pointing at library internals it never
28
+ * called. Teardown runs after the consumer has stopped listening, so there is
29
+ * nobody left to hand the error to; dropping it is the only correct outcome,
30
+ * and dropping it deliberately is what keeps it from becoming fatal.
31
+ */
32
+ function suppressRejection(result) {
33
+ if (result !== null &&
34
+ typeof result === "object" &&
35
+ typeof result.then === "function" &&
36
+ typeof result.catch === "function") {
37
+ void result.catch(() => { });
38
+ }
39
+ }
40
+ /**
41
+ * Register `cancel` as `stream`'s teardown hook and return the same object.
42
+ *
43
+ * Non-enumerable so the property never shows up in spreads, `JSON.stringify`
44
+ * or logging of a stream handle.
45
+ */
46
+ export function attachStreamCancel(stream, cancel) {
47
+ Object.defineProperty(stream, STREAM_CANCEL, {
48
+ value: cancel,
49
+ enumerable: false,
50
+ configurable: true,
51
+ writable: true,
52
+ });
53
+ return stream;
54
+ }
55
+ /**
56
+ * Invoke `stream`'s cancel hook if it has one.
57
+ *
58
+ * Never throws. This runs from `finally` blocks during teardown, where the
59
+ * consumer has already stopped listening — an exception here would replace
60
+ * whatever outcome the caller was actually returning with a cleanup error.
61
+ */
62
+ export function cancelStream(stream) {
63
+ if (stream === null || stream === undefined) {
64
+ return;
65
+ }
66
+ if (typeof stream !== "object" && typeof stream !== "function") {
67
+ return;
68
+ }
69
+ try {
70
+ // The property read is inside the try, not before it. Reading a symbol off
71
+ // an arbitrary object can execute user code — a Proxy trap or a throwing
72
+ // getter — and this function is documented as never throwing because it
73
+ // runs from teardown `finally` blocks, where an exception would replace
74
+ // the outcome the caller was actually returning with a cleanup error.
75
+ const hook = stream[STREAM_CANCEL];
76
+ if (typeof hook !== "function") {
77
+ return;
78
+ }
79
+ // `attachStreamCancel` types the hook as `() => void`, but nothing stops a
80
+ // caller registering an `async` function — whose rejection would otherwise
81
+ // be unhandled, and therefore fatal to the process.
82
+ suppressRejection(hook());
83
+ }
84
+ catch {
85
+ // Teardown is best-effort by definition.
86
+ }
87
+ }
88
+ /**
89
+ * Close `iterator` without waiting for it and without surfacing a rejection.
90
+ *
91
+ * Deliberately not awaited: on a generator that is parked mid-`await` this
92
+ * promise may never settle, which is the whole failure this module exists to
93
+ * avoid. The call still propagates whenever the iterator can act on it.
94
+ */
95
+ export function releaseIterator(iterator) {
96
+ try {
97
+ // Read the method inside the try, for the same reason `cancelStream` does:
98
+ // `iterator` is whatever an upstream handed us, and a Proxy trap or a
99
+ // throwing getter turns a property access into arbitrary user code.
100
+ const release = iterator.return;
101
+ if (typeof release !== "function") {
102
+ return;
103
+ }
104
+ suppressRejection(release.call(iterator, undefined));
105
+ }
106
+ catch {
107
+ // Best-effort, as above.
108
+ }
109
+ }
@@ -3,6 +3,7 @@ import { sanitizeErrorCause } from "./logSanitize.js";
3
3
  import { logger } from "./logger.js";
4
4
  import { IncrementalTTSSynthesisError, TTS_ERROR_CODES, TTSProcessor, } from "./ttsProcessor.js";
5
5
  import { TimeoutError as AsyncTimeoutError } from "./async/withTimeout.js";
6
+ import { cancelStream } from "./streamCancellation.js";
6
7
  function getStreamingTTSErrorDetails(error) {
7
8
  const incrementalFailure = error instanceof IncrementalTTSSynthesisError ? error : undefined;
8
9
  const cause = incrementalFailure?.firstError ?? error;
@@ -122,6 +123,20 @@ function aggregateTTSChunks(chunks) {
122
123
  sampleRate: last.sampleRate,
123
124
  };
124
125
  }
126
+ /**
127
+ * Upper bound on how long stream teardown may wait for upstream iterators to
128
+ * acknowledge `.return()`. Closing an iterator is bookkeeping, not work, so a
129
+ * responsive upstream finishes far inside this; an unresponsive one is exactly
130
+ * the case that must not block the consumer. The timer is unref'd so a pending
131
+ * grace period never by itself keeps the process alive.
132
+ */
133
+ const RELEASE_GRACE_MS = 5_000;
134
+ function releaseGraceTimer() {
135
+ return new Promise((resolve) => {
136
+ const timer = setTimeout(resolve, RELEASE_GRACE_MS);
137
+ timer.unref?.();
138
+ });
139
+ }
125
140
  /**
126
141
  * Preserve source-stream backpressure while interleaving incremental TTS audio.
127
142
  * Source chunks are yielded before their derived audio, and TTS failures degrade
@@ -208,6 +223,12 @@ export async function* interleaveTTSStream(params) {
208
223
  finally {
209
224
  if (!completed) {
210
225
  cancelled = true;
226
+ // Tell the upstream chain out of band, before asking politely via
227
+ // `.return()` below. When the consumer breaks mid-pull every wrapper
228
+ // beneath is parked inside an `await` and cannot process a queued
229
+ // `return()`, so this side channel is the only thing that actually
230
+ // reaches — and closes — the provider stream at the bottom.
231
+ cancelStream(stream);
211
232
  }
212
233
  textQueue.end();
213
234
  const releases = [];
@@ -217,7 +238,19 @@ export async function* interleaveTTSStream(params) {
217
238
  if (nextAudio && audioIterator.return) {
218
239
  releases.push(Promise.resolve().then(() => audioIterator.return?.(undefined)));
219
240
  }
220
- await Promise.allSettled(releases);
241
+ // Cleanup must not be able to outlive the consumer that abandoned this
242
+ // stream. `.return()` on an async generator parked inside an `await` is
243
+ // queued behind the in-flight `next()` and cannot interrupt it, so when an
244
+ // upstream pull never settles these promises settle *never* — not late.
245
+ // Awaiting them unbounded turned a consumer's `break` into a permanent
246
+ // hang: the caller's `for await` never returned, with no error and no way
247
+ // to defend against it from outside this module.
248
+ //
249
+ // The releases are still issued, and still propagate normally whenever the
250
+ // upstream is responsive — which is every case where awaiting them was
251
+ // doing anything useful. We only stop making the consumer's exit depend on
252
+ // an upstream that may never answer.
253
+ await Promise.race([Promise.allSettled(releases), releaseGraceTimer()]);
221
254
  if (!completed) {
222
255
  onComplete?.(undefined);
223
256
  }
@@ -52,18 +52,39 @@ export async function attachRealtimeEventBridge(params) {
52
52
  via,
53
53
  bytes: bytes.byteLength,
54
54
  });
55
- if (via === "data") {
56
- void participant.publishData(bytes, {
57
- reliable: true,
58
- topic: eventsTopic,
55
+ // Both calls are detached on purpose — the bridge must never make the
56
+ // caller wait on the UI. But `void` alone does NOT make a rejection
57
+ // harmless, and the try/catch below cannot help: a synchronous catch
58
+ // never sees a rejected promise. Measured — a rejecting publish with
59
+ // this exact shape leaves the catch untouched and, with no
60
+ // unhandledRejection handler installed, terminates the process:
61
+ // SYNC_CATCH_FIRED = false · UNHANDLED_COUNT = 1 · exit 1
62
+ // These are network calls to a peer that can disconnect mid-publish, so
63
+ // the rejection is ordinary, not exotic. Attaching a handler is what
64
+ // actually makes the bridge best-effort, which is what the catch below
65
+ // only claimed. Same class as the detached-pump crash in anthropic.
66
+ const publishFailed = (error) => {
67
+ logger.debug("realtime.bridge.publishFailed", {
68
+ seq,
69
+ type,
70
+ via,
71
+ error: error instanceof Error ? error.message : String(error),
59
72
  });
73
+ };
74
+ if (via === "data") {
75
+ void participant
76
+ .publishData(bytes, { reliable: true, topic: eventsTopic })
77
+ .catch(publishFailed);
60
78
  }
61
79
  else {
62
- void participant.sendText(json, { topic: eventsTopic });
80
+ void participant
81
+ .sendText(json, { topic: eventsTopic })
82
+ .catch(publishFailed);
63
83
  }
64
84
  }
65
85
  catch {
66
- /* non-fatal — the UI bridge is best-effort */
86
+ /* non-fatal — covers the synchronous half only (encode/stringify);
87
+ the detached publishes carry their own handler above. */
67
88
  }
68
89
  };
69
90
  // HITL: WRITE-labeled tools pause for user confirmation. We publish a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.30.0",
3
+ "version": "12.0.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {