testdriverai 7.8.0-test.6 → 7.8.0-test.60
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 +6 -0
- package/agent/index.js +10 -5
- package/agent/lib/commands.js +3 -2
- package/agent/lib/http.js +144 -0
- package/agent/lib/logger.js +15 -0
- package/agent/lib/sandbox.js +513 -207
- package/agent/lib/sdk.js +4 -2
- package/agent/lib/system.js +25 -65
- package/ai/skills/testdriver-cache/SKILL.md +221 -0
- package/ai/skills/testdriver-errors/SKILL.md +246 -0
- package/ai/skills/testdriver-events/SKILL.md +356 -0
- package/ai/skills/testdriver-find/SKILL.md +14 -20
- package/ai/skills/testdriver-mcp/SKILL.md +7 -0
- package/ai/skills/testdriver-provision/SKILL.md +331 -0
- package/ai/skills/testdriver-redraw/SKILL.md +214 -0
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
- package/docs/_data/examples-manifest.json +46 -46
- package/docs/changelog.mdx +151 -5
- package/docs/docs.json +44 -37
- package/docs/images/content/vscode/v7-chat.png +0 -0
- package/docs/images/content/vscode/v7-choose-agent.png +0 -0
- package/docs/images/content/vscode/v7-full.png +0 -0
- package/docs/images/content/vscode/v7-onboarding.png +0 -0
- package/docs/v7/cache.mdx +223 -0
- package/docs/v7/copilot/auto-healing.mdx +265 -0
- package/docs/v7/copilot/creating-tests.mdx +156 -0
- package/docs/v7/copilot/github.mdx +143 -0
- package/docs/v7/copilot/running-tests.mdx +149 -0
- package/docs/v7/copilot/setup.mdx +143 -0
- package/docs/v7/enterprise.mdx +3 -110
- package/docs/v7/errors.mdx +248 -0
- package/docs/v7/events.mdx +358 -0
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/captcha-api.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +1 -1
- package/docs/v7/examples/drag-and-drop.mdx +1 -1
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/exec-output.mdx +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll-until-image.mdx +1 -1
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/examples/windows-installer.mdx +1 -1
- package/docs/v7/find.mdx +14 -20
- package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
- package/docs/v7/mcp.mdx +9 -0
- package/docs/v7/provision.mdx +333 -0
- package/docs/v7/quickstart.mdx +30 -2
- package/docs/v7/redraw.mdx +216 -0
- package/docs/v7/running-tests.mdx +1 -1
- package/docs/v7/screenshots.mdx +186 -0
- package/docs/v7/self-hosted.mdx +127 -44
- package/examples/scroll-keyboard.test.mjs +1 -1
- package/interfaces/logger.js +0 -12
- package/interfaces/vitest-plugin.mjs +53 -43
- package/lib/core/Dashcam.js +30 -23
- package/lib/environments.json +18 -0
- package/lib/github-comment.mjs +58 -40
- package/lib/resolve-channel.js +4 -3
- package/lib/sentry.js +5 -0
- package/lib/vitest/hooks.mjs +8 -3
- package/{examples → manual}/drag-and-drop.test.mjs +1 -1
- package/mcp-server/dist/server.mjs +4 -0
- package/mcp-server/src/server.ts +5 -0
- package/package.json +3 -3
- package/sdk.d.ts +4 -0
- package/sdk.js +15 -7
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +165 -0
- package/vitest.config.mjs +22 -34
- package/vitest.runner.config.mjs +33 -0
- /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
- /package/{examples → manual}/flake-shared.mjs +0 -0
- /package/{examples → manual}/no-provision.test.mjs +0 -0
- /package/{examples → manual}/scroll-until-text.test.mjs +0 -0
package/agent/lib/sandbox.js
CHANGED
|
@@ -1,93 +1,17 @@
|
|
|
1
|
-
const crypto = require("crypto");
|
|
2
1
|
const Ably = require("ably");
|
|
2
|
+
const axios = require("axios");
|
|
3
3
|
const { events } = require("../events");
|
|
4
4
|
const logger = require("./logger");
|
|
5
5
|
const { version } = require("../../package.json");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (!sessionId) return {};
|
|
9
|
-
const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
|
|
10
|
-
const spanId = crypto.randomBytes(8).toString("hex");
|
|
11
|
-
return {
|
|
12
|
-
"sentry-trace": traceId + "-" + spanId + "-1",
|
|
13
|
-
baggage:
|
|
14
|
-
"sentry-trace_id=" +
|
|
15
|
-
traceId +
|
|
16
|
-
",sentry-sample_rate=1.0,sentry-sampled=true",
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function httpPost(apiRoot, path, body, timeout) {
|
|
21
|
-
const http = require("http");
|
|
22
|
-
const https = require("https");
|
|
23
|
-
const url = new URL(apiRoot + path);
|
|
24
|
-
const transport = url.protocol === "https:" ? https : http;
|
|
25
|
-
const bodyStr = JSON.stringify(body);
|
|
26
|
-
|
|
27
|
-
return new Promise(function (resolve, reject) {
|
|
28
|
-
var timeoutId = timeout
|
|
29
|
-
? setTimeout(function () {
|
|
30
|
-
req.destroy();
|
|
31
|
-
reject(
|
|
32
|
-
new Error("HTTP request timed out after " + timeout + "ms"),
|
|
33
|
-
);
|
|
34
|
-
}, timeout)
|
|
35
|
-
: null;
|
|
36
|
-
|
|
37
|
-
var req = transport.request(
|
|
38
|
-
url,
|
|
39
|
-
{
|
|
40
|
-
method: "POST",
|
|
41
|
-
headers: {
|
|
42
|
-
"Content-Type": "application/json",
|
|
43
|
-
"Content-Length": Buffer.byteLength(bodyStr),
|
|
44
|
-
"Connection": "close",
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
function (res) {
|
|
48
|
-
var data = "";
|
|
49
|
-
res.on("data", function (chunk) {
|
|
50
|
-
data += chunk;
|
|
51
|
-
});
|
|
52
|
-
res.on("end", function () {
|
|
53
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
54
|
-
try {
|
|
55
|
-
var parsed = JSON.parse(data);
|
|
56
|
-
if (res.statusCode >= 400) {
|
|
57
|
-
var err = new Error(
|
|
58
|
-
parsed.errorMessage ||
|
|
59
|
-
parsed.message ||
|
|
60
|
-
"HTTP " + res.statusCode,
|
|
61
|
-
);
|
|
62
|
-
err.responseData = parsed;
|
|
63
|
-
reject(err);
|
|
64
|
-
} else {
|
|
65
|
-
resolve(parsed);
|
|
66
|
-
}
|
|
67
|
-
} catch (e) {
|
|
68
|
-
reject(new Error("Failed to parse API response: " + data));
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
req.on("error", function (err) {
|
|
74
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
75
|
-
reject(err);
|
|
76
|
-
});
|
|
77
|
-
req.write(bodyStr);
|
|
78
|
-
req.end();
|
|
79
|
-
});
|
|
80
|
-
}
|
|
6
|
+
const { withRetry, getSentryTraceHeaders } = require("./sdk");
|
|
7
|
+
const sentry = require("../../lib/sentry");
|
|
81
8
|
|
|
82
9
|
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
83
10
|
class Sandbox {
|
|
84
11
|
constructor() {
|
|
85
12
|
this._ably = null;
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this._ctrlChannel = null;
|
|
89
|
-
this._filesChannel = null;
|
|
90
|
-
this._channelNames = null;
|
|
13
|
+
this._sessionChannel = null;
|
|
14
|
+
this._channelName = null;
|
|
91
15
|
this.ps = {};
|
|
92
16
|
this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
|
|
93
17
|
this.heartbeat = null;
|
|
@@ -105,6 +29,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
105
29
|
this._lastConnectParams = null;
|
|
106
30
|
this._teamId = null;
|
|
107
31
|
this._sandboxId = null;
|
|
32
|
+
this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
|
|
33
|
+
|
|
34
|
+
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
35
|
+
this._publishLastTime = 0;
|
|
36
|
+
this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
|
|
37
|
+
this._publishCount = 0;
|
|
38
|
+
this._publishWindowStart = Date.now();
|
|
108
39
|
}
|
|
109
40
|
|
|
110
41
|
getTraceId() {
|
|
@@ -118,7 +49,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
118
49
|
);
|
|
119
50
|
}
|
|
120
51
|
|
|
121
|
-
async _initAbly(ablyToken,
|
|
52
|
+
async _initAbly(ablyToken, channelName) {
|
|
122
53
|
if (this._ably) {
|
|
123
54
|
try {
|
|
124
55
|
this._ably.close();
|
|
@@ -126,48 +57,69 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
126
57
|
/* ignore */
|
|
127
58
|
}
|
|
128
59
|
}
|
|
129
|
-
this.
|
|
60
|
+
this._channelName = channelName;
|
|
130
61
|
var self = this;
|
|
131
62
|
|
|
132
63
|
this._ably = new Ably.Realtime({
|
|
133
|
-
authCallback: function (tokenParams, callback) {
|
|
134
|
-
|
|
64
|
+
authCallback: async function (tokenParams, callback) {
|
|
65
|
+
// On initial connect Ably may supply the token directly; on renewal
|
|
66
|
+
// we must fetch a fresh one from the API (the original token will
|
|
67
|
+
// have expired, causing 40143 token.unrecognized if reused).
|
|
68
|
+
try {
|
|
69
|
+
const response = await axios({
|
|
70
|
+
method: "post",
|
|
71
|
+
url: self.apiRoot + "/api/v7/sandbox/ably-token",
|
|
72
|
+
data: { apiKey: self.apiKey, sandboxId: self._sandboxId },
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
timeout: 15000,
|
|
75
|
+
});
|
|
76
|
+
callback(null, response.data.token);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
logger.warn("[ably] Token renewal failed, falling back to original token: " + (err.message || err));
|
|
79
|
+
callback(null, ablyToken);
|
|
80
|
+
}
|
|
135
81
|
},
|
|
136
82
|
clientId: "sdk-" + this._sandboxId,
|
|
83
|
+
echoMessages: false, // don't receive our own published messages
|
|
137
84
|
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
138
85
|
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
139
86
|
});
|
|
140
87
|
|
|
88
|
+
logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
|
|
89
|
+
|
|
141
90
|
await new Promise(function (resolve, reject) {
|
|
142
91
|
self._ably.connection.on("connected", resolve);
|
|
143
92
|
self._ably.connection.on("failed", function () {
|
|
144
|
-
reject(new Error("
|
|
93
|
+
reject(new Error("Realtime connection failed"));
|
|
145
94
|
});
|
|
146
95
|
setTimeout(function () {
|
|
147
|
-
reject(new Error("
|
|
96
|
+
reject(new Error("Realtime connection timeout"));
|
|
148
97
|
}, 30000);
|
|
149
98
|
});
|
|
150
99
|
|
|
151
|
-
this.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this._filesChannel = this._ably.channels.get(channelNames.files);
|
|
100
|
+
this._sessionChannel = this._ably.channels.get(channelName);
|
|
101
|
+
|
|
102
|
+
logger.debug(`[realtime] Channel initialized: ${channelName}`);
|
|
155
103
|
|
|
156
|
-
// Enter presence on
|
|
104
|
+
// Enter presence on the session channel so the API can count connected SDK clients
|
|
157
105
|
try {
|
|
158
|
-
await this.
|
|
106
|
+
await this._sessionChannel.presence.enter({
|
|
159
107
|
sandboxId: this._sandboxId,
|
|
160
108
|
connectedAt: Date.now(),
|
|
161
109
|
});
|
|
110
|
+
logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
|
|
162
111
|
} catch (e) {
|
|
163
112
|
// Non-fatal — presence is used for concurrency counting, not critical path
|
|
164
|
-
logger.warn("Failed to enter presence on
|
|
113
|
+
logger.warn("Failed to enter presence on session channel: " + (e.message || e));
|
|
165
114
|
}
|
|
166
115
|
|
|
167
|
-
|
|
116
|
+
// Save subscription references for historyBeforeSubscribe() during discontinuity recovery
|
|
117
|
+
this._onResponseMsg = function (msg) {
|
|
168
118
|
var message = msg.data;
|
|
169
119
|
if (!message) return;
|
|
170
120
|
|
|
121
|
+
logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
122
|
+
|
|
171
123
|
if (message.type === "sandbox.progress") {
|
|
172
124
|
emitter.emit(events.sandbox.progress, {
|
|
173
125
|
step: message.step,
|
|
@@ -218,31 +170,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
218
170
|
}
|
|
219
171
|
|
|
220
172
|
if (!message.requestId || !self.ps[message.requestId]) {
|
|
221
|
-
var
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
173
|
+
var pendingIds = Object.keys(self.ps);
|
|
174
|
+
var pendingSummary = pendingIds.length > 0
|
|
175
|
+
? pendingIds.map(function (rid) {
|
|
176
|
+
var e = self.ps[rid];
|
|
177
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
|
|
178
|
+
}).join(', ')
|
|
179
|
+
: 'none';
|
|
180
|
+
logger.warn(
|
|
181
|
+
'[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
|
|
182
|
+
' | response type=' + (message.type || 'unknown') +
|
|
183
|
+
' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
|
|
184
|
+
' | currently pending: [' + pendingSummary + ']'
|
|
185
|
+
);
|
|
229
186
|
return;
|
|
230
187
|
}
|
|
231
188
|
|
|
232
189
|
if (message.error) {
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
190
|
+
var pendingEntry = self.ps[message.requestId];
|
|
191
|
+
var pendingMessage = pendingEntry && pendingEntry.message;
|
|
192
|
+
var pendingAge = pendingEntry && pendingEntry.startTime
|
|
193
|
+
? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
|
|
194
|
+
: '?';
|
|
195
|
+
logger.warn(
|
|
196
|
+
'[realtime] Promise REJECTED: requestId=' + message.requestId +
|
|
197
|
+
' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
|
|
198
|
+
' | age=' + pendingAge +
|
|
199
|
+
' | error=' + (message.errorMessage || 'Sandbox error')
|
|
200
|
+
);
|
|
236
201
|
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
237
202
|
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
238
203
|
}
|
|
239
204
|
var error = new Error(message.errorMessage || "Sandbox error");
|
|
240
205
|
error.responseData = message;
|
|
241
206
|
delete self._execBuffers[message.requestId];
|
|
242
|
-
|
|
207
|
+
pendingEntry.reject(error);
|
|
243
208
|
} else {
|
|
244
209
|
emitter.emit(events.sandbox.received);
|
|
245
210
|
if (self.ps[message.requestId]) {
|
|
211
|
+
var resolveEntry = self.ps[message.requestId];
|
|
212
|
+
var resolveAge = resolveEntry.startTime
|
|
213
|
+
? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
|
|
214
|
+
: '?';
|
|
215
|
+
logger.debug(
|
|
216
|
+
'[realtime] Promise RESOLVED: requestId=' + message.requestId +
|
|
217
|
+
' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
|
|
218
|
+
' | age=' + resolveAge
|
|
219
|
+
);
|
|
246
220
|
// Unwrap the result from the Ably response envelope
|
|
247
221
|
// The runner sends { requestId, type, result, success }
|
|
248
222
|
// But SDK commands expect just the result object
|
|
@@ -261,77 +235,230 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
261
235
|
}
|
|
262
236
|
}
|
|
263
237
|
delete self.ps[message.requestId];
|
|
264
|
-
}
|
|
238
|
+
};
|
|
239
|
+
this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
|
|
265
240
|
|
|
266
|
-
this.
|
|
241
|
+
this._onFileMsg = function (msg) {
|
|
267
242
|
var message = msg.data;
|
|
268
243
|
if (!message) return;
|
|
244
|
+
logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
269
245
|
if (message.requestId && self.ps[message.requestId]) {
|
|
270
246
|
emitter.emit(events.sandbox.received);
|
|
271
247
|
self.ps[message.requestId].resolve(message);
|
|
272
248
|
delete self.ps[message.requestId];
|
|
273
249
|
}
|
|
274
250
|
emitter.emit(events.sandbox.file, message);
|
|
275
|
-
}
|
|
251
|
+
};
|
|
252
|
+
this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
|
|
276
253
|
|
|
277
|
-
this.heartbeat = setInterval(function () {}, 5000);
|
|
254
|
+
this.heartbeat = setInterval(function () { }, 5000);
|
|
278
255
|
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
279
256
|
|
|
257
|
+
// ─── Periodic stats logging ────────────────────────────────────────
|
|
258
|
+
this._statsInterval = setInterval(() => {
|
|
259
|
+
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
260
|
+
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
261
|
+
const pendingIds = Object.keys(this.ps);
|
|
262
|
+
const pending = pendingIds.length;
|
|
263
|
+
logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
264
|
+
if (pending > 0) {
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
for (const rid of pendingIds) {
|
|
267
|
+
const entry = this.ps[rid];
|
|
268
|
+
if (!entry) continue;
|
|
269
|
+
const type = entry.message ? entry.message.type : 'unknown';
|
|
270
|
+
const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
|
|
271
|
+
logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}, 10000);
|
|
275
|
+
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
276
|
+
|
|
280
277
|
this._ably.connection.on("disconnected", function () {
|
|
281
|
-
logger.
|
|
278
|
+
logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
|
|
279
|
+
self._disconnectedAt = Date.now();
|
|
282
280
|
});
|
|
283
281
|
|
|
284
282
|
this._ably.connection.on("connected", function () {
|
|
285
283
|
// Log reconnection so the user knows the blip was recovered
|
|
286
|
-
logger.
|
|
284
|
+
logger.debug("[realtime] Connection: reconnected");
|
|
285
|
+
// Extend any pending command timeouts by the disconnection duration so
|
|
286
|
+
// commands whose timer was counting down while the connection was down
|
|
287
|
+
// don't get incorrectly timed out.
|
|
288
|
+
if (self._disconnectedAt) {
|
|
289
|
+
var disconnectionDurationMs = Date.now() - self._disconnectedAt;
|
|
290
|
+
self._disconnectedAt = null;
|
|
291
|
+
var pendingIds = Object.keys(self.ps);
|
|
292
|
+
if (pendingIds.length > 0) {
|
|
293
|
+
logger.debug(
|
|
294
|
+
'[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
|
|
295
|
+
disconnectionDurationMs + 'ms after disconnection'
|
|
296
|
+
);
|
|
297
|
+
for (var i = 0; i < pendingIds.length; i++) {
|
|
298
|
+
var entry = self.ps[pendingIds[i]];
|
|
299
|
+
if (entry && typeof entry.extendTimeout === 'function') {
|
|
300
|
+
entry.extendTimeout(disconnectionDurationMs);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
287
305
|
});
|
|
288
306
|
|
|
289
307
|
this._ably.connection.on("suspended", function () {
|
|
290
|
-
logger.warn("
|
|
308
|
+
logger.warn("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
|
|
291
309
|
});
|
|
292
310
|
|
|
293
311
|
this._ably.connection.on("failed", function () {
|
|
312
|
+
logger.error("[realtime] Connection: failed");
|
|
294
313
|
self.apiSocketConnected = false;
|
|
295
314
|
self.instanceSocketConnected = false;
|
|
296
|
-
emitter.emit(events.error.sandbox, "
|
|
315
|
+
emitter.emit(events.error.sandbox, "Realtime connection failed");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ─── Channel discontinuity detection ──────────────────────────────
|
|
319
|
+
// Set up BEFORE subscribing so we catch any continuity loss during
|
|
320
|
+
// the initial attachment. Fires at the channel level, covering all
|
|
321
|
+
// message types (response, file, control).
|
|
322
|
+
this._sessionChannel.on(function (stateChange) {
|
|
323
|
+
var current = stateChange.current;
|
|
324
|
+
var previous = stateChange.previous;
|
|
325
|
+
var reason = stateChange.reason;
|
|
326
|
+
var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
|
|
327
|
+
|
|
328
|
+
if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
|
|
329
|
+
logger.warn('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
|
|
330
|
+
emitter.emit(events.sandbox.progress, {
|
|
331
|
+
step: 'discontinuity',
|
|
332
|
+
message: 'Recovering missed messages after connection interruption...',
|
|
333
|
+
});
|
|
334
|
+
self._recoverFromDiscontinuity();
|
|
335
|
+
}
|
|
297
336
|
});
|
|
298
337
|
}
|
|
299
338
|
|
|
300
339
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
340
|
+
* Recover missed messages after a channel discontinuity.
|
|
341
|
+
* Uses historyBeforeSubscribe() on each subscription, which guarantees
|
|
342
|
+
* no gap between historical and live messages. Each recovered message
|
|
343
|
+
* is dispatched through the same handler that processes live messages
|
|
344
|
+
* so that pending promises are resolved/rejected correctly.
|
|
345
|
+
*/
|
|
346
|
+
async _recoverFromDiscontinuity() {
|
|
347
|
+
var subs = [
|
|
348
|
+
{ name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
|
|
349
|
+
{ name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
|
|
350
|
+
];
|
|
351
|
+
var totalRecovered = 0;
|
|
352
|
+
for (var i = 0; i < subs.length; i++) {
|
|
353
|
+
var entry = subs[i];
|
|
354
|
+
if (!entry.sub) continue;
|
|
355
|
+
try {
|
|
356
|
+
logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
|
|
357
|
+
var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
|
|
358
|
+
var recovered = 0;
|
|
359
|
+
while (page) {
|
|
360
|
+
// Replay each missed message through the handler so pending
|
|
361
|
+
// promises get resolved instead of timing out.
|
|
362
|
+
for (var j = 0; j < page.items.length; j++) {
|
|
363
|
+
recovered++;
|
|
364
|
+
try {
|
|
365
|
+
if (entry.handler) {
|
|
366
|
+
logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
|
|
367
|
+
entry.handler(page.items[j]);
|
|
368
|
+
}
|
|
369
|
+
} catch (replayErr) {
|
|
370
|
+
logger.error('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
page = page.hasNext() ? await page.next() : null;
|
|
374
|
+
}
|
|
375
|
+
totalRecovered += recovered;
|
|
376
|
+
logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logger.error('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (totalRecovered > 0) {
|
|
382
|
+
logger.warn('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
383
|
+
} else {
|
|
384
|
+
logger.debug('[realtime] Discontinuity recovery: no missed messages found');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* POST to the API with retry for transient network errors (via withRetry)
|
|
390
|
+
* and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
|
|
391
|
+
* testTimeout kills the test).
|
|
305
392
|
*/
|
|
306
393
|
async _httpPostWithConcurrencyRetry(path, body, timeout) {
|
|
307
|
-
var
|
|
394
|
+
var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
|
|
308
395
|
var startTime = Date.now();
|
|
396
|
+
var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
|
|
397
|
+
|
|
398
|
+
var self = this;
|
|
399
|
+
var makeRequest = function () {
|
|
400
|
+
return axios({
|
|
401
|
+
method: "post",
|
|
402
|
+
url: self.apiRoot + path,
|
|
403
|
+
data: body,
|
|
404
|
+
headers: {
|
|
405
|
+
"Content-Type": "application/json",
|
|
406
|
+
"User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
|
|
407
|
+
...getSentryTraceHeaders(sessionId),
|
|
408
|
+
},
|
|
409
|
+
timeout: timeout || 120000,
|
|
410
|
+
});
|
|
411
|
+
};
|
|
309
412
|
|
|
310
413
|
while (true) {
|
|
311
414
|
try {
|
|
312
|
-
|
|
415
|
+
var response = await withRetry(makeRequest, {
|
|
416
|
+
retryConfig: {
|
|
417
|
+
maxRetries: 3,
|
|
418
|
+
baseDelayMs: 2000,
|
|
419
|
+
retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
|
|
420
|
+
},
|
|
421
|
+
onRetry: function (attempt, error, delayMs) {
|
|
422
|
+
var elapsed = Date.now() - startTime;
|
|
423
|
+
logger.warn(
|
|
424
|
+
"Transient network error: " + (error.message || error.code) +
|
|
425
|
+
" — POST " + path +
|
|
426
|
+
" — retry " + attempt + "/3" +
|
|
427
|
+
" in " + (delayMs / 1000).toFixed(1) + "s" +
|
|
428
|
+
" (" + Math.round(elapsed / 1000) + "s elapsed)...",
|
|
429
|
+
);
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
return response.data;
|
|
313
433
|
} catch (err) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
var elapsed = Date.now() - startTime;
|
|
323
|
-
|
|
324
|
-
logger.log(
|
|
325
|
-
"Concurrency limit reached — waiting " +
|
|
326
|
-
retryInterval / 1000 +
|
|
434
|
+
// Concurrency limit — poll forever until a slot opens
|
|
435
|
+
var responseData = err.response && err.response.data;
|
|
436
|
+
if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
|
|
437
|
+
var elapsed = Date.now() - startTime;
|
|
438
|
+
logger.log(
|
|
439
|
+
"Concurrency limit reached — waiting " +
|
|
440
|
+
concurrencyRetryInterval / 1000 +
|
|
327
441
|
"s for a slot to become available (" +
|
|
328
442
|
Math.round(elapsed / 1000) +
|
|
329
443
|
"s elapsed)...",
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
444
|
+
);
|
|
445
|
+
await new Promise(function (resolve) {
|
|
446
|
+
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
447
|
+
if (t.unref) t.unref();
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Non-retryable HTTP error — preserve responseData for callers
|
|
453
|
+
if (responseData) {
|
|
454
|
+
var httpErr = new Error(
|
|
455
|
+
responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
|
|
456
|
+
);
|
|
457
|
+
httpErr.responseData = responseData;
|
|
458
|
+
throw httpErr;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
throw err;
|
|
335
462
|
}
|
|
336
463
|
}
|
|
337
464
|
}
|
|
@@ -362,6 +489,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
362
489
|
body.ci = message.ci;
|
|
363
490
|
if (message.ami) body.ami = message.ami;
|
|
364
491
|
if (message.instanceType) body.instanceType = message.instanceType;
|
|
492
|
+
if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
|
|
365
493
|
if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
|
|
366
494
|
}
|
|
367
495
|
|
|
@@ -390,14 +518,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
390
518
|
this._teamId = reply.teamId;
|
|
391
519
|
|
|
392
520
|
if (reply.ably && reply.ably.token) {
|
|
393
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
521
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
394
522
|
this.instanceSocketConnected = true;
|
|
395
523
|
|
|
396
524
|
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
397
525
|
var debugMode =
|
|
398
526
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
399
|
-
if (debugMode && this.
|
|
400
|
-
this.
|
|
527
|
+
if (debugMode && this._sessionChannel) {
|
|
528
|
+
this._sessionChannel.publish("control", {
|
|
401
529
|
type: "debug",
|
|
402
530
|
enabled: true,
|
|
403
531
|
});
|
|
@@ -405,47 +533,60 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
405
533
|
}
|
|
406
534
|
|
|
407
535
|
if (message.type === "create") {
|
|
408
|
-
// E2B (Linux) sandboxes
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return {
|
|
413
|
-
success: true,
|
|
414
|
-
sandbox: {
|
|
415
|
-
sandboxId: reply.sandboxId,
|
|
416
|
-
instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
|
|
417
|
-
os: body.os || 'linux',
|
|
418
|
-
url: reply.url,
|
|
419
|
-
},
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
536
|
+
// E2B (Linux) sandboxes return a url directly.
|
|
537
|
+
// We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
|
|
538
|
+
const isE2B = !!reply.url;
|
|
539
|
+
|
|
423
540
|
const runnerIp = reply.runner && reply.runner.ip;
|
|
424
541
|
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
425
542
|
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
426
543
|
|
|
427
|
-
|
|
544
|
+
// Log image version info (AMI for Windows, E2B template for Linux)
|
|
545
|
+
if (reply.imageVersion) {
|
|
546
|
+
if (isE2B) {
|
|
547
|
+
logger.log('E2B image version: v' + reply.imageVersion + (reply.e2bTemplateId ? ' (template: ' + reply.e2bTemplateId + ')' : ''));
|
|
548
|
+
} else {
|
|
549
|
+
logger.log('AMI image version: v' + reply.imageVersion + (reply.amiId ? ' (ami: ' + reply.amiId + ')' : ''));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!isE2B) {
|
|
554
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
555
|
+
}
|
|
428
556
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
557
|
+
// Wait for the runner agent to signal readiness before sending commands.
|
|
558
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
559
|
+
// This applies to:
|
|
560
|
+
// - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
|
|
561
|
+
// - Windows EC2 sandboxes without presence runners
|
|
562
|
+
// For presence-based Windows runners (reply.runner already set), the runner
|
|
563
|
+
// is already listening so we can skip the wait.
|
|
432
564
|
var self = this;
|
|
433
|
-
|
|
565
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
566
|
+
if (needsReadyWait) {
|
|
434
567
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
435
|
-
|
|
568
|
+
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
|
|
569
|
+
var readyTimeout = isE2B ? 300000 : 120000; // 5 min for E2B (S3+npm), 2 min for EC2
|
|
436
570
|
await new Promise(function (resolve, reject) {
|
|
437
571
|
var resolved = false;
|
|
438
572
|
function finish(data) {
|
|
439
573
|
if (resolved) return;
|
|
440
574
|
resolved = true;
|
|
441
575
|
clearTimeout(timer);
|
|
442
|
-
self.
|
|
576
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
443
577
|
// Update runner info if provided
|
|
444
578
|
if (data && data.os) reply.runner = reply.runner || {};
|
|
445
579
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
446
580
|
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
447
581
|
if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
|
|
448
582
|
logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
583
|
+
// Show upgrade info: if the runner's npm version differs from the baked image version,
|
|
584
|
+
// the runner was upgraded during provisioning.
|
|
585
|
+
var runnerVer = data && data.runnerVersion;
|
|
586
|
+
var imageVer = reply.imageVersion;
|
|
587
|
+
if (runnerVer && imageVer && runnerVer !== imageVer) {
|
|
588
|
+
logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
|
|
589
|
+
}
|
|
449
590
|
if (data && data.update) {
|
|
450
591
|
var u = data.update;
|
|
451
592
|
if (u.status === 'up-to-date') {
|
|
@@ -464,8 +605,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
464
605
|
var timer = setTimeout(function () {
|
|
465
606
|
if (!resolved) {
|
|
466
607
|
resolved = true;
|
|
467
|
-
self.
|
|
468
|
-
|
|
608
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
609
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
610
|
+
sentry.captureException(err, {
|
|
611
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
612
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
613
|
+
});
|
|
614
|
+
reject(err);
|
|
469
615
|
}
|
|
470
616
|
}, readyTimeout);
|
|
471
617
|
if (timer.unref) timer.unref();
|
|
@@ -478,12 +624,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
478
624
|
finish(data);
|
|
479
625
|
}
|
|
480
626
|
};
|
|
481
|
-
self.
|
|
627
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
482
628
|
|
|
483
629
|
// Also check channel history in case runner.ready was published
|
|
484
630
|
// before we subscribed (race condition on fast-booting agents).
|
|
485
631
|
try {
|
|
486
|
-
self.
|
|
632
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
487
633
|
if (err) {
|
|
488
634
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
489
635
|
return;
|
|
@@ -505,9 +651,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
505
651
|
});
|
|
506
652
|
}
|
|
507
653
|
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
654
|
+
// For E2B sandboxes, use the url from the API reply.
|
|
508
655
|
// Fall back to constructing from ip + noVncPort for older runners.
|
|
509
656
|
let url;
|
|
510
|
-
if (
|
|
657
|
+
if (isE2B && reply.url) {
|
|
658
|
+
url = reply.url;
|
|
659
|
+
logger.log(`E2B sandbox ready — url=${url}`);
|
|
660
|
+
} else if (runnerVncUrl) {
|
|
511
661
|
url = runnerVncUrl;
|
|
512
662
|
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
513
663
|
} else if (runnerIp && noVncPort) {
|
|
@@ -538,17 +688,22 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
538
688
|
// provision the config to the instance via SSM (client-side).
|
|
539
689
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
540
690
|
// rather than from the API server.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
691
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
692
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
693
|
+
var instanceId = message.instanceId;
|
|
694
|
+
if (reply.agentConfig && instanceId) {
|
|
695
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
|
|
696
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
544
697
|
logger.log('Agent config provisioned successfully.');
|
|
698
|
+
} else if (reply.agentConfig && !instanceId) {
|
|
699
|
+
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
545
700
|
}
|
|
546
701
|
|
|
547
702
|
// If the API returned agent credentials (reply.agent present),
|
|
548
703
|
// wait for the runner agent to signal readiness before sending commands.
|
|
549
704
|
// Without this gate, commands published before the agent subscribes are lost.
|
|
550
705
|
var self = this;
|
|
551
|
-
if (reply.agent && this.
|
|
706
|
+
if (reply.agent && this._sessionChannel) {
|
|
552
707
|
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
553
708
|
var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
|
|
554
709
|
await new Promise(function (resolve, reject) {
|
|
@@ -557,7 +712,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
557
712
|
if (resolved) return;
|
|
558
713
|
resolved = true;
|
|
559
714
|
clearTimeout(timer);
|
|
560
|
-
self.
|
|
715
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
561
716
|
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
562
717
|
if (data && data.update) {
|
|
563
718
|
var u = data.update;
|
|
@@ -577,8 +732,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
577
732
|
var timer = setTimeout(function () {
|
|
578
733
|
if (!resolved) {
|
|
579
734
|
resolved = true;
|
|
580
|
-
self.
|
|
581
|
-
|
|
735
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
736
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
737
|
+
sentry.captureException(err, {
|
|
738
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
739
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
740
|
+
});
|
|
741
|
+
reject(err);
|
|
582
742
|
}
|
|
583
743
|
}, readyTimeout);
|
|
584
744
|
if (timer.unref) timer.unref();
|
|
@@ -591,12 +751,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
591
751
|
finish(data);
|
|
592
752
|
}
|
|
593
753
|
};
|
|
594
|
-
self.
|
|
754
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
595
755
|
|
|
596
756
|
// Also check channel history in case runner.ready was published
|
|
597
757
|
// before we subscribed (race condition on fast-booting agents).
|
|
598
758
|
try {
|
|
599
|
-
self.
|
|
759
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
600
760
|
if (err) {
|
|
601
761
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
602
762
|
return;
|
|
@@ -638,7 +798,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
638
798
|
_sendAbly(message, timeout) {
|
|
639
799
|
if (timeout === undefined) timeout = 300000;
|
|
640
800
|
|
|
641
|
-
if (!this.
|
|
801
|
+
if (!this._sessionChannel || !this._ably) {
|
|
642
802
|
return Promise.reject(
|
|
643
803
|
new Error("Sandbox not connected (no Ably client)"),
|
|
644
804
|
);
|
|
@@ -666,7 +826,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
666
826
|
function onFailed() {
|
|
667
827
|
clearTimeout(timer);
|
|
668
828
|
self._ably.connection.off("connected", onConnected);
|
|
669
|
-
reject(new Error("
|
|
829
|
+
reject(new Error("Realtime connection failed while waiting to send"));
|
|
670
830
|
}
|
|
671
831
|
self._ably.connection.once("connected", onConnected);
|
|
672
832
|
self._ably.connection.once("failed", onFailed);
|
|
@@ -724,21 +884,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
724
884
|
|
|
725
885
|
var requestId = message.requestId;
|
|
726
886
|
|
|
727
|
-
|
|
887
|
+
// timeoutId and timeoutExpiresAt are declared as vars so they can be
|
|
888
|
+
// updated by extendTimeout() (closure mutation).
|
|
889
|
+
var timeoutId;
|
|
890
|
+
var timeoutExpiresAt;
|
|
891
|
+
|
|
892
|
+
var timeoutFn = function () {
|
|
728
893
|
if (self.ps[requestId]) {
|
|
894
|
+
var pendingIds = Object.keys(self.ps);
|
|
895
|
+
var pendingSummary = pendingIds.map(function (rid) {
|
|
896
|
+
var e = self.ps[rid];
|
|
897
|
+
var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
|
|
898
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
|
|
899
|
+
}).join(', ');
|
|
900
|
+
logger.error(
|
|
901
|
+
'[realtime] Promise TIMEOUT: requestId=' + requestId +
|
|
902
|
+
' | type=' + message.type +
|
|
903
|
+
' | timeout=' + timeout + 'ms' +
|
|
904
|
+
' | all pending: [' + pendingSummary + ']'
|
|
905
|
+
);
|
|
729
906
|
delete self.ps[requestId];
|
|
730
907
|
delete self._execBuffers[requestId];
|
|
731
908
|
rejectPromise(
|
|
732
909
|
new Error(
|
|
733
910
|
"Sandbox message '" +
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
911
|
+
message.type +
|
|
912
|
+
"' timed out after " +
|
|
913
|
+
timeout +
|
|
914
|
+
"ms",
|
|
738
915
|
),
|
|
739
916
|
);
|
|
740
917
|
}
|
|
741
|
-
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
timeoutId = setTimeout(timeoutFn, timeout);
|
|
921
|
+
timeoutExpiresAt = Date.now() + timeout;
|
|
742
922
|
if (timeoutId.unref) timeoutId.unref();
|
|
743
923
|
|
|
744
924
|
this.ps[requestId] = {
|
|
@@ -751,16 +931,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
751
931
|
clearTimeout(timeoutId);
|
|
752
932
|
rejectPromise(error);
|
|
753
933
|
},
|
|
934
|
+
/**
|
|
935
|
+
* Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
|
|
936
|
+
* to compensate for time spent disconnected.
|
|
937
|
+
*/
|
|
938
|
+
extendTimeout: function (disconnectionDurationMs) {
|
|
939
|
+
clearTimeout(timeoutId);
|
|
940
|
+
// Clamp remaining to 0 so a command whose timer expired during the
|
|
941
|
+
// outage still gets the full disconnection duration as its new budget.
|
|
942
|
+
var remaining = Math.max(0, timeoutExpiresAt - Date.now());
|
|
943
|
+
// Minimum 5s remaining after extension to allow the response to arrive.
|
|
944
|
+
var MIN_REMAINING_MS = 5000;
|
|
945
|
+
var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
|
|
946
|
+
timeoutExpiresAt = Date.now() + newRemaining;
|
|
947
|
+
timeoutId = setTimeout(timeoutFn, newRemaining);
|
|
948
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
949
|
+
logger.log(
|
|
950
|
+
'[realtime] Extended timeout for requestId=' + requestId +
|
|
951
|
+
' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
|
|
952
|
+
);
|
|
953
|
+
},
|
|
754
954
|
message: message,
|
|
755
955
|
startTime: Date.now(),
|
|
756
956
|
};
|
|
757
957
|
|
|
758
958
|
if (message.type === "output") {
|
|
759
|
-
p.catch(function () {});
|
|
959
|
+
p.catch(function () { });
|
|
760
960
|
}
|
|
761
961
|
|
|
762
|
-
this.
|
|
763
|
-
.publish("command", message)
|
|
962
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
764
963
|
.then(function () {
|
|
765
964
|
emitter.emit(events.sandbox.sent, message);
|
|
766
965
|
})
|
|
@@ -777,6 +976,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
777
976
|
return p;
|
|
778
977
|
}
|
|
779
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
|
|
981
|
+
* Also tracks and logs the current publish rate for debugging.
|
|
982
|
+
* @param {Object} channel - Ably channel to publish on
|
|
983
|
+
* @param {string} eventName - Event name for the publish
|
|
984
|
+
* @param {Object} message - Message payload
|
|
985
|
+
* @returns {Promise} - Resolves when publish completes
|
|
986
|
+
*/
|
|
987
|
+
async _throttledPublish(channel, eventName, message) {
|
|
988
|
+
var self = this;
|
|
989
|
+
var now = Date.now();
|
|
990
|
+
|
|
991
|
+
// Rate limiting: wait if too soon since last publish
|
|
992
|
+
var elapsed = now - this._publishLastTime;
|
|
993
|
+
if (elapsed < this._publishMinIntervalMs) {
|
|
994
|
+
var waitMs = this._publishMinIntervalMs - elapsed;
|
|
995
|
+
await new Promise(function (resolve) {
|
|
996
|
+
var timer = setTimeout(resolve, waitMs);
|
|
997
|
+
if (timer.unref) timer.unref();
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
this._publishLastTime = Date.now();
|
|
1001
|
+
|
|
1002
|
+
// Metrics: track messages per second
|
|
1003
|
+
this._publishCount++;
|
|
1004
|
+
var windowElapsed = Date.now() - this._publishWindowStart;
|
|
1005
|
+
if (windowElapsed >= 1000) {
|
|
1006
|
+
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
1007
|
+
var rateStr = rate.toFixed(1);
|
|
1008
|
+
|
|
1009
|
+
// Log rate - warning if approaching limit, debug otherwise
|
|
1010
|
+
if (rate > 45) {
|
|
1011
|
+
logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
1012
|
+
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
1013
|
+
logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Reset window
|
|
1017
|
+
this._publishCount = 0;
|
|
1018
|
+
this._publishWindowStart = Date.now();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return channel.publish(eventName, message).then(function () {
|
|
1022
|
+
logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
780
1026
|
async auth(apiKey) {
|
|
781
1027
|
this.apiKey = apiKey;
|
|
782
1028
|
var sessionId = this.sessionInstance
|
|
@@ -803,7 +1049,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
803
1049
|
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
804
1050
|
logger.log(
|
|
805
1051
|
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
806
|
-
|
|
1052
|
+
reply.traceId,
|
|
807
1053
|
);
|
|
808
1054
|
}
|
|
809
1055
|
|
|
@@ -846,7 +1092,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
846
1092
|
this._sandboxId = reply.sandboxId;
|
|
847
1093
|
|
|
848
1094
|
if (reply.ably && reply.ably.token) {
|
|
849
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
1095
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
850
1096
|
}
|
|
851
1097
|
|
|
852
1098
|
this.setConnectionParams({
|
|
@@ -895,38 +1141,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
895
1141
|
clearInterval(this.heartbeat);
|
|
896
1142
|
this.heartbeat = null;
|
|
897
1143
|
}
|
|
1144
|
+
if (this._statsInterval) {
|
|
1145
|
+
clearInterval(this._statsInterval);
|
|
1146
|
+
this._statsInterval = null;
|
|
1147
|
+
}
|
|
898
1148
|
|
|
899
1149
|
// Send end-session control message to runner before disconnecting
|
|
900
|
-
if (this.
|
|
1150
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
901
1151
|
try {
|
|
902
|
-
|
|
1152
|
+
logger.log('[realtime] Publishing control: type=end-session');
|
|
1153
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
903
1154
|
} catch (e) {
|
|
904
1155
|
// Ignore - best effort
|
|
905
1156
|
}
|
|
906
1157
|
}
|
|
907
1158
|
|
|
908
|
-
// Leave presence on
|
|
909
|
-
if (this.
|
|
1159
|
+
// Leave presence on session channel
|
|
1160
|
+
if (this._sessionChannel) {
|
|
910
1161
|
try {
|
|
911
|
-
|
|
1162
|
+
logger.log('[realtime] Leaving presence on session channel');
|
|
1163
|
+
await this._sessionChannel.presence.leave();
|
|
912
1164
|
} catch (e) {
|
|
913
1165
|
// ignore - best effort, Ably will auto-leave on disconnect
|
|
914
1166
|
}
|
|
915
1167
|
}
|
|
916
1168
|
|
|
917
1169
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
921
|
-
|
|
922
|
-
this._filesChannel?.detach(),
|
|
923
|
-
].filter(Boolean));
|
|
1170
|
+
logger.log('[realtime] Detaching session channel');
|
|
1171
|
+
if (this._sessionChannel) {
|
|
1172
|
+
await this._sessionChannel.detach();
|
|
1173
|
+
}
|
|
924
1174
|
} catch (e) {
|
|
925
1175
|
/* ignore */
|
|
926
1176
|
}
|
|
927
1177
|
|
|
928
1178
|
if (this._ably) {
|
|
929
1179
|
try {
|
|
1180
|
+
logger.log('[realtime] Closing Realtime connection');
|
|
930
1181
|
this._ably.close();
|
|
931
1182
|
} catch (e) {
|
|
932
1183
|
/* ignore */
|
|
@@ -934,11 +1185,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
934
1185
|
this._ably = null;
|
|
935
1186
|
}
|
|
936
1187
|
|
|
937
|
-
this.
|
|
938
|
-
this.
|
|
939
|
-
this._ctrlChannel = null;
|
|
940
|
-
this._filesChannel = null;
|
|
941
|
-
this._channelNames = null;
|
|
1188
|
+
this._sessionChannel = null;
|
|
1189
|
+
this._channelName = null;
|
|
942
1190
|
this.apiSocketConnected = false;
|
|
943
1191
|
this.instanceSocketConnected = false;
|
|
944
1192
|
this.authenticated = false;
|
|
@@ -961,11 +1209,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
961
1209
|
const region = process.env.AWS_REGION || 'us-east-2';
|
|
962
1210
|
|
|
963
1211
|
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1212
|
+
// Log key config details for debugging
|
|
1213
|
+
logger.log('Agent config being provisioned:');
|
|
1214
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1215
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1216
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1217
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1218
|
+
|
|
964
1219
|
const paramsJson = JSON.stringify({
|
|
965
1220
|
commands: [
|
|
1221
|
+
// Debug: show existing state
|
|
1222
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1223
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1224
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1225
|
+
"if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
|
|
1226
|
+
// Stop any running runner
|
|
1227
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1228
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1229
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1230
|
+
// Write config
|
|
1231
|
+
"Write-Host '=== Writing config ==='",
|
|
966
1232
|
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
967
1233
|
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
968
1234
|
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1235
|
+
// Show what was written (redact token)
|
|
1236
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1237
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1238
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1239
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1240
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1241
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1242
|
+
// Start the runner
|
|
1243
|
+
"Write-Host '=== Starting runner ==='",
|
|
1244
|
+
"Start-Sleep -Seconds 1",
|
|
1245
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1246
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1247
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1248
|
+
// Check if node process started
|
|
1249
|
+
"Start-Sleep -Seconds 3",
|
|
1250
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1251
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1252
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1253
|
+
// Check runner logs
|
|
1254
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1255
|
+
"if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
|
|
1256
|
+
"Write-Host '=== Done ==='",
|
|
969
1257
|
],
|
|
970
1258
|
});
|
|
971
1259
|
const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
|
|
@@ -987,6 +1275,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
987
1275
|
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
988
1276
|
{ encoding: 'utf-8', timeout: 60000 }
|
|
989
1277
|
);
|
|
1278
|
+
|
|
1279
|
+
// Get the command output for debugging
|
|
1280
|
+
try {
|
|
1281
|
+
const invocationOutput = execSync(
|
|
1282
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1283
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1284
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1285
|
+
);
|
|
1286
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1287
|
+
if (invocation.StandardOutputContent) {
|
|
1288
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1289
|
+
}
|
|
1290
|
+
if (invocation.StandardErrorContent) {
|
|
1291
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1292
|
+
}
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1295
|
+
}
|
|
990
1296
|
} finally {
|
|
991
1297
|
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
992
1298
|
}
|