@tapflowio/relay 0.16.0 → 0.18.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 (79) hide show
  1. package/dist/RelayServer.d.ts +50 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +406 -81
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +45 -7
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +95 -8
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/types.d.ts +6 -10
  10. package/dist/types.d.ts.map +1 -1
  11. package/package.json +5 -3
  12. package/public/assets/{AppCenter-DHKav0MA.js → AppCenter-B3FO67aw.js} +2 -2
  13. package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
  14. package/public/assets/{Default-FOVo8D6L.js → Default-BlNUv5dw.js} +1 -1
  15. package/public/assets/Default-BlNUv5dw.js.br +0 -0
  16. package/public/assets/{Invite-DdjwJ4DV.js → Invite-CKYFmy7S.js} +1 -1
  17. package/public/assets/Invite-CKYFmy7S.js.br +0 -0
  18. package/public/assets/{MacResources-Dl1w6E1h.js → MacResources-BK4VQnFc.js} +1 -1
  19. package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
  20. package/public/assets/{NotFound-BMUavI-G.js → NotFound-BMmi1paB.js} +1 -1
  21. package/public/assets/NotFound-BMmi1paB.js.br +0 -0
  22. package/public/assets/QASession-CVTlqW3d.js +125 -0
  23. package/public/assets/QASession-CVTlqW3d.js.br +0 -0
  24. package/public/assets/{ResetPassword-Yn2hnB_M.js → ResetPassword-VPPBWzSI.js} +1 -1
  25. package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
  26. package/public/assets/{Setup-BaGV0xx6.js → Setup-CRdnCl41.js} +1 -1
  27. package/public/assets/Setup-CRdnCl41.js.br +0 -0
  28. package/public/assets/{Team-BOwoos5_.js → Team-CBsjuUwh.js} +2 -2
  29. package/public/assets/Team-CBsjuUwh.js.br +0 -0
  30. package/public/assets/{Tokens-Bu1FtE_n.js → Tokens-CU9HB9WD.js} +1 -1
  31. package/public/assets/Tokens-CU9HB9WD.js.br +0 -0
  32. package/public/assets/alert-dialog-DWqGDQTB.js +1 -0
  33. package/public/assets/alert-dialog-DWqGDQTB.js.br +0 -0
  34. package/public/assets/{badge-CNtq_oBK.js → badge-OToVq1dK.js} +1 -1
  35. package/public/assets/badge-OToVq1dK.js.br +0 -0
  36. package/public/assets/{build-format-D6egYX-U.js → build-format-B78hbKY9.js} +1 -1
  37. package/public/assets/build-format-B78hbKY9.js.br +3 -0
  38. package/public/assets/dialog-BxC9v2BL.js +11 -0
  39. package/public/assets/dialog-BxC9v2BL.js.br +0 -0
  40. package/public/assets/index-DXjMx9hQ.js +274 -0
  41. package/public/assets/index-DXjMx9hQ.js.br +0 -0
  42. package/public/assets/index-uGJGo6ac.css +1 -0
  43. package/public/assets/index-uGJGo6ac.css.br +0 -0
  44. package/public/assets/{pencil-CzKUUBmE.js → pencil-Cq64I9LV.js} +1 -1
  45. package/public/assets/pencil-Cq64I9LV.js.br +0 -0
  46. package/public/assets/{table-CU0HMyju.js → table-CbATN5Pi.js} +1 -1
  47. package/public/assets/table-CbATN5Pi.js.br +0 -0
  48. package/public/assets/tabs-BVumgDA4.js +1 -0
  49. package/public/assets/tabs-BVumgDA4.js.br +0 -0
  50. package/public/assets/{trash-2-Bw_edyHa.js → trash-2-CszwJHEh.js} +1 -1
  51. package/public/assets/trash-2-CszwJHEh.js.br +0 -0
  52. package/public/index.html +2 -2
  53. package/public/index.html.br +0 -0
  54. package/public/assets/AppCenter-DHKav0MA.js.br +0 -0
  55. package/public/assets/Default-FOVo8D6L.js.br +0 -0
  56. package/public/assets/Invite-DdjwJ4DV.js.br +0 -0
  57. package/public/assets/MacResources-Dl1w6E1h.js.br +0 -0
  58. package/public/assets/NotFound-BMUavI-G.js.br +0 -0
  59. package/public/assets/QASession-BzfWUo9F.js +0 -130
  60. package/public/assets/QASession-BzfWUo9F.js.br +0 -0
  61. package/public/assets/ResetPassword-Yn2hnB_M.js.br +0 -0
  62. package/public/assets/Setup-BaGV0xx6.js.br +0 -0
  63. package/public/assets/Team-BOwoos5_.js.br +0 -0
  64. package/public/assets/Tokens-Bu1FtE_n.js.br +0 -0
  65. package/public/assets/alert-dialog-dsCcQ1NM.js +0 -1
  66. package/public/assets/alert-dialog-dsCcQ1NM.js.br +0 -0
  67. package/public/assets/badge-CNtq_oBK.js.br +0 -0
  68. package/public/assets/build-format-D6egYX-U.js.br +0 -0
  69. package/public/assets/dialog-Blqnc3tq.js +0 -11
  70. package/public/assets/dialog-Blqnc3tq.js.br +0 -0
  71. package/public/assets/index-BI7E3MD-.css +0 -1
  72. package/public/assets/index-BI7E3MD-.css.br +0 -0
  73. package/public/assets/index-IvDXNTVd.js +0 -274
  74. package/public/assets/index-IvDXNTVd.js.br +0 -0
  75. package/public/assets/pencil-CzKUUBmE.js.br +0 -0
  76. package/public/assets/table-CU0HMyju.js.br +0 -0
  77. package/public/assets/tabs-C9nZOMC5.js +0 -1
  78. package/public/assets/tabs-C9nZOMC5.js.br +0 -0
  79. package/public/assets/trash-2-Bw_edyHa.js.br +0 -0
@@ -41,6 +41,11 @@ export function isExternalAddress(addr) {
41
41
  const IDR_REQUEST_THROTTLE_MS = 500;
42
42
  // Ping every socket each interval; a missed pong window (~2× this) terminates the dead socket.
43
43
  const HEARTBEAT_MS = 30_000;
44
+ // How long a session outlives its agent's socket, waiting for that agent to come back (#426).
45
+ // An agent registers ~1s after its process starts, so this covers an automatic respawn several
46
+ // times over and about half of a hand-typed restart. What bounds it is the other direction: the
47
+ // device stays claimed by a session nobody can use until the window closes.
48
+ const DEFAULT_AGENT_GRACE_MS = 15_000;
44
49
  import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
45
50
  import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
46
51
  import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
@@ -70,6 +75,10 @@ const _parsedThreshold = parseInt(process.env['TAPFLOW_RESOURCE_THRESHOLD_PERCEN
70
75
  const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold : 80;
71
76
  // Messages that only agents are allowed to send. Authenticated browser sockets
72
77
  // that send any of these are disconnected immediately.
78
+ // Terminal input messages the MCP client awaits an ack for — if the agent is offline
79
+ // the relay replies input:error so the client fails truthfully (non-terminal moves/starts
80
+ // expect no ack and are dropped silently).
81
+ const TERMINAL_INPUT_TYPES = new Set(['input:touch:end', 'input:pinch:end', 'input:key', 'input:button']);
73
82
  const AGENT_MSG_TYPES = new Set([
74
83
  'agent:register', 'agent:resources', 'screenshot:done', 'screenshot:error',
75
84
  'ui:tree:response', 'ui:tree:error',
@@ -78,7 +87,10 @@ const AGENT_MSG_TYPES = new Set([
78
87
  'session:chrome', 'session:deviceInfo',
79
88
  'app:install-done', 'app:install-error', 'app:launch-done', 'app:launch-error',
80
89
  'open-url:done', 'open-url:error', 'keyboard:toggled',
81
- 'input:type-done', 'input:type-error',
90
+ 'input:type-done', 'input:type-error', 'input:done', 'input:error',
91
+ // clipboard:data carries the simulator's clipboard — agent-authenticated, so a
92
+ // browser socket can never inject it into another viewer.
93
+ 'clipboard:data', 'clipboard:write-done', 'clipboard:error',
82
94
  // stream:register binds a session's stream socket — agent-only, or a browser
83
95
  // (view PAT / cookie) could hijack an existing session's video feed.
84
96
  'stream:register',
@@ -109,11 +121,22 @@ export class RelayServer {
109
121
  // Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
110
122
  idrRequesters = new Map();
111
123
  wsRoles = new Map();
124
+ // Agent sockets whose sessions are being held open, and the timer that gives up on each.
125
+ // Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
126
+ // expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
127
+ // on the way back is hygiene on top of it, not a second thing holding the property up. The late
128
+ // expiry is not a complete no-op either: it still drops the socket's resource entry.
129
+ agentHolds = new Map();
130
+ // Set by `stop()`. Its own `terminate()` loop fires a close for every socket, and a hold armed
131
+ // from there would outlive the server it belongs to — the exact hazard the clearing above it is
132
+ // for, arriving a few lines later.
133
+ stopping = false;
112
134
  // True when the connection's remote IP is public (not loopback / private LAN) — the agent uses
113
135
  // this to downscale harder for bandwidth on external viewers.
114
136
  wsExternal = new Map();
115
137
  backpressureBytes;
116
138
  screenshotTimeoutMs;
139
+ agentGraceMs;
117
140
  corsAllowed;
118
141
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
119
142
  warnedProxyMisconfig = false;
@@ -128,6 +151,24 @@ export class RelayServer {
128
151
  // itself may take up to 10s before it errors out.
129
152
  this.uiTreeTimeoutMs = options.uiTreeTimeoutMs ?? 15_000;
130
153
  this.corsAllowed = new Set(options.corsOrigins ?? []);
154
+ // Constructor option first, then env. It cannot be env-only: `IDLE_TIMEOUT_MS` is read at module
155
+ // load, which is why no test can set it, and the tests that need a different window here run in
156
+ // the same process as the default.
157
+ // Validated, not just parsed, and blank treated as unset rather than as a number. Three ways
158
+ // this silently switches the feature off if read naively: `parseInt('15s')` is 15 — and the
159
+ // documented default reads "15000 (15 s)", so that is the typo the docs invite; a non-numeric
160
+ // value is NaN, which `setTimeout` rounds to ~1ms; and `Number('')` is 0, not NaN, so an empty
161
+ // `.env` line would pass a `>= 0` check and give a zero-length window.
162
+ const graceRaw = process.env['TAPFLOW_AGENT_GRACE_MS']?.trim();
163
+ const graceEnv = graceRaw ? Number(graceRaw) : NaN;
164
+ const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
165
+ this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
166
+ // Say so rather than only documenting it. Both times this parsing was wrong the symptom was
167
+ // the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
168
+ // reading their terminal, not the configuration table.
169
+ if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
170
+ logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
171
+ }
131
172
  this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
132
173
  this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
133
174
  this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
@@ -281,6 +322,12 @@ export class RelayServer {
281
322
  clearInterval(this.heartbeatTimer);
282
323
  this.heartbeatTimer = null;
283
324
  }
325
+ // `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
326
+ // still fires — against a server that has already stopped.
327
+ this.stopping = true;
328
+ for (const timer of this.agentHolds.values())
329
+ clearTimeout(timer);
330
+ this.agentHolds.clear();
284
331
  return new Promise((resolve, reject) => {
285
332
  this.wss.clients.forEach((ws) => ws.terminate());
286
333
  this.wss.close(() => {
@@ -319,7 +366,7 @@ export class RelayServer {
319
366
  lastAt = now;
320
367
  const session = this.sessions.get(sessionId);
321
368
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
322
- session.agentSocket.send(JSON.stringify({ type: 'stream:request-idr', sessionId }));
369
+ this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
323
370
  }
324
371
  };
325
372
  }
@@ -416,19 +463,35 @@ export class RelayServer {
416
463
  }
417
464
  return;
418
465
  }
466
+ let parsed;
419
467
  try {
420
- const msg = JSON.parse(data.toString());
421
- this.route(ws, msg);
468
+ parsed = JSON.parse(data.toString());
422
469
  }
423
470
  catch {
424
- // ignore malformed messages
471
+ return; // genuinely malformed — there is no type to answer on
472
+ }
473
+ // `JSON.parse` returns bare `null`, numbers and strings without throwing, and `route` reads
474
+ // `.type` off whatever it is handed. Rejecting non-objects here is what lets the catch below
475
+ // name the message safely.
476
+ if (typeof parsed !== 'object' || parsed === null)
477
+ return;
478
+ const msg = parsed;
479
+ try {
480
+ this.route(ws, msg);
481
+ }
482
+ catch (e) {
483
+ // A throw inside a handler used to land in the same catch as a parse failure and vanish.
484
+ // Anything reaching here is a bug in routing, not a bad message, and the caller is left
485
+ // waiting either way — so at least say so once instead of dropping it silently.
486
+ logger.error(`route failed for ${String(msg.type)}:`, e);
425
487
  }
426
488
  });
427
489
  ws.on('close', () => {
428
490
  this.wsRoles.delete(ws);
429
491
  this.wsExternal.delete(ws);
430
- // Agent main socket disconnected → remove its sessions, reject in-flight screenshots, drop resources
431
- if (this.evictAgentSocket(ws))
492
+ // Agent main socket disconnected → hold its sessions open for a moment in case the agent is
493
+ // coming back (#426), rather than ending them where they stand.
494
+ if (this.holdAgentSocket(ws))
432
495
  return;
433
496
  // Stream socket disconnected → clear the streamSocket reference
434
497
  const streamSession = this.sessions.getByStreamSocket(ws);
@@ -442,11 +505,11 @@ export class RelayServer {
442
505
  this.sessions.clearBrowser(browserSession.id, () => {
443
506
  const session = this.sessions.get(browserSession.id);
444
507
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
445
- session.agentSocket.send(JSON.stringify({
508
+ this.sendTo(session.agentSocket, {
446
509
  type: 'device:shutdown',
447
510
  sessionId: session.id,
448
511
  payload: { deviceId: session.deviceId },
449
- }));
512
+ });
450
513
  }
451
514
  });
452
515
  }
@@ -493,7 +556,7 @@ export class RelayServer {
493
556
  this.handleUITreeError(msg);
494
557
  break;
495
558
  case 'agents:list': {
496
- ws.send(JSON.stringify({ type: 'agents:listed', sessions: this.sessions.list() }));
559
+ this.sendTo(ws, { type: 'agents:listed', sessions: this.sessions.list() });
497
560
  break;
498
561
  }
499
562
  // ── Session / Stream lifecycle ─────────────────────────────────────────
@@ -524,7 +587,7 @@ export class RelayServer {
524
587
  const session = this.sessions.get(msg.sessionId);
525
588
  if (session) {
526
589
  this.sessions.setStreamSocket(session.id, ws);
527
- ws.send(JSON.stringify({ type: 'stream:registered' }));
590
+ this.sendTo(ws, { type: 'stream:registered' });
528
591
  }
529
592
  break;
530
593
  }
@@ -533,6 +596,10 @@ export class RelayServer {
533
596
  const session = this.sessions.get(msg.sessionId);
534
597
  if (!session)
535
598
  break;
599
+ // Cast, not a check: nothing validates inbound messages, so the relay takes the agent's
600
+ // word for the shape. It only stores and forwards this, so a wrong shape surfaces in the
601
+ // viewer rather than here — but this is where a runtime validator belongs when one exists
602
+ // (see packages/protocol/AGENTS.md).
536
603
  this.sessions.setChromeData(session.id, msg.payload);
537
604
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
538
605
  session.browserSocket.send(JSON.stringify(msg));
@@ -572,6 +639,7 @@ export class RelayServer {
572
639
  if (!session)
573
640
  break;
574
641
  this.sessions.updateDeviceStatus(session.id, 'shutdown');
642
+ this.sessions.setReadySent(session.id, false);
575
643
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
576
644
  session.browserSocket.send(JSON.stringify(msg));
577
645
  }
@@ -582,6 +650,7 @@ export class RelayServer {
582
650
  if (!session)
583
651
  break;
584
652
  this.sessions.updateDeviceStatus(session.id, 'booted');
653
+ this.sessions.setReadySent(session.id, true);
585
654
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
586
655
  session.browserSocket.send(JSON.stringify(msg));
587
656
  }
@@ -597,6 +666,8 @@ export class RelayServer {
597
666
  case 'app:clear-state-error':
598
667
  case 'input:type-done':
599
668
  case 'input:type-error':
669
+ case 'input:done':
670
+ case 'input:error':
600
671
  case 'keyboard:toggled': {
601
672
  const session = this.sessions.get(msg.sessionId);
602
673
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
@@ -604,10 +675,38 @@ export class RelayServer {
604
675
  }
605
676
  break;
606
677
  }
678
+ // Bound to the session's own agent socket, unlike the replies above: this payload
679
+ // lands on the viewer's host OS clipboard, so a second agent (another Mac on the
680
+ // same relay) must not be able to address someone else's session.
681
+ case 'clipboard:data':
682
+ case 'clipboard:write-done':
683
+ case 'clipboard:error': {
684
+ const session = this.sessions.get(msg.sessionId);
685
+ if (session?.agentSocket !== ws)
686
+ break;
687
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
688
+ session.browserSocket.send(JSON.stringify(msg));
689
+ }
690
+ break;
691
+ }
607
692
  // ── Browser → Agent ────────────────────────────────────────────────────
608
693
  case 'device:boot':
609
694
  case 'device:shutdown': {
610
695
  const session = this.sessions.get(msg.sessionId);
696
+ if (session?.agentSocket.readyState !== WebSocket.OPEN && msg.type === 'device:boot') {
697
+ // A boot the agent never receives leaves the viewer on "Waiting for first frame…" with
698
+ // nothing said. `device:shutdown` gets no such reply: nothing waits on it, and inventing
699
+ // a `device:shutdown-error` would grow the contract for a message no one reads.
700
+ //
701
+ // The two are worth telling apart: `bootDevice` is the first call an MCP caller makes, so
702
+ // reporting a stale session id as a dead Mac sends the reader after the wrong problem.
703
+ this.sendTo(ws, {
704
+ type: 'device:boot-error',
705
+ sessionId: msg.sessionId,
706
+ message: session ? 'agent offline' : 'Session not found',
707
+ });
708
+ break;
709
+ }
611
710
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
612
711
  // Tag the boot with whether the viewer is external (public IP) so the agent can pick the
613
712
  // downscale tier. The browser already reports secureContext in the payload.
@@ -630,7 +729,7 @@ export class RelayServer {
630
729
  session.agentSocket.send(JSON.stringify(msg));
631
730
  }
632
731
  else {
633
- ws.send(JSON.stringify({ type: 'open-url:error', sessionId: msg.sessionId, message: 'agent offline' }));
732
+ this.sendTo(ws, { type: 'open-url:error', sessionId: msg.sessionId, message: 'agent offline' });
634
733
  }
635
734
  break;
636
735
  }
@@ -640,7 +739,7 @@ export class RelayServer {
640
739
  session.agentSocket.send(JSON.stringify(msg));
641
740
  }
642
741
  else {
643
- ws.send(JSON.stringify({ type: 'app:clear-state-error', sessionId: msg.sessionId, message: 'agent offline' }));
742
+ this.sendTo(ws, { type: 'app:clear-state-error', sessionId: msg.sessionId, message: 'agent offline' });
644
743
  }
645
744
  break;
646
745
  }
@@ -659,6 +758,28 @@ export class RelayServer {
659
758
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
660
759
  session.agentSocket.send(JSON.stringify(msg));
661
760
  }
761
+ else if (TERMINAL_INPUT_TYPES.has(msg.type) && ws.readyState === WebSocket.OPEN) {
762
+ // Agent offline or session evicted: a terminal input can't be dispatched.
763
+ // Reply to the sender (the MCP/browser socket) so it fails truthfully
764
+ // instead of falling through to its optimistic 2s timeout.
765
+ this.sendTo(ws, { type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' });
766
+ }
767
+ break;
768
+ }
769
+ // Kept out of the input:* chain above: these need their own error type, and the caller
770
+ // is waiting on a bounded deadline, so an undeliverable request fails now rather than
771
+ // hanging until the browser gives up.
772
+ case 'clipboard:read':
773
+ case 'clipboard:write': {
774
+ const session = this.sessions.get(msg.sessionId);
775
+ if (session?.agentSocket.readyState === WebSocket.OPEN) {
776
+ session.agentSocket.send(JSON.stringify(msg));
777
+ }
778
+ else if (ws.readyState === WebSocket.OPEN) {
779
+ this.sendTo(ws, {
780
+ type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: 'agent offline',
781
+ });
782
+ }
662
783
  break;
663
784
  }
664
785
  }
@@ -678,28 +799,124 @@ export class RelayServer {
678
799
  // Removes an agent socket's sessions + resources and rejects its in-flight
679
800
  // screenshot / ui-tree requests. Shared by socket close and re-register
680
801
  // eviction. Returns true if `ws` had agent sessions.
681
- evictAgentSocket(ws) {
682
- const agentSessions = this.sessions.getAllByAgentSocket(ws);
683
- if (agentSessions.length === 0)
684
- return false;
685
- const agentSessionIds = new Set(agentSessions.map((s) => s.id));
686
- for (const [reqId, pending] of this.pendingScreenshots.entries()) {
687
- if (agentSessionIds.has(pending.sessionId)) {
802
+ /**
803
+ * Every message the relay *originates* goes through here. The union checks the shape where the
804
+ * literal is written — a mistyped `type`, a missing field or one that does not belong are all
805
+ * compile errors — and the readyState guard replaces the check that was repeated (and sometimes
806
+ * omitted) at each call site.
807
+ *
808
+ * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
809
+ * they keep their inbound type and there is nothing new to check.
810
+ */
811
+ sendTo(socket, msg) {
812
+ if (socket.readyState !== WebSocket.OPEN)
813
+ return;
814
+ socket.send(JSON.stringify(msg));
815
+ }
816
+ /**
817
+ * Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
818
+ * are moments where the agent that was going to answer them no longer exists — it disconnected,
819
+ * or it restarted and its session moved to the new process. Left alone these reject on their own
820
+ * timeout, minutes later, having told the caller nothing in between.
821
+ */
822
+ rejectPending(sessionIds, reason) {
823
+ for (const pendings of [this.pendingScreenshots, this.pendingUITrees]) {
824
+ for (const [reqId, pending] of pendings.entries()) {
825
+ if (!sessionIds.has(pending.sessionId))
826
+ continue;
688
827
  clearTimeout(pending.timer);
689
- this.pendingScreenshots.delete(reqId);
690
- pending.reject(new Error('Agent disconnected'));
828
+ pendings.delete(reqId);
829
+ pending.reject(new Error(reason));
691
830
  }
692
831
  }
693
- for (const [reqId, pending] of this.pendingUITrees.entries()) {
694
- if (agentSessionIds.has(pending.sessionId)) {
695
- clearTimeout(pending.timer);
696
- this.pendingUITrees.delete(reqId);
697
- pending.reject(new Error('Agent disconnected'));
832
+ }
833
+ /**
834
+ * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
835
+ * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
836
+ * the close always won that race, so a restart cost the tester their place.
837
+ *
838
+ * The sessions stay exactly where they are. They are NOT moved to a holding structure of their
839
+ * own: `getAgentSocketsByIdentity` finds the returning agent's previous socket by walking
840
+ * `sessions` and reading `agentSocket`, so a session parked anywhere else is a session that can
841
+ * never be reclaimed.
842
+ *
843
+ * @returns whether this was an agent socket, matching `evictAgentSocket`'s contract — the close
844
+ * handler uses it to decide whether to keep looking.
845
+ */
846
+ holdAgentSocket(ws) {
847
+ // Shutting down: nothing is coming back, and arming a timer here would leave it running after
848
+ // `stop()` resolved.
849
+ if (this.stopping)
850
+ return this.evictAgentSocket(ws);
851
+ const sessions = this.sessions.getAllByAgentSocket(ws);
852
+ if (sessions.length === 0) {
853
+ // No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
854
+ // returns before dropping it in exactly this case.
855
+ this.sessions.removeResources(ws);
856
+ return false;
857
+ }
858
+ // Not held for the window: these were addressed to a process that is gone, and a returning
859
+ // agent is a new one that never saw them. Waiting would only delay the same failure.
860
+ this.rejectPending(new Set(sessions.map((s) => s.id)), 'Agent disconnected');
861
+ for (const s of sessions) {
862
+ if (s.browserSocket)
863
+ this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
864
+ }
865
+ const timer = setTimeout(() => {
866
+ this.agentHolds.delete(ws);
867
+ // Whatever is still on this socket never got reclaimed. A rebind moves sessions off it, so
868
+ // after a successful one this evicts nothing.
869
+ if (this.evictAgentSocket(ws))
870
+ logger.info(`agent did not come back within ${this.agentGraceMs}ms — session(s) ended`);
871
+ // Not inside that branch: `evictAgentSocket` returns before dropping resources when the
872
+ // socket has no sessions left, and the sessions can leave by routes other than a rebind —
873
+ // `session:end` among them. This is the last moment anything holds a reference to the dead
874
+ // socket, so it is the last chance to drop its entry.
875
+ this.sessions.removeResources(ws);
876
+ }, this.agentGraceMs);
877
+ this.agentHolds.set(ws, timer);
878
+ logger.info(`agent socket lost — holding ${sessions.length} session(s) for ${this.agentGraceMs}ms`);
879
+ return true;
880
+ }
881
+ /** Stop waiting on a socket, whether or not anything was reclaimed from it. */
882
+ releaseHold(ws) {
883
+ const timer = this.agentHolds.get(ws);
884
+ if (!timer)
885
+ return;
886
+ clearTimeout(timer);
887
+ this.agentHolds.delete(ws);
888
+ }
889
+ /**
890
+ * @param cause `'disconnect'` — the socket closed. `'replaced'` — the same agent re-registered
891
+ * and this is its previous socket. Only affects the log line: a restart otherwise reads as a
892
+ * crash followed by a recovery, which is not what happened.
893
+ */
894
+ evictAgentSocket(ws, cause = 'disconnect') {
895
+ const agentSessions = this.sessions.getAllByAgentSocket(ws);
896
+ if (agentSessions.length === 0)
897
+ return false;
898
+ this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
899
+ // Tell whoever is attached before the session stops existing — after `remove()` the socket
900
+ // reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
901
+ // relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
902
+ // the tab sits on "Waiting for first frame..." with no explanation (#426).
903
+ //
904
+ // `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
905
+ // `browserSocket` at all. An attached MCP client does have one and will receive this; its
906
+ // dispatcher drops messages no waiter matches, so that is harmless.
907
+ for (const s of agentSessions) {
908
+ if (s.browserSocket) {
909
+ this.sendTo(s.browserSocket, {
910
+ type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected',
911
+ });
698
912
  }
699
913
  }
700
914
  for (const s of agentSessions)
701
915
  this.sessions.remove(s.id);
702
916
  this.sessions.removeResources(ws);
917
+ logger.info(cause === 'replaced'
918
+ ? `agent re-registered — ${agentSessions.length} previous session(s) replaced`
919
+ : `agent disconnected — ${agentSessions.length} session(s) ended`);
703
920
  return true;
704
921
  }
705
922
  handleAgentRegister(ws, msg) {
@@ -709,34 +926,109 @@ export class RelayServer {
709
926
  // its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
710
927
  // else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
711
928
  const identity = msg.agentId ?? msg.agentName;
929
+ // Deduplicate first. Everything below is keyed by device id, so a payload naming one device
930
+ // twice would collapse to a single entry in `registeredSessions` while `create()` had already
931
+ // made two sessions — leaving one the agent is never told about. That is the same orphan the
932
+ // rebind exists to prevent, arriving by a different door.
933
+ const devices = [...new Map((msg.devices ?? []).map((d) => [d.id, d])).values()];
934
+ const agent = {
935
+ agentId: msg.agentId, agentName: msg.agentName,
936
+ agentPlatform: msg.platform, agentCapabilities: msg.capabilities,
937
+ };
938
+ // deviceId → the session id kept across the restart. Also the guard that a device is rebound at
939
+ // most once (#426): before rebinding existed, `create()` ran after every old session had been
940
+ // evicted, so one device could not be behind two sessions. It can now, and `list()` does not
941
+ // deduplicate — the second card would name a session the agent has never heard of, which is the
942
+ // symptom this whole change is fixing.
943
+ const rebound = new Map();
712
944
  if (identity) {
713
945
  for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
714
946
  if (old === ws)
715
947
  continue;
948
+ // Before any decision about what to rebind: the eviction below settles this socket either
949
+ // way, so the timer has nothing left to do. Releasing here rather than after a successful
950
+ // rebind also covers the case where nothing is rebound at all — a restart that reports a
951
+ // completely different device list still leaves a timer keyed to a dead socket.
952
+ this.releaseHold(old);
953
+ for (const s of this.sessions.getAllByAgentSocket(old)) {
954
+ if (rebound.has(s.deviceId))
955
+ continue;
956
+ const device = devices.find((d) => d.id === s.deviceId);
957
+ // The device is gone from this agent's list — unplugged, deleted, renamed away. Leave it
958
+ // for the eviction below, which tells the browser the session ended.
959
+ if (!device)
960
+ continue;
961
+ this.sessions.rebind(s.id, ws, device, agent);
962
+ rebound.set(s.deviceId, s.id);
963
+ }
716
964
  // Evict before terminate: the old socket's close fires async, by which point its sessions are
717
965
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
718
- this.evictAgentSocket(old);
966
+ // The rebound sessions have already moved off `old`, so this no longer covers them.
967
+ this.evictAgentSocket(old, 'replaced');
719
968
  old.terminate();
720
969
  }
721
970
  }
722
- const sessionIds = this.sessions.create(ws, msg.devices ?? [], msg.agentName, msg.platform, msg.agentId);
723
- const registeredSessions = (msg.devices ?? []).map((d, i) => ({
724
- deviceId: d.id,
725
- sessionId: sessionIds[i],
726
- }));
727
- ws.send(JSON.stringify({ type: 'agent:registered', registeredSessions }));
971
+ // Their in-flight requests are addressed to a process that is gone, and the eviction above can
972
+ // no longer see them. Nothing else would ever settle these.
973
+ if (rebound.size > 0)
974
+ this.rejectPending(new Set(rebound.values()), 'Agent restarted');
975
+ // A device can be back under an agent this session cannot be rebound to — identity is
976
+ // `agentId ?? agentName`, and the upgrade that prompted the restart is often the one that
977
+ // starts sending an agentId. The device is demonstrably present, so holding its old session
978
+ // any longer strands that viewer while someone else picks the very same simulator. End it now
979
+ // and say so, which is what would have happened before the hold existed.
980
+ for (const d of devices) {
981
+ if (rebound.has(d.id))
982
+ continue;
983
+ for (const s of this.sessions.getAllByDeviceId(d.id)) {
984
+ if (s.agentSocket.readyState === WebSocket.OPEN)
985
+ continue;
986
+ if (s.browserSocket) {
987
+ this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
988
+ }
989
+ this.sessions.remove(s.id);
990
+ }
991
+ }
992
+ // Only devices without a surviving session get a new one. Passing all of them here is what
993
+ // would produce the duplicate card described above.
994
+ const fresh = devices.filter((d) => !rebound.has(d.id));
995
+ const freshIds = this.sessions.create(ws, fresh, msg.agentName, msg.platform, msg.agentId, msg.capabilities);
996
+ const byDeviceId = new Map(rebound);
997
+ fresh.forEach((d, i) => byDeviceId.set(d.id, freshIds[i]));
998
+ // Keyed by deviceId, not by position. The old form paired `msg.devices[i]` with `sessionIds[i]`,
999
+ // which only held while every device got a session — now that some are rebound, the arrays have
1000
+ // different lengths and index alignment would hand the agent someone else's session id.
1001
+ const registeredSessions = devices.map((d) => ({ deviceId: d.id, sessionId: byDeviceId.get(d.id) }));
1002
+ this.sendTo(ws, { type: 'agent:registered', registeredSessions });
1003
+ // After the state is final: the browser answers this with `device:boot` on the same session id.
1004
+ for (const sessionId of rebound.values()) {
1005
+ const s = this.sessions.get(sessionId);
1006
+ if (s?.browserSocket) {
1007
+ this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
1008
+ }
1009
+ }
1010
+ if (rebound.size > 0)
1011
+ logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
1012
+ // The startup banner prints "Waiting for agents..." once and then the relay says nothing either
1013
+ // way, so a terminal gives no signal about whether an agent is attached. One line per
1014
+ // transition, matching the disconnect line in evictAgentSocket.
1015
+ logger.info(`agent connected: ${msg.agentName ?? msg.agentId ?? 'unknown'} (${msg.platform ?? 'unknown'}) — ${registeredSessions.length} device(s)`);
728
1016
  }
729
1017
  handleSessionStart(ws, msg) {
730
1018
  const session = this.sessions.get(msg.sessionId);
731
1019
  if (!session) {
732
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
1020
+ this.sendTo(ws, { type: 'error', message: 'Session not found' });
733
1021
  return;
734
1022
  }
735
- const resources = this.sessions.getResources(session.agentSocket);
1023
+ // Read before the resource gate, and answered before it too. The gate would otherwise read the
1024
+ // dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
1025
+ // the tester would be told the Mac is exhausted at the exact moment it is recovering.
1026
+ const agentAway = session.agentSocket.readyState !== WebSocket.OPEN;
1027
+ const resources = agentAway ? undefined : this.sessions.getResources(session.agentSocket);
736
1028
  if (resources) {
737
1029
  const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
738
1030
  if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
739
- ws.send(JSON.stringify({ type: 'error', message: 'Agent resources exhausted' }));
1031
+ this.sendTo(ws, { type: 'error', message: 'Agent resources exhausted' });
740
1032
  return;
741
1033
  }
742
1034
  }
@@ -744,71 +1036,104 @@ export class RelayServer {
744
1036
  this.sessions.join(msg.sessionId, ws);
745
1037
  }
746
1038
  catch {
747
- ws.send(JSON.stringify({ type: 'error', message: 'Session busy' }));
1039
+ this.sendTo(ws, { type: 'error', message: 'Session busy' });
1040
+ return;
1041
+ }
1042
+ // Include the agent's capabilities so the viewer knows up front what is implemented on
1043
+ // the other end — an agent that predates a feature omits it, and the dashboard degrades
1044
+ // deliberately instead of inferring anything from a timeout.
1045
+ this.sendTo(ws, {
1046
+ type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
1047
+ });
1048
+ if (agentAway) {
1049
+ // Joining into a held session. Refusing instead would be worse than it sounds: the viewer
1050
+ // sends `session:start` exactly once per reconnect and ignores a plain `error`, so a browser
1051
+ // blip inside the window would leave a tab that no later `session:rebound` can reach — it is
1052
+ // addressed to `browserSocket`, which the refusal never set. Saying what is happening keeps
1053
+ // the tab inside the contract instead.
1054
+ //
1055
+ // Everything below describes the agent that went away, so it stops here. The viewer will get
1056
+ // it all again from the boot it sends on `session:rebound`.
1057
+ this.sendTo(ws, { type: 'session:agent-away', sessionId: msg.sessionId });
748
1058
  return;
749
1059
  }
750
- ws.send(JSON.stringify({ type: 'session:joined', sessionId: msg.sessionId }));
751
1060
  if (session.chromeData) {
752
- ws.send(JSON.stringify({ type: 'session:chrome', payload: session.chromeData }));
1061
+ this.sendTo(ws, { type: 'session:chrome', payload: session.chromeData });
753
1062
  }
754
1063
  if (session.deviceInfo) {
755
- ws.send(JSON.stringify({ type: 'session:deviceInfo', payload: session.deviceInfo }));
1064
+ this.sendTo(ws, { type: 'session:deviceInfo', payload: session.deviceInfo });
756
1065
  }
757
- // Replay device:ready if the device is already booted (browser WS blip reconnect)
758
- if (session.deviceStatus === 'booted') {
759
- ws.send(JSON.stringify({ type: 'device:ready', payload: { deviceId: session.deviceId } }));
1066
+ // Replay device:ready only if this session actually announced one (browser WS blip reconnect).
1067
+ // Not `deviceStatus`: that starts from the agent's `simctl list` snapshot, so a session for a
1068
+ // simulator that was already running would fire this before the agent had done anything —
1069
+ // telling the viewer a stream exists when none does (#440).
1070
+ if (session.readySent) {
1071
+ this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
760
1072
  // (Re)joining a live stream: ask the agent for an IDR so this viewer gets a decodable
761
1073
  // keyframe immediately, instead of waiting for the next periodic one — and so it isn't
762
1074
  // left blank when the encoder is static-skipping an unchanged screen. Agents that don't
763
1075
  // support on-demand IDR ignore the message.
764
1076
  if (session.agentSocket.readyState === WebSocket.OPEN) {
765
- session.agentSocket.send(JSON.stringify({ type: 'stream:request-idr', sessionId: session.id }));
1077
+ this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId: session.id });
766
1078
  }
767
1079
  }
768
1080
  }
1081
+ /** Relay looks up file_path from DB and enriches it for the agent.
1082
+ *
1083
+ * Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
1084
+ * one session per socket, so an uncorrelated error still lands somewhere sensible — but an MCP
1085
+ * caller waits for the reply matching its own sessionId, so anything else is indistinguishable
1086
+ * from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
1087
+ * same reason: a generic `error` cannot be correlated by construction. */
769
1088
  handleBrowserAppInstall(ws, msg) {
770
- // Relay looks up file_path from DB and enriches; includes sessionId for response routing
771
- const session = this.sessions.get(msg.sessionId);
772
- if (!session) {
773
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
774
- return;
775
- }
1089
+ const sessionId = msg.sessionId;
1090
+ const fail = (message) => this.sendTo(ws, { type: 'app:install-error', sessionId, message });
1091
+ const session = this.sessions.get(sessionId);
1092
+ if (!session)
1093
+ return fail('Session not found');
1094
+ // `JSON.parse` does not honour `RelayMessage`, so buildId is whatever the sender put there.
1095
+ // better-sqlite3 binds a missing value as NULL but *throws* on an object or array — and that
1096
+ // exception is swallowed by the message-loop catch, which is the silence this PR exists to
1097
+ // remove. Checked here rather than trusted; general inbound validation is #444.
1098
+ if (!Number.isInteger(msg.buildId))
1099
+ return fail('Build not found');
776
1100
  const build = getDb()
777
1101
  .prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
778
1102
  .get(msg.buildId);
779
- if (!build) {
780
- ws.send(JSON.stringify({ type: 'app:install-error', message: 'Build not found' }));
781
- return;
782
- }
783
- if (session.agentSocket.readyState === WebSocket.OPEN) {
784
- session.agentSocket.send(JSON.stringify({
785
- type: 'app:install',
786
- sessionId: msg.sessionId,
787
- payload: { filePath: build.file_path, bundleId: build.bundle_id },
788
- }));
789
- }
1103
+ if (!build)
1104
+ return fail('Build not found');
1105
+ // Answer now rather than letting the caller time out — the same shape as `open-url` above.
1106
+ if (session.agentSocket.readyState !== WebSocket.OPEN)
1107
+ return fail('agent offline');
1108
+ this.sendTo(session.agentSocket, {
1109
+ type: 'app:install',
1110
+ sessionId,
1111
+ payload: { filePath: build.file_path, bundleId: build.bundle_id },
1112
+ });
790
1113
  }
1114
+ /** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
791
1115
  handleBrowserAppLaunch(ws, msg) {
792
- // Relay looks up bundle_id from DB; includes sessionId for response routing
793
- const session = this.sessions.get(msg.sessionId);
794
- if (!session) {
795
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
796
- return;
797
- }
1116
+ const sessionId = msg.sessionId;
1117
+ const fail = (message) => this.sendTo(ws, { type: 'app:launch-error', sessionId, message });
1118
+ const session = this.sessions.get(sessionId);
1119
+ if (!session)
1120
+ return fail('Session not found');
1121
+ // See handleBrowserAppInstall — an object here throws inside the driver and the exception is
1122
+ // swallowed upstream.
1123
+ if (!Number.isInteger(msg.buildId))
1124
+ return fail('Bundle ID not available for this build');
798
1125
  const build = getDb()
799
1126
  .prepare('SELECT bundle_id FROM builds WHERE id = ?')
800
1127
  .get(msg.buildId);
801
- if (!build?.bundle_id) {
802
- ws.send(JSON.stringify({ type: 'app:launch-error', message: 'Bundle ID not available for this build' }));
803
- return;
804
- }
805
- if (session.agentSocket.readyState === WebSocket.OPEN) {
806
- session.agentSocket.send(JSON.stringify({
807
- type: 'app:launch',
808
- sessionId: msg.sessionId,
809
- payload: { bundleId: build.bundle_id },
810
- }));
811
- }
1128
+ if (!build?.bundle_id)
1129
+ return fail('Bundle ID not available for this build');
1130
+ if (session.agentSocket.readyState !== WebSocket.OPEN)
1131
+ return fail('agent offline');
1132
+ this.sendTo(session.agentSocket, {
1133
+ type: 'app:launch',
1134
+ sessionId,
1135
+ payload: { bundleId: build.bundle_id },
1136
+ });
812
1137
  }
813
1138
  async handleGetScreenshot(req, res, params) {
814
1139
  if (!requireViewAuth(req, res))
@@ -854,7 +1179,7 @@ export class RelayServer {
854
1179
  },
855
1180
  timer,
856
1181
  });
857
- session.agentSocket.send(JSON.stringify({ type: 'screenshot:request', sessionId, requestId, format }));
1182
+ this.sendTo(session.agentSocket, { type: 'screenshot:request', sessionId, requestId, format });
858
1183
  });
859
1184
  }
860
1185
  async handleGetUITree(req, res, params) {
@@ -897,7 +1222,7 @@ export class RelayServer {
897
1222
  },
898
1223
  timer,
899
1224
  });
900
- session.agentSocket.send(JSON.stringify({ type: 'ui:tree:request', sessionId, requestId }));
1225
+ this.sendTo(session.agentSocket, { type: 'ui:tree:request', sessionId, requestId });
901
1226
  });
902
1227
  }
903
1228
  handleUITreeResponse(msg) {