testdriverai 7.4.4 → 7.5.0

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.
Files changed (45) hide show
  1. package/.github/copilot-instructions.md +1 -1
  2. package/.github/skills/testdriver:performing-actions/SKILL.md +3 -0
  3. package/.github/skills/testdriver:waiting-for-elements/SKILL.md +16 -0
  4. package/CHANGELOG.md +4 -0
  5. package/agent/events.js +7 -0
  6. package/agent/index.js +24 -17
  7. package/agent/lib/sandbox.js +703 -428
  8. package/agent/lib/system.js +70 -1
  9. package/ai/agents/testdriver.md +1 -1
  10. package/ai/skills/testdriver:performing-actions/SKILL.md +3 -0
  11. package/ai/skills/testdriver:testdriver/SKILL.md +1 -1
  12. package/ai/skills/testdriver:waiting-for-elements/SKILL.md +16 -0
  13. package/docs/_data/examples-manifest.json +68 -68
  14. package/docs/guide/best-practices-polling.mdx +25 -5
  15. package/docs/v7/examples/ai.mdx +1 -1
  16. package/docs/v7/examples/assert.mdx +1 -1
  17. package/docs/v7/examples/chrome-extension.mdx +1 -1
  18. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  19. package/docs/v7/examples/element-not-found.mdx +1 -1
  20. package/docs/v7/examples/hover-image.mdx +1 -1
  21. package/docs/v7/examples/hover-text.mdx +1 -1
  22. package/docs/v7/examples/installer.mdx +1 -1
  23. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  24. package/docs/v7/examples/match-image.mdx +1 -1
  25. package/docs/v7/examples/press-keys.mdx +1 -1
  26. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  27. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  28. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  29. package/docs/v7/examples/scroll.mdx +1 -1
  30. package/docs/v7/examples/type.mdx +1 -1
  31. package/docs/v7/examples/windows-installer.mdx +1 -1
  32. package/docs/v7/performing-actions.mdx +3 -0
  33. package/docs/v7/wait.mdx +52 -0
  34. package/docs/v7/waiting-for-elements.mdx +18 -0
  35. package/examples/chrome-extension.test.mjs +2 -0
  36. package/examples/no-provision.test.mjs +2 -0
  37. package/lib/vitest/hooks.mjs +9 -0
  38. package/lib/vitest/setup-aws.mjs +1 -0
  39. package/manual/packer-hover-image.test.mjs +176 -0
  40. package/package.json +2 -1
  41. package/sdk.d.ts +14 -2
  42. package/sdk.js +10 -0
  43. package/setup/aws/cloudformation.yaml +1 -8
  44. package/setup/aws/spawn-runner.sh +2 -37
  45. package/vitest.config.mjs +1 -1
@@ -1,32 +1,93 @@
1
- const WebSocket = require("ws");
2
1
  const crypto = require("crypto");
2
+ const Ably = require("ably");
3
3
  const { events } = require("../events");
4
4
  const logger = require("./logger");
5
5
  const { version } = require("../../package.json");
6
6
 
7
- /**
8
- * Generate Sentry trace headers for distributed tracing
9
- * Uses the same trace ID derivation as the API (MD5 hash of session ID)
10
- * @param {string} sessionId - The session ID
11
- * @returns {Object} Headers object with sentry-trace and baggage
12
- */
13
7
  function getSentryTraceHeaders(sessionId) {
14
8
  if (!sessionId) return {};
15
-
16
- // Same logic as API: derive trace ID from session ID
17
9
  const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
18
10
  const spanId = crypto.randomBytes(8).toString("hex");
19
-
20
11
  return {
21
- "sentry-trace": `${traceId}-${spanId}-1`,
22
- baggage: `sentry-trace_id=${traceId},sentry-sample_rate=1.0,sentry-sampled=true`,
12
+ "sentry-trace": traceId + "-" + spanId + "-1",
13
+ baggage:
14
+ "sentry-trace_id=" +
15
+ traceId +
16
+ ",sentry-sample_rate=1.0,sentry-sampled=true",
23
17
  };
24
18
  }
25
19
 
26
- const createSandbox = (emitter, analytics, sessionInstance) => {
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
+ }
81
+
82
+ const createSandbox = function (emitter, analytics, sessionInstance) {
27
83
  class Sandbox {
28
84
  constructor() {
29
- this.socket = null;
85
+ this._ably = null;
86
+ this._cmdChannel = null;
87
+ this._respChannel = null;
88
+ this._ctrlChannel = null;
89
+ this._filesChannel = null;
90
+ this._channelNames = null;
30
91
  this.ps = {};
31
92
  this.heartbeat = null;
32
93
  this.apiSocketConnected = false;
@@ -35,525 +96,739 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
35
96
  this.instance = null;
36
97
  this.messageId = 0;
37
98
  this.uniqueId = Math.random().toString(36).substring(7);
38
- this.os = null; // Store OS value to send with every message
39
- this.sessionInstance = sessionInstance; // Store session instance to include in messages
40
- this.traceId = null; // Sentry trace ID for debugging
41
- this.reconnectAttempts = 0;
42
- this.maxReconnectAttempts = 10;
43
- this.intentionalDisconnect = false;
99
+ this.os = null;
100
+ this.sessionInstance = sessionInstance;
101
+ this.traceId = null;
44
102
  this.apiRoot = null;
45
103
  this.apiKey = null;
46
- this.reconnectTimer = null; // Track reconnect setTimeout
47
- this.reconnecting = false; // Prevent duplicate reconnection attempts
48
- this.pendingTimeouts = new Map(); // Track per-message timeouts
49
- this.pendingRetryQueue = []; // Queue of requests to retry after reconnection
50
- this._lastConnectParams = null; // Connection params for reconnection (per-instance, not shared)
104
+ this._lastConnectParams = null;
105
+ this._teamId = null;
106
+ this._sandboxId = null;
51
107
  }
52
108
 
53
- /**
54
- * Get the Sentry trace ID for this session
55
- * Useful for debugging with customers - they can share this ID to look up their traces
56
- * @returns {string|null} The trace ID or null if not authenticated
57
- */
58
109
  getTraceId() {
59
110
  return this.traceId;
60
111
  }
61
112
 
62
- /**
63
- * Get the Sentry trace URL for this session
64
- * @returns {string|null} The full Sentry trace URL or null if no trace ID
65
- */
66
113
  getTraceUrl() {
67
114
  if (!this.traceId) return null;
68
- return `https://testdriver.sentry.io/explore/traces/trace/${this.traceId}`;
115
+ return (
116
+ "https://testdriver.sentry.io/explore/traces/trace/" + this.traceId
117
+ );
69
118
  }
70
119
 
71
- send(message, timeout = 300000) {
72
- let resolvePromise;
73
- let rejectPromise;
120
+ async _initAbly(ablyToken, channelNames) {
121
+ if (this._ably) {
122
+ try {
123
+ this._ably.close();
124
+ } catch (e) {
125
+ /* ignore */
126
+ }
127
+ }
128
+ this._channelNames = channelNames;
129
+ var self = this;
130
+
131
+ this._ably = new Ably.Realtime({
132
+ authCallback: function (tokenParams, callback) {
133
+ callback(null, ablyToken);
134
+ },
135
+ clientId: "sdk-" + this._sandboxId,
136
+ disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
137
+ suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
138
+ });
74
139
 
75
- // Check if socket exists and is actually open before sending
76
- // This prevents sending to a closed connection (e.g., sandbox killed due to test failure)
77
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
78
- this.messageId++;
79
- message.requestId = `${this.uniqueId}-${this.messageId}`;
140
+ await new Promise(function (resolve, reject) {
141
+ self._ably.connection.on("connected", resolve);
142
+ self._ably.connection.on("failed", function () {
143
+ reject(new Error("Ably connection failed"));
144
+ });
145
+ setTimeout(function () {
146
+ reject(new Error("Ably connection timeout"));
147
+ }, 30000);
148
+ });
80
149
 
81
- // If os is set in the message, store it for future messages
82
- if (message.os) {
83
- this.os = message.os;
150
+ this._cmdChannel = this._ably.channels.get(channelNames.commands);
151
+ this._respChannel = this._ably.channels.get(channelNames.responses);
152
+ this._ctrlChannel = this._ably.channels.get(channelNames.control);
153
+ this._filesChannel = this._ably.channels.get(channelNames.files);
154
+
155
+ this._respChannel.subscribe("response", function (msg) {
156
+ var message = msg.data;
157
+ if (!message) return;
158
+
159
+ if (message.type === "sandbox.progress") {
160
+ emitter.emit(events.sandbox.progress, {
161
+ step: message.step,
162
+ message: message.message,
163
+ });
164
+ return;
84
165
  }
85
166
 
86
- // Add os to every message if it's been set
87
- if (this.os && !message.os) {
88
- message.os = this.os;
167
+ if (
168
+ message.type === "before.file" ||
169
+ message.type === "after.file" ||
170
+ message.type === "screenshot.file"
171
+ ) {
172
+ emitter.emit(events.sandbox.file, message);
173
+ return;
89
174
  }
90
175
 
91
- // Add session to every message if available (for interaction tracking)
92
- if (this.sessionInstance && !message.session) {
93
- const sessionId = this.sessionInstance.get();
94
- if (sessionId) {
95
- message.session = sessionId;
96
- }
176
+ // Streaming exec output chunks emit as events, don't resolve the pending promise
177
+ if (message.type === "exec.output") {
178
+ emitter.emit(events.exec.output, { chunk: message.chunk, requestId: message.requestId });
179
+ return;
97
180
  }
98
181
 
99
- // Add sandboxId to every message if we have a connected sandbox
100
- // This allows the API to reconnect if the connection was rerouted
101
- // Don't inject IP addresses as sandboxId — only valid instance/sandbox IDs
102
- if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
103
- const id = this._lastConnectParams.sandboxId;
104
- // Only inject if it looks like a valid ID (not an IP address)
105
- if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
106
- message.sandboxId = id;
182
+ // Runner debug logs only received when debug mode is enabled
183
+ if (message.type === "runner.log") {
184
+ var logLevel = message.level || "info";
185
+ var logMsg = "[runner] " + (message.message || "");
186
+ if (logLevel === "error") {
187
+ logger.error(logMsg);
188
+ } else {
189
+ logger.log(logMsg);
107
190
  }
191
+ emitter.emit(events.runner.log, {
192
+ level: logLevel,
193
+ message: message.message,
194
+ timestamp: message.timestamp,
195
+ });
196
+ return;
108
197
  }
109
198
 
110
- let p = new Promise((resolve, reject) => {
111
- this.socket.send(JSON.stringify(message));
112
- emitter.emit(events.sandbox.sent, message);
113
- resolvePromise = resolve;
114
- rejectPromise = reject;
115
- });
116
-
117
- const requestId = message.requestId;
118
-
119
- // Set up timeout to prevent hanging requests
120
- const timeoutId = setTimeout(() => {
121
- this.pendingTimeouts.delete(requestId);
122
- if (this.ps[requestId]) {
123
- delete this.ps[requestId];
124
- rejectPromise(
125
- new Error(
126
- `Sandbox message '${message.type}' timed out after ${timeout}ms`,
127
- ),
199
+ if (!message.requestId || !self.ps[message.requestId]) {
200
+ var debugMode =
201
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
202
+ if (debugMode) {
203
+ console.warn(
204
+ "No pending promise found for requestId:",
205
+ message.requestId,
128
206
  );
129
207
  }
130
- }, timeout);
131
- // Don't let pending timeouts prevent Node process from exiting
132
- if (timeoutId.unref) {
133
- timeoutId.unref();
208
+ return;
134
209
  }
135
210
 
136
- // Track timeout so close() can clear it
137
- this.pendingTimeouts.set(requestId, timeoutId);
138
-
139
- this.ps[requestId] = {
140
- promise: p,
141
- resolve: (result) => {
142
- clearTimeout(timeoutId);
143
- this.pendingTimeouts.delete(requestId);
144
- resolvePromise(result);
145
- },
146
- reject: (error) => {
147
- clearTimeout(timeoutId);
148
- this.pendingTimeouts.delete(requestId);
149
- rejectPromise(error);
150
- },
151
- message,
152
- startTime: Date.now(),
153
- };
211
+ if (message.error) {
212
+ var pendingMessage =
213
+ self.ps[message.requestId] &&
214
+ self.ps[message.requestId].message;
215
+ if (!pendingMessage || pendingMessage.type !== "output") {
216
+ emitter.emit(events.error.sandbox, message.errorMessage);
217
+ }
218
+ var error = new Error(message.errorMessage || "Sandbox error");
219
+ error.responseData = message;
220
+ self.ps[message.requestId].reject(error);
221
+ } else {
222
+ emitter.emit(events.sandbox.received);
223
+ if (self.ps[message.requestId]) {
224
+ // Unwrap the result from the Ably response envelope
225
+ // The runner sends { requestId, type, result, success }
226
+ // But SDK commands expect just the result object
227
+ var resolvedValue = message.result !== undefined ? message.result : message;
228
+ self.ps[message.requestId].resolve(resolvedValue);
229
+ }
230
+ }
231
+ delete self.ps[message.requestId];
232
+ });
154
233
 
155
- // Fire-and-forget message types: attach .catch() to prevent
156
- // unhandled promise rejections if nobody awaits the result
157
- const fireAndForgetTypes = ["output"];
158
- if (fireAndForgetTypes.includes(message.type)) {
159
- p.catch(() => {});
234
+ this._filesChannel.subscribe("response", function (msg) {
235
+ var message = msg.data;
236
+ if (!message) return;
237
+ if (message.requestId && self.ps[message.requestId]) {
238
+ emitter.emit(events.sandbox.received);
239
+ self.ps[message.requestId].resolve(message);
240
+ delete self.ps[message.requestId];
160
241
  }
242
+ emitter.emit(events.sandbox.file, message);
243
+ });
161
244
 
162
- return p;
163
- }
245
+ this.heartbeat = setInterval(function () {}, 5000);
246
+ if (this.heartbeat.unref) this.heartbeat.unref();
164
247
 
165
- // Return a rejected promise if socket is not available or not open
166
- // This can happen when the sandbox is killed (e.g., due to test failure)
167
- const state = this.socket?.readyState;
168
- const stateMap = {
169
- [WebSocket.CONNECTING]: "connecting",
170
- [WebSocket.CLOSING]: "closing",
171
- [WebSocket.CLOSED]: "closed",
172
- };
173
- const stateDesc = stateMap[state] || "unavailable";
174
- return Promise.reject(new Error(`Sandbox socket not connected (state: ${stateDesc})`));
175
- }
248
+ this._ably.connection.on("disconnected", function () {
249
+ logger.log("Ably disconnected - will auto-reconnect");
250
+ });
176
251
 
177
- async auth(apiKey) {
178
- this.apiKey = apiKey;
179
- let reply = await this.send({
180
- type: "authenticate",
181
- apiKey,
182
- version,
252
+ this._ably.connection.on("connected", function () {
253
+ // Log reconnection so the user knows the blip was recovered
254
+ logger.log("Ably reconnected");
183
255
  });
184
256
 
185
- if (reply.success) {
186
- this.authenticated = true;
257
+ this._ably.connection.on("suspended", function () {
258
+ logger.warn("Ably suspended - connection lost for extended period, will keep retrying");
259
+ });
187
260
 
188
- // Log and store the Sentry trace ID for debugging
189
- if (reply.traceId) {
190
- this.traceId = reply.traceId;
191
- logger.log('');
192
- logger.log(`🔗 Trace Report (Share When Reporting Bugs):`);
193
- logger.log(`https://testdriver.sentry.io/explore/traces/trace/${reply.traceId}`);
194
- }
261
+ this._ably.connection.on("failed", function () {
262
+ self.apiSocketConnected = false;
263
+ self.instanceSocketConnected = false;
264
+ emitter.emit(events.error.sandbox, "Ably connection failed");
265
+ });
266
+ }
195
267
 
196
- emitter.emit(events.sandbox.authenticated, { traceId: reply.traceId });
197
- return true;
268
+ send(message, timeout) {
269
+ if (timeout === undefined) timeout = 300000;
270
+ if (message.type === "create" || message.type === "direct") {
271
+ return this._sendHttp(message, timeout);
198
272
  }
273
+ return this._sendAbly(message, timeout);
199
274
  }
200
275
 
201
- /**
202
- * Set connection params for reconnection logic and sandboxId injection.
203
- * Use this instead of directly assigning this._lastConnectParams from
204
- * external code. Keeps the shape consistent and avoids stale state
205
- * leaking across concurrent test runs.
206
- * @param {Object|null} params
207
- * @param {string} [params.type] - 'direct' for IP-based connections
208
- * @param {string} [params.ip] - IP address for direct connections
209
- * @param {string} [params.sandboxId] - Sandbox/instance ID
210
- * @param {boolean} [params.persist] - Whether to persist the sandbox
211
- * @param {number|null} [params.keepAlive] - Keep-alive TTL in ms
212
- */
213
- setConnectionParams(params) {
214
- this._lastConnectParams = params ? { ...params } : null;
215
- }
276
+ async _sendHttp(message, timeout) {
277
+ var sessionId = this.sessionInstance
278
+ ? this.sessionInstance.get()
279
+ : null;
280
+ var body = {
281
+ apiKey: this.apiKey,
282
+ version: version,
283
+ os: message.os || this.os,
284
+ session: sessionId,
285
+ };
216
286
 
217
- async connect(sandboxId, persist = false, keepAlive = null) {
218
- let reply = await this.send({
219
- type: "connect",
220
- persist,
221
- sandboxId,
222
- keepAlive,
223
- });
287
+ if (message.type === "create") {
288
+ body.os = message.os || this.os || "linux";
289
+ body.resolution = message.resolution;
290
+ body.ci = message.ci;
291
+ if (message.ami) body.ami = message.ami;
292
+ if (message.instanceType) body.instanceType = message.instanceType;
293
+ if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
294
+ }
224
295
 
225
- if (reply.success) {
226
- // Only store connection params after successful connection
227
- // This prevents malformed sandboxId from being attached to subsequent messages
228
- this.setConnectionParams({ sandboxId, persist, keepAlive });
229
- this.instanceSocketConnected = true;
230
- emitter.emit(events.sandbox.connected);
231
- // Return the full reply (includes url and sandbox)
232
- return reply;
233
- } else {
234
- // Clear any previous connection params on failure
235
- this.setConnectionParams(null);
236
- // Throw error to trigger fallback to creating new sandbox
237
- throw new Error(reply.errorMessage || "Failed to connect to sandbox");
296
+ if (message.type === "direct") {
297
+ body.ip = message.ip;
298
+ body.resolution = message.resolution;
299
+ body.ci = message.ci;
300
+ if (message.instanceId) body.instanceId = message.instanceId;
238
301
  }
239
- }
240
302
 
241
- /**
242
- * Reconnect to a direct IP-based sandbox after connection loss.
243
- * Sends a 'direct' message instead of 'connect' to avoid the API
244
- * treating the IP as an AWS instance ID.
245
- */
246
- async reconnectDirect(ip) {
247
- let reply = await this.send({
248
- type: "direct",
249
- ip,
250
- });
303
+ var reply = await httpPost(
304
+ this.apiRoot,
305
+ "/api/v7/sandbox/authenticate",
306
+ body,
307
+ timeout,
308
+ );
251
309
 
252
- if (reply.success) {
310
+ if (!reply.success) {
311
+ var err = new Error(
312
+ reply.errorMessage || "Failed to allocate sandbox",
313
+ );
314
+ err.responseData = reply;
315
+ throw err;
316
+ }
317
+
318
+ this._sandboxId = reply.sandboxId;
319
+ this._teamId = reply.teamId;
320
+
321
+ if (reply.ably && reply.ably.token) {
322
+ await this._initAbly(reply.ably.token, reply.ably.channels);
253
323
  this.instanceSocketConnected = true;
254
- emitter.emit(events.sandbox.connected);
255
- return reply;
256
- } else {
257
- throw new Error(reply.errorMessage || "Failed to reconnect to direct sandbox");
324
+
325
+ // Tell the runner to enable debug log forwarding if debug mode is on
326
+ var debugMode =
327
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
328
+ if (debugMode && this._ctrlChannel) {
329
+ this._ctrlChannel.publish("control", {
330
+ type: "debug",
331
+ enabled: true,
332
+ });
333
+ }
258
334
  }
259
- }
260
335
 
261
- async handleConnectionLoss() {
262
- if (this.intentionalDisconnect) return;
336
+ if (message.type === "create") {
337
+ // E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
338
+ // No runner agent involved — skip runner.ready wait.
339
+ if (reply.url) {
340
+ logger.log(`E2B sandbox ready — url=${reply.url}`);
341
+ return {
342
+ success: true,
343
+ sandbox: {
344
+ sandboxId: reply.sandboxId,
345
+ instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
346
+ os: body.os || 'linux',
347
+ url: reply.url,
348
+ },
349
+ };
350
+ }
263
351
 
264
- // Prevent duplicate reconnection attempts (both 'error' and 'close' fire)
265
- if (this.reconnecting) return;
266
- this.reconnecting = true;
352
+ const runnerIp = reply.runner && reply.runner.ip;
353
+ const noVncPort = reply.runner && reply.runner.noVncPort;
354
+ const runnerVncUrl = reply.runner && reply.runner.vncUrl;
355
+
356
+ logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
357
+
358
+ // For cloud Windows sandboxes (no runner in reply), wait for the
359
+ // agent to signal readiness before sending commands. Without this
360
+ // gate, commands published before the agent subscribes are lost.
361
+ var self = this;
362
+ if (!reply.runner && this._ctrlChannel) {
363
+ logger.log('Waiting for runner agent to signal readiness...');
364
+ var readyTimeout = 120000; // 120s — allows for EC2 boot + agent startup
365
+ await new Promise(function (resolve, reject) {
366
+ var resolved = false;
367
+ function finish(data) {
368
+ if (resolved) return;
369
+ resolved = true;
370
+ clearTimeout(timer);
371
+ self._ctrlChannel.unsubscribe('control', onCtrl);
372
+ // Update runner info if provided
373
+ if (data && data.os) reply.runner = reply.runner || {};
374
+ if (data && data.os && reply.runner) reply.runner.os = data.os;
375
+ if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
376
+ logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ')');
377
+ resolve();
378
+ }
267
379
 
268
- // Remove listeners from the old socket to prevent "No pending promise found" warnings
269
- // when late responses arrive on the dying connection
270
- if (this.socket) {
271
- try {
272
- this.socket.removeAllListeners("message");
273
- } catch (e) {
274
- // Ignore errors removing listeners from closed socket
380
+ var timer = setTimeout(function () {
381
+ if (!resolved) {
382
+ resolved = true;
383
+ self._ctrlChannel.unsubscribe('control', onCtrl);
384
+ reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
385
+ }
386
+ }, readyTimeout);
387
+ if (timer.unref) timer.unref();
388
+
389
+ // Listen for live runner.ready messages
390
+ var onCtrl;
391
+ onCtrl = function (msg) {
392
+ var data = msg.data;
393
+ if (data && data.type === 'runner.ready') {
394
+ finish(data);
395
+ }
396
+ };
397
+ self._ctrlChannel.subscribe('control', onCtrl);
398
+
399
+ // Also check channel history in case runner.ready was published
400
+ // before we subscribed (race condition on fast-booting agents).
401
+ try {
402
+ self._ctrlChannel.history({ limit: 50 }, function (err, page) {
403
+ if (err) {
404
+ logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
405
+ return;
406
+ }
407
+ if (page && page.items) {
408
+ for (var i = 0; i < page.items.length; i++) {
409
+ var item = page.items[i];
410
+ if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
411
+ logger.log('Found runner.ready in channel history');
412
+ finish(item.data);
413
+ return;
414
+ }
415
+ }
416
+ }
417
+ });
418
+ } catch (histErr) {
419
+ logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
420
+ }
421
+ });
422
+ }
423
+ // Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
424
+ // Fall back to constructing from ip + noVncPort for older runners.
425
+ let url;
426
+ if (runnerVncUrl) {
427
+ url = runnerVncUrl;
428
+ logger.log(`Using runner-provided vncUrl: ${url}`);
429
+ } else if (runnerIp && noVncPort) {
430
+ url = `http://${runnerIp}:${noVncPort}/vnc_lite.html`;
431
+ logger.log(`noVNC URL constructed from runner ip+port: ${url}`);
432
+ } else if (runnerIp) {
433
+ url = "http://" + runnerIp;
434
+ logger.warn(`Runner did not report noVNC port — using bare IP: ${url}`);
435
+ } else {
436
+ logger.warn('Runner has no IP — preview will not be available');
275
437
  }
438
+ return {
439
+ success: true,
440
+ sandbox: {
441
+ sandboxId: reply.sandboxId,
442
+ instanceId: reply.sandboxId,
443
+ os: reply.runner?.os || body.os,
444
+ ip: runnerIp,
445
+ url: url,
446
+ vncPort: noVncPort || undefined,
447
+ runner: reply.runner,
448
+ },
449
+ };
276
450
  }
277
451
 
278
- // Queue pending requests for retry after reconnection
279
- // (they were sent on the old socket and will never receive responses)
280
- const pendingRequestIds = Object.keys(this.ps);
281
- if (pendingRequestIds.length > 0) {
282
- console.log(`[Sandbox] Queuing ${pendingRequestIds.length} pending request(s) for retry after reconnection`);
283
- for (const requestId of pendingRequestIds) {
284
- const pending = this.ps[requestId];
285
- if (pending) {
286
- // Clear the timeout - we'll set a new one when we retry
287
- const timeoutId = this.pendingTimeouts.get(requestId);
288
- if (timeoutId) {
289
- clearTimeout(timeoutId);
290
- this.pendingTimeouts.delete(requestId);
452
+ if (message.type === "direct") {
453
+ // If the API provisioned Ably credentials to the instance (reply.agent present),
454
+ // wait for the runner agent to signal readiness before sending commands.
455
+ // Without this gate, commands published before the agent subscribes are lost.
456
+ var self = this;
457
+ if (reply.agent && this._ctrlChannel) {
458
+ logger.log('Waiting for runner agent to signal readiness (direct connection)...');
459
+ var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
460
+ await new Promise(function (resolve, reject) {
461
+ var resolved = false;
462
+ function finish(data) {
463
+ if (resolved) return;
464
+ resolved = true;
465
+ clearTimeout(timer);
466
+ self._ctrlChannel.unsubscribe('control', onCtrl);
467
+ logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ')');
468
+ resolve();
291
469
  }
292
- // Queue for retry (store message and promise handlers)
293
- this.pendingRetryQueue.push({
294
- message: pending.message,
295
- resolve: pending.resolve,
296
- reject: pending.reject,
297
- });
298
- }
470
+
471
+ var timer = setTimeout(function () {
472
+ if (!resolved) {
473
+ resolved = true;
474
+ self._ctrlChannel.unsubscribe('control', onCtrl);
475
+ reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
476
+ }
477
+ }, readyTimeout);
478
+ if (timer.unref) timer.unref();
479
+
480
+ // Listen for live runner.ready messages
481
+ var onCtrl;
482
+ onCtrl = function (msg) {
483
+ var data = msg.data;
484
+ if (data && data.type === 'runner.ready') {
485
+ finish(data);
486
+ }
487
+ };
488
+ self._ctrlChannel.subscribe('control', onCtrl);
489
+
490
+ // Also check channel history in case runner.ready was published
491
+ // before we subscribed (race condition on fast-booting agents).
492
+ try {
493
+ self._ctrlChannel.history({ limit: 50 }, function (err, page) {
494
+ if (err) {
495
+ logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
496
+ return;
497
+ }
498
+ if (page && page.items) {
499
+ for (var i = 0; i < page.items.length; i++) {
500
+ var item = page.items[i];
501
+ if (item.name === 'control' && item.data && item.data.type === 'runner.ready') {
502
+ logger.log('Found runner.ready in channel history (direct)');
503
+ finish(item.data);
504
+ return;
505
+ }
506
+ }
507
+ }
508
+ });
509
+ } catch (histErr) {
510
+ logger.warn('History call threw (non-fatal): ' + (histErr.message || histErr));
511
+ }
512
+ });
299
513
  }
300
- this.ps = {};
514
+
515
+ // Construct VNC URL — use port 8080 (nginx noVNC proxy) for Windows instances
516
+ var directUrl = message.ip ? "http://" + message.ip + ":8080/vnc_lite.html" : undefined;
517
+
518
+ return {
519
+ success: true,
520
+ instance: {
521
+ instanceId: reply.sandboxId,
522
+ sandboxId: reply.sandboxId,
523
+ ip: message.ip,
524
+ url: directUrl || "http://" + message.ip,
525
+ },
526
+ };
301
527
  }
302
528
 
303
- // Cancel any existing reconnect timer
304
- if (this.reconnectTimer) {
305
- clearTimeout(this.reconnectTimer);
306
- this.reconnectTimer = null;
529
+ return reply;
530
+ }
531
+
532
+ _sendAbly(message, timeout) {
533
+ if (timeout === undefined) timeout = 300000;
534
+
535
+ if (!this._cmdChannel || !this._ably) {
536
+ return Promise.reject(
537
+ new Error("Sandbox not connected (no Ably client)"),
538
+ );
307
539
  }
308
540
 
309
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
310
- const errorMsg =
311
- "Unable to reconnect to TestDriver sandbox after multiple attempts. Please check your internet connection.";
312
- emitter.emit(events.error.sandbox, errorMsg);
313
- console.error(errorMsg);
314
-
315
- // Reject all queued requests since reconnection failed
316
- if (this.pendingRetryQueue.length > 0) {
317
- console.log(`[Sandbox] Rejecting ${this.pendingRetryQueue.length} queued request(s) - reconnection failed`);
318
- for (const queued of this.pendingRetryQueue) {
319
- queued.reject(new Error("Sandbox reconnection failed after multiple attempts"));
320
- }
321
- this.pendingRetryQueue = [];
541
+ // If temporarily disconnected, wait up to 30s for reconnection
542
+ // instead of failing immediately (dashcam uploads can cause brief blips)
543
+ var self = this;
544
+ var connState = this._ably.connection.state;
545
+ if (connState !== "connected") {
546
+ if (connState === "disconnected" || connState === "connecting" || connState === "suspended") {
547
+ logger.log("Ably is " + connState + ", waiting for reconnect before sending...");
548
+ var waitForConnect = new Promise(function (resolve, reject) {
549
+ var timer = setTimeout(function () {
550
+ self._ably.connection.off("connected", onConnected);
551
+ self._ably.connection.off("failed", onFailed);
552
+ reject(new Error("Sandbox not connected after waiting 30s (state: " + self._ably.connection.state + ")"));
553
+ }, 30000);
554
+ if (timer.unref) timer.unref();
555
+ function onConnected() {
556
+ clearTimeout(timer);
557
+ self._ably.connection.off("failed", onFailed);
558
+ resolve();
559
+ }
560
+ function onFailed() {
561
+ clearTimeout(timer);
562
+ self._ably.connection.off("connected", onConnected);
563
+ reject(new Error("Ably connection failed while waiting to send"));
564
+ }
565
+ self._ably.connection.once("connected", onConnected);
566
+ self._ably.connection.once("failed", onFailed);
567
+ });
568
+ return waitForConnect.then(function () {
569
+ return self._sendAbly(message, timeout);
570
+ });
322
571
  }
323
-
324
- this.reconnecting = false;
325
- return;
572
+ return Promise.reject(
573
+ new Error("Sandbox not connected (state: " + connState + ")"),
574
+ );
326
575
  }
327
576
 
328
- this.reconnectAttempts++;
329
- const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 60000);
577
+ this.messageId++;
578
+ message.requestId = this.uniqueId + "-" + this.messageId;
330
579
 
331
- console.log(
332
- `[Sandbox] Connection lost. Reconnecting in ${delay}ms... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
333
- );
580
+ if (message.os) this.os = message.os;
581
+ if (this.os && !message.os) message.os = this.os;
334
582
 
335
- this.reconnectTimer = setTimeout(async () => {
336
- this.reconnectTimer = null;
337
- try {
338
- await this.boot(this.apiRoot);
339
- if (this.apiKey) {
340
- await this.auth(this.apiKey);
341
- }
342
- // Re-establish sandbox connection on the new API instance
343
- // Without this, the new API instance has no connection.desktop
344
- // and all Linux operations will fail with "sandbox not initialized"
345
- if (this._lastConnectParams) {
346
- if (this._lastConnectParams.type === 'direct') {
347
- // Direct IP connections must reconnect via 'direct' message, not 'connect'
348
- const { ip, persist, keepAlive } = this._lastConnectParams;
349
- console.log(`[Sandbox] Re-establishing direct connection (${ip})...`);
350
- await this.reconnectDirect(ip);
351
- } else {
352
- const { sandboxId, persist, keepAlive } = this._lastConnectParams;
353
- console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
354
- await this.connect(sandboxId, persist, keepAlive);
355
- }
356
- }
357
- console.log("[Sandbox] Reconnected successfully.");
358
-
359
- // Retry queued requests
360
- await this._retryQueuedRequests();
361
- } catch (e) {
362
- // boot's close handler will trigger handleConnectionLoss again
363
- } finally {
364
- this.reconnecting = false;
583
+ if (this.sessionInstance && !message.session) {
584
+ var sessionId = this.sessionInstance.get();
585
+ if (sessionId) message.session = sessionId;
586
+ }
587
+
588
+ if (
589
+ this._lastConnectParams &&
590
+ this._lastConnectParams.sandboxId &&
591
+ !message.sandboxId
592
+ ) {
593
+ var id = this._lastConnectParams.sandboxId;
594
+ if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
595
+ message.sandboxId = id;
365
596
  }
366
- }, delay);
367
- // Don't let reconnect timer prevent Node process from exiting
368
- if (this.reconnectTimer.unref) {
369
- this.reconnectTimer.unref();
370
597
  }
371
- }
372
598
 
373
- /**
374
- * Retry queued requests after successful reconnection
375
- * @private
376
- */
377
- async _retryQueuedRequests() {
378
- if (this.pendingRetryQueue.length === 0) return;
379
-
380
- console.log(`[Sandbox] Retrying ${this.pendingRetryQueue.length} queued request(s)...`);
381
-
382
- // Take all queued requests and clear the queue
383
- const toRetry = this.pendingRetryQueue.splice(0);
384
-
385
- for (const queued of toRetry) {
386
- try {
387
- // Re-send the message and resolve/reject the original promise
388
- const result = await this.send(queued.message);
389
- queued.resolve(result);
390
- } catch (err) {
391
- queued.reject(err);
599
+ // Attach Sentry distributed trace headers for runner-side tracing
600
+ var traceSessionId = this.sessionInstance
601
+ ? this.sessionInstance.get()
602
+ : message.session;
603
+ if (traceSessionId) {
604
+ var traceHeaders = getSentryTraceHeaders(traceSessionId);
605
+ if (traceHeaders["sentry-trace"]) {
606
+ message.sentryTrace = traceHeaders["sentry-trace"];
607
+ message.baggage = traceHeaders.baggage;
392
608
  }
393
609
  }
394
-
395
- console.log(`[Sandbox] Finished retrying queued requests.`);
396
- }
397
610
 
398
- async boot(apiRoot) {
399
- if (apiRoot) this.apiRoot = apiRoot;
400
- return new Promise((resolve, reject) => {
401
- // Get session ID for Sentry trace headers
402
- const sessionId = this.sessionInstance?.get();
611
+ var resolvePromise, rejectPromise;
612
+ var self = this;
613
+
614
+ var p = new Promise(function (resolve, reject) {
615
+ resolvePromise = resolve;
616
+ rejectPromise = reject;
617
+ });
403
618
 
404
- if (!sessionId) {
405
- console.warn(
406
- "[Sandbox] No session ID available at boot time - Sentry tracing will not be available",
619
+ var requestId = message.requestId;
620
+
621
+ var timeoutId = setTimeout(function () {
622
+ if (self.ps[requestId]) {
623
+ delete self.ps[requestId];
624
+ rejectPromise(
625
+ new Error(
626
+ "Sandbox message '" +
627
+ message.type +
628
+ "' timed out after " +
629
+ timeout +
630
+ "ms",
631
+ ),
407
632
  );
408
633
  }
634
+ }, timeout);
635
+ if (timeoutId.unref) timeoutId.unref();
636
+
637
+ this.ps[requestId] = {
638
+ promise: p,
639
+ resolve: function (result) {
640
+ clearTimeout(timeoutId);
641
+ resolvePromise(result);
642
+ },
643
+ reject: function (error) {
644
+ clearTimeout(timeoutId);
645
+ rejectPromise(error);
646
+ },
647
+ message: message,
648
+ startTime: Date.now(),
649
+ };
409
650
 
410
- const sentryHeaders = getSentryTraceHeaders(sessionId);
411
-
412
- // Build WebSocket URL with Sentry trace headers as query params
413
- const wsUrl = new URL(apiRoot.replace("https://", "wss://"));
414
- if (sentryHeaders["sentry-trace"]) {
415
- wsUrl.searchParams.set("sentry-trace", sentryHeaders["sentry-trace"]);
416
- }
417
- if (sentryHeaders["baggage"]) {
418
- wsUrl.searchParams.set("baggage", sentryHeaders["baggage"]);
419
- }
651
+ if (message.type === "output") {
652
+ p.catch(function () {});
653
+ }
420
654
 
421
- this.socket = new WebSocket(wsUrl.toString());
422
-
423
- // handle errors
424
- this.socket.on("close", () => {
425
- clearInterval(this.heartbeat);
426
- this.apiSocketConnected = false;
427
- // Also mark instance socket as disconnected to prevent sending messages
428
- // to a closed connection (e.g., when sandbox is killed due to test failure)
429
- this.instanceSocketConnected = false;
430
- // Reset reconnecting flag so handleConnectionLoss can run for this new disconnection
431
- this.reconnecting = false;
432
- this.handleConnectionLoss();
433
- reject();
655
+ this._cmdChannel
656
+ .publish("command", message)
657
+ .then(function () {
658
+ emitter.emit(events.sandbox.sent, message);
659
+ })
660
+ .catch(function (err) {
661
+ if (self.ps[requestId]) {
662
+ clearTimeout(timeoutId);
663
+ delete self.ps[requestId];
664
+ rejectPromise(
665
+ new Error("Failed to send message: " + err.message),
666
+ );
667
+ }
434
668
  });
435
669
 
436
- this.socket.on("error", (err) => {
437
- logger.log("Socket Error");
438
- err && logger.log(err);
439
- clearInterval(this.heartbeat);
440
- emitter.emit(events.error.sandbox, err);
441
- this.apiSocketConnected = false;
442
- // Don't call handleConnectionLoss here - the 'close' event always fires
443
- // after 'error', so let 'close' handle reconnection to avoid duplicate attempts
444
- reject(err);
445
- });
670
+ return p;
671
+ }
446
672
 
447
- this.socket.on("open", async () => {
448
- this.reconnectAttempts = 0;
449
- this.reconnecting = false;
450
- this.apiSocketConnected = true;
673
+ async auth(apiKey) {
674
+ this.apiKey = apiKey;
675
+ var sessionId = this.sessionInstance
676
+ ? this.sessionInstance.get()
677
+ : null;
678
+
679
+ var reply = await httpPost(
680
+ this.apiRoot,
681
+ "/api/v7/sandbox/authenticate",
682
+ {
683
+ apiKey: apiKey,
684
+ version: version,
685
+ session: sessionId,
686
+ },
687
+ );
451
688
 
452
- this.heartbeat = setInterval(() => {
453
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
454
- this.socket.ping();
455
- }
456
- }, 5000);
457
- // Don't let the heartbeat interval prevent Node process from exiting
458
- if (this.heartbeat.unref) {
459
- this.heartbeat.unref();
460
- }
689
+ if (reply.success) {
690
+ this.authenticated = true;
691
+ this.apiSocketConnected = true;
692
+ this._teamId = reply.teamId;
461
693
 
462
- resolve(this);
694
+ if (reply.traceId) {
695
+ this.traceId = reply.traceId;
696
+ logger.log("");
697
+ logger.log("Trace Report (Share When Reporting Bugs):");
698
+ logger.log(
699
+ "https://testdriver.sentry.io/explore/traces/trace/" +
700
+ reply.traceId,
701
+ );
702
+ }
703
+
704
+ emitter.emit(events.sandbox.authenticated, {
705
+ traceId: reply.traceId,
463
706
  });
707
+ return true;
708
+ }
464
709
 
465
- this.socket.on("message", async (raw) => {
466
- let message = JSON.parse(raw);
710
+ return false;
711
+ }
467
712
 
468
- // Handle progress messages (no requestId needed)
469
- if (message.type === "sandbox.progress") {
470
- emitter.emit(events.sandbox.progress, {
471
- step: message.step,
472
- message: message.message,
473
- });
474
- return;
475
- }
713
+ setConnectionParams(params) {
714
+ this._lastConnectParams = params ? Object.assign({}, params) : null;
715
+ }
476
716
 
477
- if (!this.ps[message.requestId]) {
478
- // This can happen during reconnection (ps was cleared) or after timeout
479
- // (promise was deleted). Expected during polling loops (e.g. Chrome
480
- // debugger readiness checks) where short-timeout exec calls regularly
481
- // expire before the sandbox responds. Only log in debug/verbose mode.
482
- if (!this.reconnecting) {
483
- const debugMode = process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
484
- if (debugMode) {
485
- console.warn(
486
- "No pending promise found for requestId:",
487
- message.requestId,
488
- );
489
- }
490
- }
491
- return;
492
- }
717
+ async connect(sandboxId, persist, keepAlive) {
718
+ if (persist === undefined) persist = false;
719
+ if (keepAlive === undefined) keepAlive = null;
720
+ var sessionId = this.sessionInstance
721
+ ? this.sessionInstance.get()
722
+ : null;
723
+
724
+ var reply = await httpPost(
725
+ this.apiRoot,
726
+ "/api/v7/sandbox/authenticate",
727
+ {
728
+ apiKey: this.apiKey,
729
+ version: version,
730
+ sandboxId: sandboxId,
731
+ session: sessionId,
732
+ keepAlive: keepAlive || undefined,
733
+ },
734
+ );
493
735
 
494
- if (message.error) {
495
- // Don't emit error:sandbox for output (log forwarding) messages
496
- // to prevent infinite loops: error log → sendToSandbox → error → ...
497
- const pendingMessage = this.ps[message.requestId]?.message;
498
- if (pendingMessage?.type !== "output") {
499
- emitter.emit(events.error.sandbox, message.errorMessage);
500
- }
501
- const error = new Error(message.errorMessage || "Sandbox error");
502
- error.responseData = message;
503
- this.ps[message.requestId].reject(error);
504
- } else {
505
- emitter.emit(events.sandbox.received);
506
- this.ps[message.requestId]?.resolve(message);
507
- }
508
- delete this.ps[message.requestId];
509
- });
736
+ if (!reply.success) {
737
+ this.setConnectionParams(null);
738
+ throw new Error(reply.errorMessage || "Failed to connect to sandbox");
739
+ }
740
+
741
+ this._sandboxId = reply.sandboxId;
742
+
743
+ if (reply.ably && reply.ably.token) {
744
+ await this._initAbly(reply.ably.token, reply.ably.channels);
745
+ }
746
+
747
+ this.setConnectionParams({
748
+ sandboxId: sandboxId,
749
+ persist: persist,
750
+ keepAlive: keepAlive,
510
751
  });
752
+ this.instanceSocketConnected = true;
753
+ emitter.emit(events.sandbox.connected);
754
+
755
+ // Prefer runner-provided vncUrl, fall back to ip+port, then bare IP
756
+ const reconnectRunner = reply.runner || {};
757
+ const reconnectVncUrl = reconnectRunner.vncUrl;
758
+ const reconnectNoVncPort = reconnectRunner.noVncPort;
759
+ const reconnectIp = reconnectRunner.ip;
760
+ let reconnectUrl;
761
+ if (reconnectVncUrl) {
762
+ reconnectUrl = reconnectVncUrl;
763
+ } else if (reconnectIp && reconnectNoVncPort) {
764
+ reconnectUrl = `http://${reconnectIp}:${reconnectNoVncPort}/vnc_lite.html`;
765
+ } else if (reconnectIp) {
766
+ reconnectUrl = "http://" + reconnectIp;
767
+ }
768
+
769
+ return {
770
+ success: true,
771
+ url: reconnectUrl,
772
+ sandbox: {
773
+ sandboxId: reply.sandboxId,
774
+ instanceId: reply.sandboxId,
775
+ os: reconnectRunner.os || undefined,
776
+ ip: reconnectIp || undefined,
777
+ url: reconnectUrl,
778
+ vncPort: reconnectNoVncPort || undefined,
779
+ },
780
+ };
511
781
  }
512
782
 
513
- /**
514
- * Close the WebSocket connection and clean up resources
515
- */
516
- close() {
517
- this.intentionalDisconnect = true;
518
- this.reconnecting = false;
519
- // Cancel any pending reconnect timer
520
- if (this.reconnectTimer) {
521
- clearTimeout(this.reconnectTimer);
522
- this.reconnectTimer = null;
523
- }
783
+ async boot(apiRoot) {
784
+ if (apiRoot) this.apiRoot = apiRoot;
785
+ return this;
786
+ }
524
787
 
788
+ async close() {
525
789
  if (this.heartbeat) {
526
790
  clearInterval(this.heartbeat);
527
791
  this.heartbeat = null;
528
792
  }
529
793
 
530
- // Clear all pending message timeouts to prevent timers keeping the process alive
531
- for (const timeoutId of this.pendingTimeouts.values()) {
532
- clearTimeout(timeoutId);
794
+ // Send end-session control message to runner before disconnecting
795
+ if (this._ctrlChannel && this._ably?.connection?.state === 'connected') {
796
+ try {
797
+ await this._ctrlChannel.publish('control', { type: 'end-session' });
798
+ } catch (e) {
799
+ // Ignore - best effort
800
+ }
801
+ }
802
+
803
+ try {
804
+ if (this._cmdChannel) this._cmdChannel.detach().catch(() => {});
805
+ if (this._respChannel) this._respChannel.detach().catch(() => {});
806
+ if (this._ctrlChannel) this._ctrlChannel.detach().catch(() => {});
807
+ if (this._filesChannel) this._filesChannel.detach().catch(() => {});
808
+ } catch (e) {
809
+ /* ignore */
533
810
  }
534
- this.pendingTimeouts.clear();
535
811
 
536
- if (this.socket) {
537
- // Remove all listeners before closing to prevent reconnect attempts
538
- this.socket.removeAllListeners();
812
+ if (this._ably) {
539
813
  try {
540
- this.socket.close();
541
- } catch (err) {
542
- // Ignore close errors
814
+ this._ably.close();
815
+ } catch (e) {
816
+ /* ignore */
543
817
  }
544
- this.socket = null;
818
+ this._ably = null;
545
819
  }
546
820
 
821
+ this._cmdChannel = null;
822
+ this._respChannel = null;
823
+ this._ctrlChannel = null;
824
+ this._filesChannel = null;
825
+ this._channelNames = null;
547
826
  this.apiSocketConnected = false;
548
827
  this.instanceSocketConnected = false;
549
828
  this.authenticated = false;
550
829
  this.instance = null;
551
830
  this._lastConnectParams = null;
552
-
553
- // Silently clear pending promises and retry queue without rejecting
554
- // (rejecting causes unhandled promise rejections during cleanup)
555
831
  this.ps = {};
556
- this.pendingRetryQueue = [];
557
832
  }
558
833
  }
559
834