@tapflowio/relay 0.17.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 +359 -84
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +43 -6
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +94 -8
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/types.d.ts +5 -10
  10. package/dist/types.d.ts.map +1 -1
  11. package/package.json +5 -3
  12. package/public/assets/{AppCenter-BRmG2FEm.js → AppCenter-B3FO67aw.js} +1 -1
  13. package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
  14. package/public/assets/{Default-DTr0wqds.js → Default-BlNUv5dw.js} +1 -1
  15. package/public/assets/Default-BlNUv5dw.js.br +0 -0
  16. package/public/assets/{Invite-DKsNh2GB.js → Invite-CKYFmy7S.js} +1 -1
  17. package/public/assets/Invite-CKYFmy7S.js.br +0 -0
  18. package/public/assets/{MacResources-CLDdcibL.js → MacResources-BK4VQnFc.js} +1 -1
  19. package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
  20. package/public/assets/{NotFound-BtKq2uv6.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-gp6hVQrf.js → ResetPassword-VPPBWzSI.js} +1 -1
  25. package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
  26. package/public/assets/{Setup-DiG4Nbs7.js → Setup-CRdnCl41.js} +1 -1
  27. package/public/assets/Setup-CRdnCl41.js.br +0 -0
  28. package/public/assets/{Team-D1iwg2vL.js → Team-CBsjuUwh.js} +1 -1
  29. package/public/assets/Team-CBsjuUwh.js.br +0 -0
  30. package/public/assets/{Tokens-DGtE4Xcy.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-CavXTq2x.js → badge-OToVq1dK.js} +1 -1
  35. package/public/assets/badge-OToVq1dK.js.br +0 -0
  36. package/public/assets/{build-format-BZV6gt41.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-BGrOEXS6.js → pencil-Cq64I9LV.js} +1 -1
  45. package/public/assets/pencil-Cq64I9LV.js.br +0 -0
  46. package/public/assets/{table-CqOA0Bb1.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-CwlFrbEF.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-BRmG2FEm.js.br +0 -0
  55. package/public/assets/Default-DTr0wqds.js.br +0 -0
  56. package/public/assets/Invite-DKsNh2GB.js.br +0 -0
  57. package/public/assets/MacResources-CLDdcibL.js.br +0 -0
  58. package/public/assets/NotFound-BtKq2uv6.js.br +0 -0
  59. package/public/assets/QASession-B_5aJ67l.js +0 -130
  60. package/public/assets/QASession-B_5aJ67l.js.br +0 -0
  61. package/public/assets/ResetPassword-gp6hVQrf.js.br +0 -0
  62. package/public/assets/Setup-DiG4Nbs7.js.br +0 -0
  63. package/public/assets/Team-D1iwg2vL.js.br +0 -0
  64. package/public/assets/Tokens-DGtE4Xcy.js.br +0 -0
  65. package/public/assets/alert-dialog-CXwkFlF3.js +0 -1
  66. package/public/assets/alert-dialog-CXwkFlF3.js.br +0 -0
  67. package/public/assets/badge-CavXTq2x.js.br +0 -0
  68. package/public/assets/build-format-BZV6gt41.js.br +0 -0
  69. package/public/assets/dialog-JbKwqqS0.js +0 -11
  70. package/public/assets/dialog-JbKwqqS0.js.br +0 -0
  71. package/public/assets/index-CBnxiD7-.js +0 -274
  72. package/public/assets/index-CBnxiD7-.js.br +0 -0
  73. package/public/assets/index-Ctx3_cYz.css +0 -1
  74. package/public/assets/index-Ctx3_cYz.css.br +0 -0
  75. package/public/assets/pencil-BGrOEXS6.js.br +0 -0
  76. package/public/assets/table-CqOA0Bb1.js.br +0 -0
  77. package/public/assets/tabs-BVBbhCFG.js +0 -1
  78. package/public/assets/tabs-BVBbhCFG.js.br +0 -0
  79. package/public/assets/trash-2-CwlFrbEF.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';
@@ -116,11 +121,22 @@ export class RelayServer {
116
121
  // Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
117
122
  idrRequesters = new Map();
118
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;
119
134
  // True when the connection's remote IP is public (not loopback / private LAN) — the agent uses
120
135
  // this to downscale harder for bandwidth on external viewers.
121
136
  wsExternal = new Map();
122
137
  backpressureBytes;
123
138
  screenshotTimeoutMs;
139
+ agentGraceMs;
124
140
  corsAllowed;
125
141
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
126
142
  warnedProxyMisconfig = false;
@@ -135,6 +151,24 @@ export class RelayServer {
135
151
  // itself may take up to 10s before it errors out.
136
152
  this.uiTreeTimeoutMs = options.uiTreeTimeoutMs ?? 15_000;
137
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
+ }
138
172
  this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
139
173
  this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
140
174
  this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
@@ -288,6 +322,12 @@ export class RelayServer {
288
322
  clearInterval(this.heartbeatTimer);
289
323
  this.heartbeatTimer = null;
290
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();
291
331
  return new Promise((resolve, reject) => {
292
332
  this.wss.clients.forEach((ws) => ws.terminate());
293
333
  this.wss.close(() => {
@@ -326,7 +366,7 @@ export class RelayServer {
326
366
  lastAt = now;
327
367
  const session = this.sessions.get(sessionId);
328
368
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
329
- session.agentSocket.send(JSON.stringify({ type: 'stream:request-idr', sessionId }));
369
+ this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
330
370
  }
331
371
  };
332
372
  }
@@ -423,19 +463,35 @@ export class RelayServer {
423
463
  }
424
464
  return;
425
465
  }
466
+ let parsed;
426
467
  try {
427
- const msg = JSON.parse(data.toString());
428
- this.route(ws, msg);
468
+ parsed = JSON.parse(data.toString());
429
469
  }
430
470
  catch {
431
- // 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);
432
487
  }
433
488
  });
434
489
  ws.on('close', () => {
435
490
  this.wsRoles.delete(ws);
436
491
  this.wsExternal.delete(ws);
437
- // Agent main socket disconnected → remove its sessions, reject in-flight screenshots, drop resources
438
- 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))
439
495
  return;
440
496
  // Stream socket disconnected → clear the streamSocket reference
441
497
  const streamSession = this.sessions.getByStreamSocket(ws);
@@ -449,11 +505,11 @@ export class RelayServer {
449
505
  this.sessions.clearBrowser(browserSession.id, () => {
450
506
  const session = this.sessions.get(browserSession.id);
451
507
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
452
- session.agentSocket.send(JSON.stringify({
508
+ this.sendTo(session.agentSocket, {
453
509
  type: 'device:shutdown',
454
510
  sessionId: session.id,
455
511
  payload: { deviceId: session.deviceId },
456
- }));
512
+ });
457
513
  }
458
514
  });
459
515
  }
@@ -500,7 +556,7 @@ export class RelayServer {
500
556
  this.handleUITreeError(msg);
501
557
  break;
502
558
  case 'agents:list': {
503
- ws.send(JSON.stringify({ type: 'agents:listed', sessions: this.sessions.list() }));
559
+ this.sendTo(ws, { type: 'agents:listed', sessions: this.sessions.list() });
504
560
  break;
505
561
  }
506
562
  // ── Session / Stream lifecycle ─────────────────────────────────────────
@@ -531,7 +587,7 @@ export class RelayServer {
531
587
  const session = this.sessions.get(msg.sessionId);
532
588
  if (session) {
533
589
  this.sessions.setStreamSocket(session.id, ws);
534
- ws.send(JSON.stringify({ type: 'stream:registered' }));
590
+ this.sendTo(ws, { type: 'stream:registered' });
535
591
  }
536
592
  break;
537
593
  }
@@ -540,6 +596,10 @@ export class RelayServer {
540
596
  const session = this.sessions.get(msg.sessionId);
541
597
  if (!session)
542
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).
543
603
  this.sessions.setChromeData(session.id, msg.payload);
544
604
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
545
605
  session.browserSocket.send(JSON.stringify(msg));
@@ -579,6 +639,7 @@ export class RelayServer {
579
639
  if (!session)
580
640
  break;
581
641
  this.sessions.updateDeviceStatus(session.id, 'shutdown');
642
+ this.sessions.setReadySent(session.id, false);
582
643
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
583
644
  session.browserSocket.send(JSON.stringify(msg));
584
645
  }
@@ -589,6 +650,7 @@ export class RelayServer {
589
650
  if (!session)
590
651
  break;
591
652
  this.sessions.updateDeviceStatus(session.id, 'booted');
653
+ this.sessions.setReadySent(session.id, true);
592
654
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
593
655
  session.browserSocket.send(JSON.stringify(msg));
594
656
  }
@@ -631,6 +693,20 @@ export class RelayServer {
631
693
  case 'device:boot':
632
694
  case 'device:shutdown': {
633
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
+ }
634
710
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
635
711
  // Tag the boot with whether the viewer is external (public IP) so the agent can pick the
636
712
  // downscale tier. The browser already reports secureContext in the payload.
@@ -653,7 +729,7 @@ export class RelayServer {
653
729
  session.agentSocket.send(JSON.stringify(msg));
654
730
  }
655
731
  else {
656
- 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' });
657
733
  }
658
734
  break;
659
735
  }
@@ -663,7 +739,7 @@ export class RelayServer {
663
739
  session.agentSocket.send(JSON.stringify(msg));
664
740
  }
665
741
  else {
666
- 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' });
667
743
  }
668
744
  break;
669
745
  }
@@ -686,7 +762,7 @@ export class RelayServer {
686
762
  // Agent offline or session evicted: a terminal input can't be dispatched.
687
763
  // Reply to the sender (the MCP/browser socket) so it fails truthfully
688
764
  // instead of falling through to its optimistic 2s timeout.
689
- ws.send(JSON.stringify({ type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' }));
765
+ this.sendTo(ws, { type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' });
690
766
  }
691
767
  break;
692
768
  }
@@ -700,9 +776,9 @@ export class RelayServer {
700
776
  session.agentSocket.send(JSON.stringify(msg));
701
777
  }
702
778
  else if (ws.readyState === WebSocket.OPEN) {
703
- ws.send(JSON.stringify({
779
+ this.sendTo(ws, {
704
780
  type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: 'agent offline',
705
- }));
781
+ });
706
782
  }
707
783
  break;
708
784
  }
@@ -723,28 +799,124 @@ export class RelayServer {
723
799
  // Removes an agent socket's sessions + resources and rejects its in-flight
724
800
  // screenshot / ui-tree requests. Shared by socket close and re-register
725
801
  // eviction. Returns true if `ws` had agent sessions.
726
- evictAgentSocket(ws) {
727
- const agentSessions = this.sessions.getAllByAgentSocket(ws);
728
- if (agentSessions.length === 0)
729
- return false;
730
- const agentSessionIds = new Set(agentSessions.map((s) => s.id));
731
- for (const [reqId, pending] of this.pendingScreenshots.entries()) {
732
- 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;
733
827
  clearTimeout(pending.timer);
734
- this.pendingScreenshots.delete(reqId);
735
- pending.reject(new Error('Agent disconnected'));
828
+ pendings.delete(reqId);
829
+ pending.reject(new Error(reason));
736
830
  }
737
831
  }
738
- for (const [reqId, pending] of this.pendingUITrees.entries()) {
739
- if (agentSessionIds.has(pending.sessionId)) {
740
- clearTimeout(pending.timer);
741
- this.pendingUITrees.delete(reqId);
742
- 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
+ });
743
912
  }
744
913
  }
745
914
  for (const s of agentSessions)
746
915
  this.sessions.remove(s.id);
747
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`);
748
920
  return true;
749
921
  }
750
922
  handleAgentRegister(ws, msg) {
@@ -754,34 +926,109 @@ export class RelayServer {
754
926
  // its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
755
927
  // else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
756
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();
757
944
  if (identity) {
758
945
  for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
759
946
  if (old === ws)
760
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
+ }
761
964
  // Evict before terminate: the old socket's close fires async, by which point its sessions are
762
965
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
763
- this.evictAgentSocket(old);
966
+ // The rebound sessions have already moved off `old`, so this no longer covers them.
967
+ this.evictAgentSocket(old, 'replaced');
764
968
  old.terminate();
765
969
  }
766
970
  }
767
- const sessionIds = this.sessions.create(ws, msg.devices ?? [], msg.agentName, msg.platform, msg.agentId, msg.capabilities);
768
- const registeredSessions = (msg.devices ?? []).map((d, i) => ({
769
- deviceId: d.id,
770
- sessionId: sessionIds[i],
771
- }));
772
- 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)`);
773
1016
  }
774
1017
  handleSessionStart(ws, msg) {
775
1018
  const session = this.sessions.get(msg.sessionId);
776
1019
  if (!session) {
777
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
1020
+ this.sendTo(ws, { type: 'error', message: 'Session not found' });
778
1021
  return;
779
1022
  }
780
- 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);
781
1028
  if (resources) {
782
1029
  const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
783
1030
  if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
784
- ws.send(JSON.stringify({ type: 'error', message: 'Agent resources exhausted' }));
1031
+ this.sendTo(ws, { type: 'error', message: 'Agent resources exhausted' });
785
1032
  return;
786
1033
  }
787
1034
  }
@@ -789,76 +1036,104 @@ export class RelayServer {
789
1036
  this.sessions.join(msg.sessionId, ws);
790
1037
  }
791
1038
  catch {
792
- ws.send(JSON.stringify({ type: 'error', message: 'Session busy' }));
1039
+ this.sendTo(ws, { type: 'error', message: 'Session busy' });
793
1040
  return;
794
1041
  }
795
1042
  // Include the agent's capabilities so the viewer knows up front what is implemented on
796
1043
  // the other end — an agent that predates a feature omits it, and the dashboard degrades
797
1044
  // deliberately instead of inferring anything from a timeout.
798
- ws.send(JSON.stringify({
1045
+ this.sendTo(ws, {
799
1046
  type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
800
- }));
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 });
1058
+ return;
1059
+ }
801
1060
  if (session.chromeData) {
802
- ws.send(JSON.stringify({ type: 'session:chrome', payload: session.chromeData }));
1061
+ this.sendTo(ws, { type: 'session:chrome', payload: session.chromeData });
803
1062
  }
804
1063
  if (session.deviceInfo) {
805
- ws.send(JSON.stringify({ type: 'session:deviceInfo', payload: session.deviceInfo }));
1064
+ this.sendTo(ws, { type: 'session:deviceInfo', payload: session.deviceInfo });
806
1065
  }
807
- // Replay device:ready if the device is already booted (browser WS blip reconnect)
808
- if (session.deviceStatus === 'booted') {
809
- 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 } });
810
1072
  // (Re)joining a live stream: ask the agent for an IDR so this viewer gets a decodable
811
1073
  // keyframe immediately, instead of waiting for the next periodic one — and so it isn't
812
1074
  // left blank when the encoder is static-skipping an unchanged screen. Agents that don't
813
1075
  // support on-demand IDR ignore the message.
814
1076
  if (session.agentSocket.readyState === WebSocket.OPEN) {
815
- session.agentSocket.send(JSON.stringify({ type: 'stream:request-idr', sessionId: session.id }));
1077
+ this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId: session.id });
816
1078
  }
817
1079
  }
818
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. */
819
1088
  handleBrowserAppInstall(ws, msg) {
820
- // Relay looks up file_path from DB and enriches; includes sessionId for response routing
821
- const session = this.sessions.get(msg.sessionId);
822
- if (!session) {
823
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
824
- return;
825
- }
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');
826
1100
  const build = getDb()
827
1101
  .prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
828
1102
  .get(msg.buildId);
829
- if (!build) {
830
- ws.send(JSON.stringify({ type: 'app:install-error', message: 'Build not found' }));
831
- return;
832
- }
833
- if (session.agentSocket.readyState === WebSocket.OPEN) {
834
- session.agentSocket.send(JSON.stringify({
835
- type: 'app:install',
836
- sessionId: msg.sessionId,
837
- payload: { filePath: build.file_path, bundleId: build.bundle_id },
838
- }));
839
- }
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
+ });
840
1113
  }
1114
+ /** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
841
1115
  handleBrowserAppLaunch(ws, msg) {
842
- // Relay looks up bundle_id from DB; includes sessionId for response routing
843
- const session = this.sessions.get(msg.sessionId);
844
- if (!session) {
845
- ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
846
- return;
847
- }
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');
848
1125
  const build = getDb()
849
1126
  .prepare('SELECT bundle_id FROM builds WHERE id = ?')
850
1127
  .get(msg.buildId);
851
- if (!build?.bundle_id) {
852
- ws.send(JSON.stringify({ type: 'app:launch-error', message: 'Bundle ID not available for this build' }));
853
- return;
854
- }
855
- if (session.agentSocket.readyState === WebSocket.OPEN) {
856
- session.agentSocket.send(JSON.stringify({
857
- type: 'app:launch',
858
- sessionId: msg.sessionId,
859
- payload: { bundleId: build.bundle_id },
860
- }));
861
- }
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
+ });
862
1137
  }
863
1138
  async handleGetScreenshot(req, res, params) {
864
1139
  if (!requireViewAuth(req, res))
@@ -904,7 +1179,7 @@ export class RelayServer {
904
1179
  },
905
1180
  timer,
906
1181
  });
907
- session.agentSocket.send(JSON.stringify({ type: 'screenshot:request', sessionId, requestId, format }));
1182
+ this.sendTo(session.agentSocket, { type: 'screenshot:request', sessionId, requestId, format });
908
1183
  });
909
1184
  }
910
1185
  async handleGetUITree(req, res, params) {
@@ -947,7 +1222,7 @@ export class RelayServer {
947
1222
  },
948
1223
  timer,
949
1224
  });
950
- session.agentSocket.send(JSON.stringify({ type: 'ui:tree:request', sessionId, requestId }));
1225
+ this.sendTo(session.agentSocket, { type: 'ui:tree:request', sessionId, requestId });
951
1226
  });
952
1227
  }
953
1228
  handleUITreeResponse(msg) {