testdriverai 7.8.0-test.5 → 7.8.0-test.50
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 +12 -0
- package/agent/index.js +6 -5
- package/agent/lib/commands.js +3 -2
- package/agent/lib/http.js +144 -0
- package/agent/lib/sandbox.js +492 -203
- 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-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 +155 -3
- 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/{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/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 +3 -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.js +3 -3
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +134 -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 Ably 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,18 +57,36 @@ 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.log(`[ably] 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 () {
|
|
@@ -148,26 +97,29 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
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.log(`[ably] 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.log(`[ably] 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.log(`[ably] 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
|
+
'[ably] 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
|
+
'[ably] 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.log(
|
|
216
|
+
'[ably] 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.log(`[ably] 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.log(`[ably][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.log(`[ably][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.log("
|
|
278
|
+
logger.log("[ably] 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.log("
|
|
284
|
+
logger.log("[ably] 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.log(
|
|
294
|
+
'[ably] 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("[ably] 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("[ably] Connection: failed");
|
|
294
313
|
self.apiSocketConnected = false;
|
|
295
314
|
self.instanceSocketConnected = false;
|
|
296
315
|
emitter.emit(events.error.sandbox, "Ably connection failed");
|
|
297
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('[ably] 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
|
+
}
|
|
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.log('[ably] 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.log('[ably] 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('[ably] Error replaying recovered message: ' + (replayErr.message || replayErr));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
page = page.hasNext() ? await page.next() : null;
|
|
374
|
+
}
|
|
375
|
+
totalRecovered += recovered;
|
|
376
|
+
logger.log('[ably] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (totalRecovered > 0) {
|
|
382
|
+
logger.warn('[ably] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
383
|
+
} else {
|
|
384
|
+
logger.log('[ably] 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
|
}
|
|
@@ -390,14 +517,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
390
517
|
this._teamId = reply.teamId;
|
|
391
518
|
|
|
392
519
|
if (reply.ably && reply.ably.token) {
|
|
393
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
520
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
394
521
|
this.instanceSocketConnected = true;
|
|
395
522
|
|
|
396
523
|
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
397
524
|
var debugMode =
|
|
398
525
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
399
|
-
if (debugMode && this.
|
|
400
|
-
this.
|
|
526
|
+
if (debugMode && this._sessionChannel) {
|
|
527
|
+
this._sessionChannel.publish("control", {
|
|
401
528
|
type: "debug",
|
|
402
529
|
enabled: true,
|
|
403
530
|
});
|
|
@@ -405,41 +532,38 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
405
532
|
}
|
|
406
533
|
|
|
407
534
|
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
|
-
|
|
535
|
+
// E2B (Linux) sandboxes return a url directly.
|
|
536
|
+
// We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
|
|
537
|
+
const isE2B = !!reply.url;
|
|
538
|
+
|
|
423
539
|
const runnerIp = reply.runner && reply.runner.ip;
|
|
424
540
|
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
425
541
|
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
426
542
|
|
|
427
|
-
|
|
543
|
+
if (!isE2B) {
|
|
544
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
545
|
+
}
|
|
428
546
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
547
|
+
// Wait for the runner agent to signal readiness before sending commands.
|
|
548
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
549
|
+
// This applies to:
|
|
550
|
+
// - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
|
|
551
|
+
// - Windows EC2 sandboxes without presence runners
|
|
552
|
+
// For presence-based Windows runners (reply.runner already set), the runner
|
|
553
|
+
// is already listening so we can skip the wait.
|
|
432
554
|
var self = this;
|
|
433
|
-
|
|
555
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
556
|
+
if (needsReadyWait) {
|
|
434
557
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
435
|
-
|
|
558
|
+
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
|
|
559
|
+
var readyTimeout = isE2B ? 300000 : 120000; // 5 min for E2B (S3+npm), 2 min for EC2
|
|
436
560
|
await new Promise(function (resolve, reject) {
|
|
437
561
|
var resolved = false;
|
|
438
562
|
function finish(data) {
|
|
439
563
|
if (resolved) return;
|
|
440
564
|
resolved = true;
|
|
441
565
|
clearTimeout(timer);
|
|
442
|
-
self.
|
|
566
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
443
567
|
// Update runner info if provided
|
|
444
568
|
if (data && data.os) reply.runner = reply.runner || {};
|
|
445
569
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
@@ -464,8 +588,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
464
588
|
var timer = setTimeout(function () {
|
|
465
589
|
if (!resolved) {
|
|
466
590
|
resolved = true;
|
|
467
|
-
self.
|
|
468
|
-
|
|
591
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
592
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
593
|
+
sentry.captureException(err, {
|
|
594
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
595
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
596
|
+
});
|
|
597
|
+
reject(err);
|
|
469
598
|
}
|
|
470
599
|
}, readyTimeout);
|
|
471
600
|
if (timer.unref) timer.unref();
|
|
@@ -478,12 +607,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
478
607
|
finish(data);
|
|
479
608
|
}
|
|
480
609
|
};
|
|
481
|
-
self.
|
|
610
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
482
611
|
|
|
483
612
|
// Also check channel history in case runner.ready was published
|
|
484
613
|
// before we subscribed (race condition on fast-booting agents).
|
|
485
614
|
try {
|
|
486
|
-
self.
|
|
615
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
487
616
|
if (err) {
|
|
488
617
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
489
618
|
return;
|
|
@@ -505,9 +634,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
505
634
|
});
|
|
506
635
|
}
|
|
507
636
|
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
637
|
+
// For E2B sandboxes, use the url from the API reply.
|
|
508
638
|
// Fall back to constructing from ip + noVncPort for older runners.
|
|
509
639
|
let url;
|
|
510
|
-
if (
|
|
640
|
+
if (isE2B && reply.url) {
|
|
641
|
+
url = reply.url;
|
|
642
|
+
logger.log(`E2B sandbox ready — url=${url}`);
|
|
643
|
+
} else if (runnerVncUrl) {
|
|
511
644
|
url = runnerVncUrl;
|
|
512
645
|
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
513
646
|
} else if (runnerIp && noVncPort) {
|
|
@@ -538,17 +671,22 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
538
671
|
// provision the config to the instance via SSM (client-side).
|
|
539
672
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
540
673
|
// rather than from the API server.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
674
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
675
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
676
|
+
var instanceId = message.instanceId;
|
|
677
|
+
if (reply.agentConfig && instanceId) {
|
|
678
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
|
|
679
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
544
680
|
logger.log('Agent config provisioned successfully.');
|
|
681
|
+
} else if (reply.agentConfig && !instanceId) {
|
|
682
|
+
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
545
683
|
}
|
|
546
684
|
|
|
547
685
|
// If the API returned agent credentials (reply.agent present),
|
|
548
686
|
// wait for the runner agent to signal readiness before sending commands.
|
|
549
687
|
// Without this gate, commands published before the agent subscribes are lost.
|
|
550
688
|
var self = this;
|
|
551
|
-
if (reply.agent && this.
|
|
689
|
+
if (reply.agent && this._sessionChannel) {
|
|
552
690
|
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
553
691
|
var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
|
|
554
692
|
await new Promise(function (resolve, reject) {
|
|
@@ -557,7 +695,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
557
695
|
if (resolved) return;
|
|
558
696
|
resolved = true;
|
|
559
697
|
clearTimeout(timer);
|
|
560
|
-
self.
|
|
698
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
561
699
|
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
562
700
|
if (data && data.update) {
|
|
563
701
|
var u = data.update;
|
|
@@ -577,8 +715,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
577
715
|
var timer = setTimeout(function () {
|
|
578
716
|
if (!resolved) {
|
|
579
717
|
resolved = true;
|
|
580
|
-
self.
|
|
581
|
-
|
|
718
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
719
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
720
|
+
sentry.captureException(err, {
|
|
721
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
722
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
723
|
+
});
|
|
724
|
+
reject(err);
|
|
582
725
|
}
|
|
583
726
|
}, readyTimeout);
|
|
584
727
|
if (timer.unref) timer.unref();
|
|
@@ -591,12 +734,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
591
734
|
finish(data);
|
|
592
735
|
}
|
|
593
736
|
};
|
|
594
|
-
self.
|
|
737
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
595
738
|
|
|
596
739
|
// Also check channel history in case runner.ready was published
|
|
597
740
|
// before we subscribed (race condition on fast-booting agents).
|
|
598
741
|
try {
|
|
599
|
-
self.
|
|
742
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
600
743
|
if (err) {
|
|
601
744
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
602
745
|
return;
|
|
@@ -638,7 +781,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
638
781
|
_sendAbly(message, timeout) {
|
|
639
782
|
if (timeout === undefined) timeout = 300000;
|
|
640
783
|
|
|
641
|
-
if (!this.
|
|
784
|
+
if (!this._sessionChannel || !this._ably) {
|
|
642
785
|
return Promise.reject(
|
|
643
786
|
new Error("Sandbox not connected (no Ably client)"),
|
|
644
787
|
);
|
|
@@ -724,21 +867,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
724
867
|
|
|
725
868
|
var requestId = message.requestId;
|
|
726
869
|
|
|
727
|
-
|
|
870
|
+
// timeoutId and timeoutExpiresAt are declared as vars so they can be
|
|
871
|
+
// updated by extendTimeout() (closure mutation).
|
|
872
|
+
var timeoutId;
|
|
873
|
+
var timeoutExpiresAt;
|
|
874
|
+
|
|
875
|
+
var timeoutFn = function () {
|
|
728
876
|
if (self.ps[requestId]) {
|
|
877
|
+
var pendingIds = Object.keys(self.ps);
|
|
878
|
+
var pendingSummary = pendingIds.map(function (rid) {
|
|
879
|
+
var e = self.ps[rid];
|
|
880
|
+
var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
|
|
881
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
|
|
882
|
+
}).join(', ');
|
|
883
|
+
logger.error(
|
|
884
|
+
'[ably] Promise TIMEOUT: requestId=' + requestId +
|
|
885
|
+
' | type=' + message.type +
|
|
886
|
+
' | timeout=' + timeout + 'ms' +
|
|
887
|
+
' | all pending: [' + pendingSummary + ']'
|
|
888
|
+
);
|
|
729
889
|
delete self.ps[requestId];
|
|
730
890
|
delete self._execBuffers[requestId];
|
|
731
891
|
rejectPromise(
|
|
732
892
|
new Error(
|
|
733
893
|
"Sandbox message '" +
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
894
|
+
message.type +
|
|
895
|
+
"' timed out after " +
|
|
896
|
+
timeout +
|
|
897
|
+
"ms",
|
|
738
898
|
),
|
|
739
899
|
);
|
|
740
900
|
}
|
|
741
|
-
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
timeoutId = setTimeout(timeoutFn, timeout);
|
|
904
|
+
timeoutExpiresAt = Date.now() + timeout;
|
|
742
905
|
if (timeoutId.unref) timeoutId.unref();
|
|
743
906
|
|
|
744
907
|
this.ps[requestId] = {
|
|
@@ -751,16 +914,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
751
914
|
clearTimeout(timeoutId);
|
|
752
915
|
rejectPromise(error);
|
|
753
916
|
},
|
|
917
|
+
/**
|
|
918
|
+
* Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
|
|
919
|
+
* to compensate for time spent disconnected.
|
|
920
|
+
*/
|
|
921
|
+
extendTimeout: function (disconnectionDurationMs) {
|
|
922
|
+
clearTimeout(timeoutId);
|
|
923
|
+
// Clamp remaining to 0 so a command whose timer expired during the
|
|
924
|
+
// outage still gets the full disconnection duration as its new budget.
|
|
925
|
+
var remaining = Math.max(0, timeoutExpiresAt - Date.now());
|
|
926
|
+
// Minimum 5s remaining after extension to allow the response to arrive.
|
|
927
|
+
var MIN_REMAINING_MS = 5000;
|
|
928
|
+
var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
|
|
929
|
+
timeoutExpiresAt = Date.now() + newRemaining;
|
|
930
|
+
timeoutId = setTimeout(timeoutFn, newRemaining);
|
|
931
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
932
|
+
logger.log(
|
|
933
|
+
'[ably] Extended timeout for requestId=' + requestId +
|
|
934
|
+
' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
|
|
935
|
+
);
|
|
936
|
+
},
|
|
754
937
|
message: message,
|
|
755
938
|
startTime: Date.now(),
|
|
756
939
|
};
|
|
757
940
|
|
|
758
941
|
if (message.type === "output") {
|
|
759
|
-
p.catch(function () {});
|
|
942
|
+
p.catch(function () { });
|
|
760
943
|
}
|
|
761
944
|
|
|
762
|
-
this.
|
|
763
|
-
.publish("command", message)
|
|
945
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
764
946
|
.then(function () {
|
|
765
947
|
emitter.emit(events.sandbox.sent, message);
|
|
766
948
|
})
|
|
@@ -777,6 +959,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
777
959
|
return p;
|
|
778
960
|
}
|
|
779
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
|
|
964
|
+
* Also tracks and logs the current publish rate for debugging.
|
|
965
|
+
* @param {Object} channel - Ably channel to publish on
|
|
966
|
+
* @param {string} eventName - Event name for the publish
|
|
967
|
+
* @param {Object} message - Message payload
|
|
968
|
+
* @returns {Promise} - Resolves when publish completes
|
|
969
|
+
*/
|
|
970
|
+
async _throttledPublish(channel, eventName, message) {
|
|
971
|
+
var self = this;
|
|
972
|
+
var now = Date.now();
|
|
973
|
+
|
|
974
|
+
// Rate limiting: wait if too soon since last publish
|
|
975
|
+
var elapsed = now - this._publishLastTime;
|
|
976
|
+
if (elapsed < this._publishMinIntervalMs) {
|
|
977
|
+
var waitMs = this._publishMinIntervalMs - elapsed;
|
|
978
|
+
await new Promise(function (resolve) {
|
|
979
|
+
var timer = setTimeout(resolve, waitMs);
|
|
980
|
+
if (timer.unref) timer.unref();
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
this._publishLastTime = Date.now();
|
|
984
|
+
|
|
985
|
+
// Metrics: track messages per second
|
|
986
|
+
this._publishCount++;
|
|
987
|
+
var windowElapsed = Date.now() - this._publishWindowStart;
|
|
988
|
+
if (windowElapsed >= 1000) {
|
|
989
|
+
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
990
|
+
var rateStr = rate.toFixed(1);
|
|
991
|
+
|
|
992
|
+
// Log rate - warning if approaching limit, debug otherwise
|
|
993
|
+
if (rate > 45) {
|
|
994
|
+
logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
995
|
+
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
996
|
+
logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Reset window
|
|
1000
|
+
this._publishCount = 0;
|
|
1001
|
+
this._publishWindowStart = Date.now();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return channel.publish(eventName, message).then(function () {
|
|
1005
|
+
logger.log(`[ably] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
780
1009
|
async auth(apiKey) {
|
|
781
1010
|
this.apiKey = apiKey;
|
|
782
1011
|
var sessionId = this.sessionInstance
|
|
@@ -803,7 +1032,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
803
1032
|
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
804
1033
|
logger.log(
|
|
805
1034
|
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
806
|
-
|
|
1035
|
+
reply.traceId,
|
|
807
1036
|
);
|
|
808
1037
|
}
|
|
809
1038
|
|
|
@@ -846,7 +1075,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
846
1075
|
this._sandboxId = reply.sandboxId;
|
|
847
1076
|
|
|
848
1077
|
if (reply.ably && reply.ably.token) {
|
|
849
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
1078
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
850
1079
|
}
|
|
851
1080
|
|
|
852
1081
|
this.setConnectionParams({
|
|
@@ -895,38 +1124,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
895
1124
|
clearInterval(this.heartbeat);
|
|
896
1125
|
this.heartbeat = null;
|
|
897
1126
|
}
|
|
1127
|
+
if (this._statsInterval) {
|
|
1128
|
+
clearInterval(this._statsInterval);
|
|
1129
|
+
this._statsInterval = null;
|
|
1130
|
+
}
|
|
898
1131
|
|
|
899
1132
|
// Send end-session control message to runner before disconnecting
|
|
900
|
-
if (this.
|
|
1133
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
901
1134
|
try {
|
|
902
|
-
|
|
1135
|
+
logger.log('[ably] Publishing control: type=end-session');
|
|
1136
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
903
1137
|
} catch (e) {
|
|
904
1138
|
// Ignore - best effort
|
|
905
1139
|
}
|
|
906
1140
|
}
|
|
907
1141
|
|
|
908
|
-
// Leave presence on
|
|
909
|
-
if (this.
|
|
1142
|
+
// Leave presence on session channel
|
|
1143
|
+
if (this._sessionChannel) {
|
|
910
1144
|
try {
|
|
911
|
-
|
|
1145
|
+
logger.log('[ably] Leaving presence on session channel');
|
|
1146
|
+
await this._sessionChannel.presence.leave();
|
|
912
1147
|
} catch (e) {
|
|
913
1148
|
// ignore - best effort, Ably will auto-leave on disconnect
|
|
914
1149
|
}
|
|
915
1150
|
}
|
|
916
1151
|
|
|
917
1152
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
921
|
-
|
|
922
|
-
this._filesChannel?.detach(),
|
|
923
|
-
].filter(Boolean));
|
|
1153
|
+
logger.log('[ably] Detaching session channel');
|
|
1154
|
+
if (this._sessionChannel) {
|
|
1155
|
+
await this._sessionChannel.detach();
|
|
1156
|
+
}
|
|
924
1157
|
} catch (e) {
|
|
925
1158
|
/* ignore */
|
|
926
1159
|
}
|
|
927
1160
|
|
|
928
1161
|
if (this._ably) {
|
|
929
1162
|
try {
|
|
1163
|
+
logger.log('[ably] Closing Ably connection');
|
|
930
1164
|
this._ably.close();
|
|
931
1165
|
} catch (e) {
|
|
932
1166
|
/* ignore */
|
|
@@ -934,11 +1168,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
934
1168
|
this._ably = null;
|
|
935
1169
|
}
|
|
936
1170
|
|
|
937
|
-
this.
|
|
938
|
-
this.
|
|
939
|
-
this._ctrlChannel = null;
|
|
940
|
-
this._filesChannel = null;
|
|
941
|
-
this._channelNames = null;
|
|
1171
|
+
this._sessionChannel = null;
|
|
1172
|
+
this._channelName = null;
|
|
942
1173
|
this.apiSocketConnected = false;
|
|
943
1174
|
this.instanceSocketConnected = false;
|
|
944
1175
|
this.authenticated = false;
|
|
@@ -961,11 +1192,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
961
1192
|
const region = process.env.AWS_REGION || 'us-east-2';
|
|
962
1193
|
|
|
963
1194
|
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1195
|
+
// Log key config details for debugging
|
|
1196
|
+
logger.log('Agent config being provisioned:');
|
|
1197
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1198
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1199
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1200
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1201
|
+
|
|
964
1202
|
const paramsJson = JSON.stringify({
|
|
965
1203
|
commands: [
|
|
1204
|
+
// Debug: show existing state
|
|
1205
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1206
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1207
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1208
|
+
"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' }",
|
|
1209
|
+
// Stop any running runner
|
|
1210
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1211
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1212
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1213
|
+
// Write config
|
|
1214
|
+
"Write-Host '=== Writing config ==='",
|
|
966
1215
|
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
967
1216
|
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
968
1217
|
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1218
|
+
// Show what was written (redact token)
|
|
1219
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1220
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1221
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1222
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1223
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1224
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1225
|
+
// Start the runner
|
|
1226
|
+
"Write-Host '=== Starting runner ==='",
|
|
1227
|
+
"Start-Sleep -Seconds 1",
|
|
1228
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1229
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1230
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1231
|
+
// Check if node process started
|
|
1232
|
+
"Start-Sleep -Seconds 3",
|
|
1233
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1234
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1235
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1236
|
+
// Check runner logs
|
|
1237
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1238
|
+
"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' }",
|
|
1239
|
+
"Write-Host '=== Done ==='",
|
|
969
1240
|
],
|
|
970
1241
|
});
|
|
971
1242
|
const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
|
|
@@ -987,6 +1258,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
987
1258
|
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
988
1259
|
{ encoding: 'utf-8', timeout: 60000 }
|
|
989
1260
|
);
|
|
1261
|
+
|
|
1262
|
+
// Get the command output for debugging
|
|
1263
|
+
try {
|
|
1264
|
+
const invocationOutput = execSync(
|
|
1265
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1266
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1267
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1268
|
+
);
|
|
1269
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1270
|
+
if (invocation.StandardOutputContent) {
|
|
1271
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1272
|
+
}
|
|
1273
|
+
if (invocation.StandardErrorContent) {
|
|
1274
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1275
|
+
}
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1278
|
+
}
|
|
990
1279
|
} finally {
|
|
991
1280
|
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
992
1281
|
}
|