@ai-sdk/harness 1.0.116 → 1.0.118
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/CHANGELOG.md +32 -0
- package/dist/agent/index.js +64 -18
- package/dist/agent/index.js.map +1 -1
- package/dist/utils/index.d.ts +19 -3
- package/dist/utils/index.js +220 -53
- package/dist/utils/index.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/internal/strip-work-dir.ts +91 -20
- package/src/utils/index.ts +2 -0
- package/src/utils/sandbox-channel.ts +220 -57
- package/src/utils/sleep.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.118",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@ai-sdk/provider": "4.0.17",
|
|
48
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
49
|
-
"ai": "7.0.
|
|
48
|
+
"@ai-sdk/provider-utils": "5.0.45",
|
|
49
|
+
"ai": "7.0.108"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"ws": "^8.21.0",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@ai-sdk/otel": "1.0.
|
|
61
|
+
"@ai-sdk/otel": "1.0.108",
|
|
62
62
|
"@opentelemetry/sdk-trace-base": "2.7.1",
|
|
63
63
|
"@types/node": "22.19.19",
|
|
64
64
|
"@types/ws": "^8.5.13",
|
|
@@ -12,36 +12,63 @@ export function createToolInputWorkDirStripper({
|
|
|
12
12
|
}: {
|
|
13
13
|
sessionWorkDir: string;
|
|
14
14
|
}): (part: ToolInputStreamPart) => ToolInputStreamPart[] {
|
|
15
|
-
const
|
|
15
|
+
const stateByToolCallId = new Map<
|
|
16
|
+
string,
|
|
17
|
+
{
|
|
18
|
+
pending: string;
|
|
19
|
+
precedingCharacter: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
>();
|
|
16
22
|
|
|
17
23
|
return part => {
|
|
18
24
|
if (sessionWorkDir.length === 0) return [part];
|
|
19
25
|
|
|
20
26
|
if (part.type === 'tool-input-start') {
|
|
21
|
-
|
|
27
|
+
stateByToolCallId.set(part.id, {
|
|
28
|
+
pending: '',
|
|
29
|
+
precedingCharacter: undefined,
|
|
30
|
+
});
|
|
22
31
|
return [part];
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
if (part.type === 'tool-input-delta') {
|
|
35
|
+
const state = stateByToolCallId.get(part.id) ?? {
|
|
36
|
+
pending: '',
|
|
37
|
+
precedingCharacter: undefined,
|
|
38
|
+
};
|
|
39
|
+
const value = state.pending + part.delta;
|
|
26
40
|
const stripped = stripStreamingString({
|
|
27
|
-
value
|
|
41
|
+
value,
|
|
28
42
|
workDir: sessionWorkDir,
|
|
29
43
|
final: false,
|
|
44
|
+
precedingCharacter: state.precedingCharacter,
|
|
45
|
+
});
|
|
46
|
+
const pendingStart = value.length - stripped.pending.length;
|
|
47
|
+
stateByToolCallId.set(part.id, {
|
|
48
|
+
pending: stripped.pending,
|
|
49
|
+
precedingCharacter:
|
|
50
|
+
stripped.pending.length > 0
|
|
51
|
+
? pendingStart > 0
|
|
52
|
+
? value[pendingStart - 1]
|
|
53
|
+
: state.precedingCharacter
|
|
54
|
+
: value.length > 0
|
|
55
|
+
? value[value.length - 1]
|
|
56
|
+
: state.precedingCharacter,
|
|
30
57
|
});
|
|
31
|
-
pendingByToolCallId.set(part.id, stripped.pending);
|
|
32
58
|
return stripped.output.length === 0
|
|
33
59
|
? []
|
|
34
60
|
: [{ ...part, delta: stripped.output }];
|
|
35
61
|
}
|
|
36
62
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (
|
|
63
|
+
const state = stateByToolCallId.get(part.id);
|
|
64
|
+
stateByToolCallId.delete(part.id);
|
|
65
|
+
if (state == null || state.pending.length === 0) return [part];
|
|
40
66
|
|
|
41
67
|
const stripped = stripStreamingString({
|
|
42
|
-
value: pending,
|
|
68
|
+
value: state.pending,
|
|
43
69
|
workDir: sessionWorkDir,
|
|
44
70
|
final: true,
|
|
71
|
+
precedingCharacter: state.precedingCharacter,
|
|
45
72
|
});
|
|
46
73
|
return stripped.output.length === 0
|
|
47
74
|
? [part]
|
|
@@ -62,11 +89,10 @@ export function createToolInputWorkDirStripper({
|
|
|
62
89
|
* operates. The absolute paths are correct but noisy in a UI, so this strips
|
|
63
90
|
* the prefix for the consumer-facing projection only.
|
|
64
91
|
*
|
|
65
|
-
*
|
|
66
|
-
* deliberately: `tool-result` results are free-form text — command
|
|
67
|
-
* output — where paths can appear anywhere and field-aware
|
|
68
|
-
*
|
|
69
|
-
* enough that replacing every occurrence is safe.
|
|
92
|
+
* Boundary-aware prefix replacement (rather than rewriting known path fields)
|
|
93
|
+
* is used deliberately: `tool-result` results are free-form text — command
|
|
94
|
+
* stdout, grep output — where paths can appear anywhere and field-aware
|
|
95
|
+
* rewriting is impossible.
|
|
70
96
|
*/
|
|
71
97
|
export function stripWorkDir(
|
|
72
98
|
part: HarnessV1StreamPart,
|
|
@@ -101,33 +127,60 @@ export function stripWorkDir(
|
|
|
101
127
|
* becomes `.`.
|
|
102
128
|
*/
|
|
103
129
|
function stripString(value: string, workDir: string): string {
|
|
104
|
-
return
|
|
130
|
+
return stripStreamingString({
|
|
131
|
+
value,
|
|
132
|
+
workDir,
|
|
133
|
+
final: true,
|
|
134
|
+
precedingCharacter: undefined,
|
|
135
|
+
}).output;
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
function stripStreamingString({
|
|
108
139
|
value,
|
|
109
140
|
workDir,
|
|
110
141
|
final,
|
|
142
|
+
precedingCharacter,
|
|
111
143
|
}: {
|
|
112
144
|
value: string;
|
|
113
145
|
workDir: string;
|
|
114
146
|
final: boolean;
|
|
147
|
+
precedingCharacter: string | undefined;
|
|
115
148
|
}): { output: string; pending: string } {
|
|
116
149
|
let remaining = value;
|
|
117
150
|
let output = '';
|
|
151
|
+
let characterBeforeRemaining = precedingCharacter;
|
|
118
152
|
|
|
119
153
|
while (remaining.length > 0) {
|
|
120
154
|
const matchIndex = remaining.indexOf(workDir);
|
|
121
155
|
if (matchIndex >= 0) {
|
|
122
|
-
output += remaining.slice(0, matchIndex);
|
|
123
156
|
const followingIndex = matchIndex + workDir.length;
|
|
124
|
-
|
|
125
|
-
|
|
157
|
+
const hasPathBoundary = isPathBoundary(
|
|
158
|
+
remaining,
|
|
159
|
+
matchIndex,
|
|
160
|
+
characterBeforeRemaining,
|
|
161
|
+
);
|
|
162
|
+
if (hasPathBoundary && followingIndex === remaining.length && !final) {
|
|
163
|
+
return {
|
|
164
|
+
output: output + remaining.slice(0, matchIndex),
|
|
165
|
+
pending: remaining.slice(matchIndex),
|
|
166
|
+
};
|
|
126
167
|
}
|
|
127
|
-
|
|
168
|
+
|
|
169
|
+
const isPath =
|
|
170
|
+
hasPathBoundary &&
|
|
171
|
+
(followingIndex === remaining.length ||
|
|
172
|
+
remaining[followingIndex] === '/' ||
|
|
173
|
+
isPathTerminator(remaining[followingIndex]));
|
|
174
|
+
if (!isPath) {
|
|
175
|
+
output += remaining.slice(0, followingIndex);
|
|
176
|
+
characterBeforeRemaining = remaining[followingIndex - 1];
|
|
177
|
+
remaining = remaining.slice(followingIndex);
|
|
178
|
+
} else if (remaining[followingIndex] === '/') {
|
|
179
|
+
output += remaining.slice(0, matchIndex);
|
|
180
|
+
characterBeforeRemaining = remaining[followingIndex];
|
|
128
181
|
remaining = remaining.slice(followingIndex + 1);
|
|
129
182
|
} else {
|
|
130
|
-
output += '.';
|
|
183
|
+
output += remaining.slice(0, matchIndex) + '.';
|
|
131
184
|
remaining = remaining.slice(followingIndex);
|
|
132
185
|
}
|
|
133
186
|
continue;
|
|
@@ -138,7 +191,12 @@ function stripStreamingString({
|
|
|
138
191
|
let pendingLength = Math.min(remaining.length, workDir.length - 1);
|
|
139
192
|
while (
|
|
140
193
|
pendingLength > 0 &&
|
|
141
|
-
!workDir.startsWith(remaining.slice(-pendingLength))
|
|
194
|
+
(!workDir.startsWith(remaining.slice(-pendingLength)) ||
|
|
195
|
+
!isPathBoundary(
|
|
196
|
+
remaining,
|
|
197
|
+
remaining.length - pendingLength,
|
|
198
|
+
characterBeforeRemaining,
|
|
199
|
+
))
|
|
142
200
|
) {
|
|
143
201
|
pendingLength -= 1;
|
|
144
202
|
}
|
|
@@ -152,6 +210,19 @@ function stripStreamingString({
|
|
|
152
210
|
return { output, pending: '' };
|
|
153
211
|
}
|
|
154
212
|
|
|
213
|
+
function isPathBoundary(
|
|
214
|
+
value: string,
|
|
215
|
+
index: number,
|
|
216
|
+
precedingCharacter?: string,
|
|
217
|
+
): boolean {
|
|
218
|
+
const character = index === 0 ? precedingCharacter : value[index - 1]!;
|
|
219
|
+
return character === undefined || /[\s"'`=]/.test(character);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isPathTerminator(character: string | undefined): boolean {
|
|
223
|
+
return character === undefined || /[\s"'`;|&<>()]/.test(character);
|
|
224
|
+
}
|
|
225
|
+
|
|
155
226
|
/**
|
|
156
227
|
* Recursively strip the working directory from every string nested in an
|
|
157
228
|
* arbitrary JSON-like value. Non-string leaves are returned unchanged.
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export {
|
|
2
2
|
SandboxChannel,
|
|
3
|
+
type SandboxChannelConnectOptions,
|
|
3
4
|
type SandboxChannelDebugEvent,
|
|
4
5
|
type SandboxChannelOptions,
|
|
5
6
|
type SandboxChannelReconnectOptions,
|
|
@@ -69,3 +70,4 @@ export {
|
|
|
69
70
|
} from './bridge-diagnostics';
|
|
70
71
|
export { resolveSandboxDefaultWorkingDirectory } from './resolve-sandbox-default-working-directory';
|
|
71
72
|
export { getRestrictedSandboxSession } from './get-restricted-sandbox-session';
|
|
73
|
+
export { sleep } from './sleep';
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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: (
|
|
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
|
|
210
|
-
this.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
446
|
+
const deadline = Date.now() + this.maxElapsedMs;
|
|
366
447
|
let attempt = 0;
|
|
367
448
|
let delay = this.initialDelayMs;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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: '
|
|
464
|
+
event: 'reconnect-attempt',
|
|
400
465
|
attempt,
|
|
401
466
|
lastSeenEventId: this._lastSeenEventId,
|
|
402
467
|
});
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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: '
|
|
409
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
+
}
|