@ai-sdk/harness 1.0.117 → 1.0.119

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.
@@ -4,6 +4,7 @@ import {
4
4
  type FlexibleSchema,
5
5
  } from '@ai-sdk/provider-utils';
6
6
  import type { WebSocket } from 'ws';
7
+ import { sleep } from './sleep';
7
8
 
8
9
  /**
9
10
  * Diagnostic event surfaced by {@link SandboxChannel} during its connection
@@ -22,7 +23,10 @@ export type SandboxChannelDebugEvent =
22
23
  };
23
24
 
24
25
  export interface SandboxChannelReconnectOptions {
25
- /** Give up reconnecting after this many milliseconds. Default 30_000. */
26
+ /**
27
+ * Give up reconnecting after this many milliseconds, including connection
28
+ * establishment and backoff delays. Default 30_000.
29
+ */
26
30
  readonly maxElapsedMs?: number;
27
31
  /** First backoff delay. Default 50. */
28
32
  readonly initialDelayMs?: number;
@@ -30,6 +34,10 @@ export interface SandboxChannelReconnectOptions {
30
34
  readonly maxDelayMs?: number;
31
35
  }
32
36
 
37
+ export interface SandboxChannelConnectOptions {
38
+ readonly abortSignal: AbortSignal;
39
+ }
40
+
33
41
  export interface SandboxChannelOptions<TOut> {
34
42
  /**
35
43
  * Open a fresh WebSocket to the bridge and resolve once it is ready to carry
@@ -38,7 +46,7 @@ export interface SandboxChannelOptions<TOut> {
38
46
  * every transient reconnect. Must reject if the connection cannot be
39
47
  * established.
40
48
  */
41
- connect: () => Promise<WebSocket>;
49
+ connect: (options: SandboxChannelConnectOptions) => Promise<WebSocket>;
42
50
 
43
51
  /** Schema validating inbound (bridge → host) frames. */
44
52
  outboundSchema: FlexibleSchema<TOut>;
@@ -99,12 +107,68 @@ export function pinSandboxChannelEventCheckpoint(
99
107
  )[sandboxChannelEventCheckpointSymbol]?.pin();
100
108
  }
101
109
 
102
- const sleep = (ms: number): Promise<void> =>
103
- new Promise(resolve => {
104
- const t = setTimeout(resolve, ms);
105
- (t as { unref?: () => void }).unref?.();
110
+ function getAbortReason({
111
+ abortSignal,
112
+ }: {
113
+ abortSignal: AbortSignal;
114
+ }): unknown {
115
+ return abortSignal.reason ?? new Error('SandboxChannel connection aborted');
116
+ }
117
+
118
+ function closeWebSocket({ ws }: { ws: WebSocket }): void {
119
+ try {
120
+ const terminable = ws as WebSocket & { terminate?: () => void };
121
+ if (terminable.terminate != null) {
122
+ terminable.terminate();
123
+ } else {
124
+ ws.close();
125
+ }
126
+ } catch {
127
+ try {
128
+ ws.close();
129
+ } catch {
130
+ // best-effort
131
+ }
132
+ }
133
+ }
134
+
135
+ async function awaitWebSocketConnection({
136
+ connection,
137
+ abortSignal,
138
+ }: {
139
+ connection: Promise<WebSocket>;
140
+ abortSignal: AbortSignal;
141
+ }): Promise<WebSocket> {
142
+ if (abortSignal.aborted) {
143
+ void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
144
+ throw getAbortReason({ abortSignal });
145
+ }
146
+
147
+ let onAbort: (() => void) | undefined;
148
+ const aborted = new Promise<never>((_, reject) => {
149
+ onAbort = () => reject(getAbortReason({ abortSignal }));
150
+ abortSignal.addEventListener('abort', onAbort, { once: true });
106
151
  });
107
152
 
153
+ try {
154
+ const ws = await Promise.race([connection, aborted]);
155
+ if (abortSignal.aborted) {
156
+ closeWebSocket({ ws });
157
+ throw getAbortReason({ abortSignal });
158
+ }
159
+ return ws;
160
+ } catch (error) {
161
+ if (abortSignal.aborted) {
162
+ void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
163
+ }
164
+ throw error;
165
+ } finally {
166
+ if (onAbort != null) {
167
+ abortSignal.removeEventListener('abort', onAbort);
168
+ }
169
+ }
170
+ }
171
+
108
172
  /**
109
173
  * Host-side typed wrapper around the bridge WebSocket connection.
110
174
  *
@@ -135,7 +199,9 @@ export class SandboxChannel<
135
199
  >();
136
200
  private readonly onReconnectHandlers = new Set<() => void>();
137
201
 
138
- private readonly connectThunk: () => Promise<WebSocket>;
202
+ private readonly connectThunk: (
203
+ options: SandboxChannelConnectOptions,
204
+ ) => Promise<WebSocket>;
139
205
  private readonly outboundSchema: FlexibleSchema<TOut>;
140
206
  private readonly onDebug:
141
207
  | ((event: SandboxChannelDebugEvent) => void)
@@ -169,6 +235,8 @@ export class SandboxChannel<
169
235
  private _lastSeenEventId = 0;
170
236
  private readonly pendingSends: string[] = [];
171
237
  private dispatchChain: Promise<void> = Promise.resolve();
238
+ private activeConnectAbortController: AbortController | undefined;
239
+ private reconnectAbortController: AbortController | undefined;
172
240
 
173
241
  constructor(options: SandboxChannelOptions<TOut>) {
174
242
  this.connectThunk = options.connect;
@@ -206,17 +274,27 @@ export class SandboxChannel<
206
274
  if (this.terminal) {
207
275
  throw new Error('SandboxChannel: cannot open a closed channel.');
208
276
  }
209
- const ws = await this.connectThunk();
210
- this.wire(ws);
211
- this.ws = ws;
212
- this.connected = true;
213
- if (opts?.resume) {
214
- this.rawSend(
215
- JSON.stringify({
216
- type: 'resume',
217
- lastSeenEventId: this._lastSeenEventId,
218
- }),
219
- );
277
+ const abortController = new AbortController();
278
+ this.activeConnectAbortController = abortController;
279
+ try {
280
+ const ws = await this.connect({
281
+ abortSignal: abortController.signal,
282
+ });
283
+ this.wire(ws);
284
+ this.ws = ws;
285
+ this.connected = true;
286
+ if (opts?.resume) {
287
+ this.rawSend(
288
+ JSON.stringify({
289
+ type: 'resume',
290
+ lastSeenEventId: this._lastSeenEventId,
291
+ }),
292
+ );
293
+ }
294
+ } finally {
295
+ if (this.activeConnectAbortController === abortController) {
296
+ this.activeConnectAbortController = undefined;
297
+ }
220
298
  }
221
299
  }
222
300
 
@@ -272,11 +350,13 @@ export class SandboxChannel<
272
350
  */
273
351
  beginClose(): void {
274
352
  this.closing = true;
353
+ this.abortActiveConnect();
275
354
  }
276
355
 
277
356
  close(): void {
278
357
  if (this.terminal) return;
279
358
  this.closing = true;
359
+ this.abortActiveConnect();
280
360
  try {
281
361
  this.ws?.close();
282
362
  } catch {
@@ -310,6 +390,7 @@ export class SandboxChannel<
310
390
  // suppress reconnect so the socket close finalises.
311
391
  this.suspended = true;
312
392
  this.closing = true;
393
+ this.abortActiveConnect();
313
394
  this.onClose(() =>
314
395
  resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
315
396
  );
@@ -362,62 +443,143 @@ export class SandboxChannel<
362
443
 
363
444
  private async reconnectLoop(): Promise<void> {
364
445
  if (this.terminal || this.closing) return;
365
- const start = Date.now();
446
+ const deadline = Date.now() + this.maxElapsedMs;
366
447
  let attempt = 0;
367
448
  let delay = this.initialDelayMs;
368
- while (!this.terminal && !this.closing) {
369
- attempt++;
370
- this.onDebug?.({
371
- event: 'reconnect-attempt',
372
- attempt,
373
- lastSeenEventId: this._lastSeenEventId,
374
- });
375
- try {
376
- const ws = await this.connectThunk();
377
- if (this.terminal || this.closing) {
378
- try {
379
- ws.close();
380
- } catch {
381
- // best-effort
382
- }
449
+ const reconnectAbortController = new AbortController();
450
+ this.reconnectAbortController = reconnectAbortController;
451
+ try {
452
+ while (!this.terminal && !this.closing) {
453
+ const remaining = deadline - Date.now();
454
+ if (remaining <= 0) {
455
+ this.failReconnect({
456
+ attempts: attempt,
457
+ cause: new Error('Reconnect deadline expired'),
458
+ });
383
459
  return;
384
460
  }
385
- this.wire(ws);
386
- this.ws = ws;
387
- this.connected = true;
388
- // Ask the bridge to replay everything we have not seen, then flush any
389
- // host → bridge frames produced while we were disconnected.
390
- this.rawSend(
391
- JSON.stringify({
392
- type: 'resume',
393
- lastSeenEventId: this._lastSeenEventId,
394
- }),
395
- );
396
- this.flushPending();
397
- for (const handler of this.onReconnectHandlers) handler();
461
+
462
+ attempt++;
398
463
  this.onDebug?.({
399
- event: 'reconnected',
464
+ event: 'reconnect-attempt',
400
465
  attempt,
401
466
  lastSeenEventId: this._lastSeenEventId,
402
467
  });
403
- return;
404
- } catch (cause) {
405
- if (Date.now() - start >= this.maxElapsedMs) {
406
- this.finalizeClose(1006, 'reconnect failed');
468
+ const abortController = new AbortController();
469
+ this.activeConnectAbortController = abortController;
470
+ const abortConnect = () =>
471
+ abortController.abort(reconnectAbortController.signal.reason);
472
+ reconnectAbortController.signal.addEventListener(
473
+ 'abort',
474
+ abortConnect,
475
+ {
476
+ once: true,
477
+ },
478
+ );
479
+ const timeout = setTimeout(() => {
480
+ abortController.abort(new Error('Reconnect deadline expired'));
481
+ }, remaining);
482
+ (timeout as { unref?: () => void }).unref?.();
483
+
484
+ try {
485
+ const ws = await this.connect({
486
+ abortSignal: abortController.signal,
487
+ });
488
+ if (this.terminal || this.closing) {
489
+ closeWebSocket({ ws });
490
+ return;
491
+ }
492
+ this.wire(ws);
493
+ this.ws = ws;
494
+ this.connected = true;
495
+ // Ask the bridge to replay everything we have not seen, then flush any
496
+ // host → bridge frames produced while we were disconnected.
497
+ this.rawSend(
498
+ JSON.stringify({
499
+ type: 'resume',
500
+ lastSeenEventId: this._lastSeenEventId,
501
+ }),
502
+ );
503
+ this.flushPending();
504
+ for (const handler of this.onReconnectHandlers) handler();
407
505
  this.onDebug?.({
408
- event: 'reconnect-failed',
409
- attempts: attempt,
506
+ event: 'reconnected',
507
+ attempt,
410
508
  lastSeenEventId: this._lastSeenEventId,
411
- cause,
412
509
  });
413
510
  return;
511
+ } catch (cause) {
512
+ if (
513
+ this.terminal ||
514
+ this.closing ||
515
+ reconnectAbortController.signal.aborted
516
+ ) {
517
+ return;
518
+ }
519
+ const remainingAfterFailure = deadline - Date.now();
520
+ if (remainingAfterFailure <= 0) {
521
+ this.failReconnect({ attempts: attempt, cause });
522
+ return;
523
+ }
524
+ await sleep({
525
+ ms: Math.min(delay, remainingAfterFailure),
526
+ abortSignal: reconnectAbortController.signal,
527
+ });
528
+ delay = Math.min(delay * 1.5, this.maxDelayMs);
529
+ } finally {
530
+ clearTimeout(timeout);
531
+ reconnectAbortController.signal.removeEventListener(
532
+ 'abort',
533
+ abortConnect,
534
+ );
535
+ if (this.activeConnectAbortController === abortController) {
536
+ this.activeConnectAbortController = undefined;
537
+ }
414
538
  }
415
- await sleep(delay);
416
- delay = Math.min(delay * 1.5, this.maxDelayMs);
539
+ }
540
+ } finally {
541
+ if (this.reconnectAbortController === reconnectAbortController) {
542
+ this.reconnectAbortController = undefined;
417
543
  }
418
544
  }
419
545
  }
420
546
 
547
+ private connect({
548
+ abortSignal,
549
+ }: SandboxChannelConnectOptions): Promise<WebSocket> {
550
+ return awaitWebSocketConnection({
551
+ connection: Promise.resolve().then(() =>
552
+ this.connectThunk({ abortSignal }),
553
+ ),
554
+ abortSignal,
555
+ });
556
+ }
557
+
558
+ private abortActiveConnect(): void {
559
+ const controller = this.activeConnectAbortController;
560
+ this.activeConnectAbortController = undefined;
561
+ controller?.abort(new Error('SandboxChannel connection aborted'));
562
+ this.reconnectAbortController?.abort(
563
+ new Error('SandboxChannel connection aborted'),
564
+ );
565
+ }
566
+
567
+ private failReconnect({
568
+ attempts,
569
+ cause,
570
+ }: {
571
+ attempts: number;
572
+ cause: unknown;
573
+ }): void {
574
+ this.finalizeClose(1006, 'reconnect failed');
575
+ this.onDebug?.({
576
+ event: 'reconnect-failed',
577
+ attempts,
578
+ lastSeenEventId: this._lastSeenEventId,
579
+ cause,
580
+ });
581
+ }
582
+
421
583
  private rawSend(text: string): void {
422
584
  if (this.connected && this.ws) {
423
585
  this.ws.send(text);
@@ -519,6 +681,7 @@ export class SandboxChannel<
519
681
 
520
682
  private finalizeClose(code: number, reason: string): void {
521
683
  if (this.terminal) return;
684
+ this.abortActiveConnect();
522
685
  this.terminal = true;
523
686
  this.connected = false;
524
687
  for (const h of this.onCloseHandlers) h(code, reason);
@@ -0,0 +1,30 @@
1
+ export function sleep({
2
+ ms,
3
+ abortSignal,
4
+ }: {
5
+ ms: number;
6
+ abortSignal?: AbortSignal;
7
+ }): Promise<void> {
8
+ return new Promise(resolve => {
9
+ if (abortSignal?.aborted) {
10
+ resolve();
11
+ return;
12
+ }
13
+
14
+ let settled = false;
15
+ let onAbort = () => {};
16
+ const finish = () => {
17
+ if (settled) return;
18
+ settled = true;
19
+ abortSignal?.removeEventListener('abort', onAbort);
20
+ resolve();
21
+ };
22
+ const timer = setTimeout(finish, ms);
23
+ (timer as { unref?: () => void }).unref?.();
24
+ onAbort = () => {
25
+ clearTimeout(timer);
26
+ finish();
27
+ };
28
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
29
+ });
30
+ }