@xenon-device-management/xenon 2.0.0 → 2.1.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 (279) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +1 -2
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-C-Pw_hvw.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-DmYQ0oSk.js} +1 -1
  5. package/lib/public/assets/BugReportButton-n8T1dFUp.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-D-rGOBbJ.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-DExETevp.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-CCdFlZm6.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-DiUSOSL-.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-ZOfpH6gh.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-CZNtoB-1.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-BKp6h8Jl.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-Bf881FX4.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-EUoyizvW.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-ClT4hf4_.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-ucR4bnmc.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-K-hdylwq.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-BLhdwI3c.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-BbiN7rBI.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-Dez1petz.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-UjmFHLAJ.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-BVyEigbu.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-B-ZlBof3.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-DAQjAgUv.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-CmIPa5JK.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-Dd-IhMC1.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-CAIeJwa5.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-C4Jrjg8f.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-BGzlf-4i.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-_wYqDjDC.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-Ckn00yMB.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-pdVkomLa.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-B8vmwkHj.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-BAqnFI-F.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CYq_4F_N.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-B6UEAp_1.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-CLqNvmrN.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-Da90BBbT.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-BuIIcmGp.js} +2 -2
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-MX7IzqyW.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-Bq9aY-We.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CIhXIJyP.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-bXjpswQI.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-pJ72y5Fy.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-UOcHALV2.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-XnJgp5yb.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-QkAhI1Jx.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-B42Da0m4.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-CKVqcyp6.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-BORGkN3N.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CKWFOjOI.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-BpFKVdC8.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-CcbKBicY.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BA0W9bxl.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Cp7ZBq3s.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-DIZWIoVR.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C_4YuvAp.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CXjD_Xth.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CTqIHeoG.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-B0KYkk8z.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-BcRBKUXd.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-Dh2PXSf_.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-_sXqZztt.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-DGHT8MKm.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV88cV3Z.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-CIDH9wkj.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-DYGuSNwl.js} +1 -1
  69. package/lib/public/assets/{users-CEJ3fC78.js → users-7yviqflc.js} +1 -1
  70. package/lib/public/assets/{users-Drd7ifJx.js → users-BUE7MoTq.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-BTRxtlGo.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-DMKD9Pgm.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-CWE94qcK.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +57 -13
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/ws/h264StreamWs.js +13 -8
  84. package/lib/src/app/ws/logcatWs.js +12 -7
  85. package/lib/src/app/ws/upgradeRouter.js +215 -0
  86. package/lib/src/dashboard/event-manager.js +7 -6
  87. package/lib/src/data-service/device-service.js +152 -49
  88. package/lib/src/data-service/device-store.js +81 -10
  89. package/lib/src/data-service/deviceClaims.js +98 -0
  90. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  91. package/lib/src/data-service/prisma-store.js +94 -31
  92. package/lib/src/device-managers/AndroidDeviceManager.js +13 -15
  93. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  94. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  95. package/lib/src/device-managers/NodeDevices.js +7 -2
  96. package/lib/src/device-managers/android/AndroidH264StreamService.js +3 -3
  97. package/lib/src/device-managers/android/AndroidStreamService.js +6 -5
  98. package/lib/src/device-managers/android/previewHold.js +2 -2
  99. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  100. package/lib/src/device-managers/ios/IOSStreamService.js +158 -38
  101. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  102. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  103. package/lib/src/device-utils.js +78 -24
  104. package/lib/src/gateway/createHandoff.js +73 -0
  105. package/lib/src/gateway/defaultGateway.js +80 -0
  106. package/lib/src/gateway/forwardToNode.js +147 -0
  107. package/lib/src/gateway/hubSessionToken.js +356 -0
  108. package/lib/src/gateway/internalCall.js +90 -0
  109. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  110. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  111. package/lib/src/gateway/sessionCreate.js +136 -0
  112. package/lib/src/gateway/sessionGateway.js +201 -0
  113. package/lib/src/gateway/sessionLocator.js +100 -0
  114. package/lib/src/generated/client/edge.js +7 -4
  115. package/lib/src/generated/client/index-browser.js +4 -1
  116. package/lib/src/generated/client/index.d.ts +112 -1
  117. package/lib/src/generated/client/index.js +7 -4
  118. package/lib/src/generated/client/package.json +1 -1
  119. package/lib/src/generated/client/schema.prisma +9 -0
  120. package/lib/src/generated/client/wasm.js +4 -1
  121. package/lib/src/helpers/index.js +5 -1
  122. package/lib/src/logger.js +5 -0
  123. package/lib/src/middleware/authMiddleware.js +80 -0
  124. package/lib/src/middleware/commandAuth.js +31 -17
  125. package/lib/src/middleware/csrfMiddleware.js +3 -2
  126. package/lib/src/middleware/sessionListingFilter.js +153 -0
  127. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  128. package/lib/src/plugin.js +27 -1
  129. package/lib/src/prisma.js +2 -1
  130. package/lib/src/services/DeviceReconciler.js +5 -2
  131. package/lib/src/services/OrphanSweeper.js +12 -19
  132. package/lib/src/services/PortAllocator.js +75 -16
  133. package/lib/src/services/ServerManager.js +50 -30
  134. package/lib/src/services/SessionLifecycleService.js +335 -76
  135. package/lib/src/services/ShutdownCoordinator.js +7 -1
  136. package/lib/src/services/SocketServer.js +11 -2
  137. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  138. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  139. package/lib/src/services/lease/LeaseOrphanSweeper.js +32 -6
  140. package/lib/src/services/lease/LeaseService.js +19 -5
  141. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  142. package/lib/src/services/session/sessionCredentials.js +7 -59
  143. package/lib/src/sessions/CloudSession.js +12 -0
  144. package/lib/src/sessions/LocalSession.js +52 -26
  145. package/lib/src/sessions/RemoteSession.js +116 -9
  146. package/lib/src/sessions/SessionManager.js +49 -6
  147. package/lib/src/sessions/appiumUmbrella.js +32 -0
  148. package/lib/test/helpers/appium-umbrella.js +84 -0
  149. package/lib/test/helpers/fake-device-table.js +97 -0
  150. package/lib/test/helpers/loki-stores.js +41 -0
  151. package/lib/test/helpers/loopbackRequest.js +77 -0
  152. package/lib/test/helpers/scratch-database.js +100 -0
  153. package/lib/test/helpers/scratch-port-leases.js +119 -0
  154. package/lib/test/helpers/test-container.js +35 -27
  155. package/lib/test/integration/androidDevices.spec.js +5 -4
  156. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  157. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  158. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  159. package/lib/test/unit/AndroidDeviceManager.spec.js +8 -1
  160. package/lib/test/unit/DeviceModel.spec.js +9 -9
  161. package/lib/test/unit/HealingCascade.test.js +5 -1
  162. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  163. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  164. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  165. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  166. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  167. package/lib/test/unit/android-h264-service.spec.js +6 -0
  168. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  169. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  170. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  171. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  172. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  173. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  174. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  175. package/lib/test/unit/authMiddleware.test.js +3 -0
  176. package/lib/test/unit/bug-report/route.spec.js +9 -9
  177. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  178. package/lib/test/unit/clipboard-route.spec.js +18 -8
  179. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  180. package/lib/test/unit/command-auth.spec.js +31 -31
  181. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  182. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  183. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  184. package/lib/test/unit/create-error-message.spec.js +43 -0
  185. package/lib/test/unit/create-handoff.spec.js +86 -0
  186. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  187. package/lib/test/unit/device-access-guard.spec.js +28 -28
  188. package/lib/test/unit/device-service.spec.js +4 -0
  189. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  190. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  191. package/lib/test/unit/device-team-guard.spec.js +20 -20
  192. package/lib/test/unit/device-utils.spec.js +8 -0
  193. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  194. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  195. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  196. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  197. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  198. package/lib/test/unit/helpers.spec.js +27 -0
  199. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  200. package/lib/test/unit/hub-create-once.spec.js +190 -0
  201. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  202. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  203. package/lib/test/unit/hub-node-control-forward.spec.js +291 -0
  204. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  205. package/lib/test/unit/hub-node-race.spec.js +401 -0
  206. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  207. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  208. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  209. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  210. package/lib/test/unit/hub-session-token.spec.js +265 -0
  211. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  212. package/lib/test/unit/install-repository-app-team.spec.js +2 -2
  213. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  214. package/lib/test/unit/internal-call.spec.js +181 -0
  215. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  216. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  217. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  218. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  219. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  220. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  221. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  222. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  223. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +92 -0
  224. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  225. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  226. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  227. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  228. package/lib/test/unit/logcat-ws.spec.js +4 -1
  229. package/lib/test/unit/loopback-request.spec.js +81 -0
  230. package/lib/test/unit/node-create-caps.spec.js +54 -0
  231. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  232. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  233. package/lib/test/unit/omni-vision.spec.js +7 -3
  234. package/lib/test/unit/plugin.spec.js +45 -4
  235. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  236. package/lib/test/unit/profile-router.test.js +11 -11
  237. package/lib/test/unit/queue-requester.spec.js +5 -0
  238. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  239. package/lib/test/unit/queue-visibility.spec.js +1 -1
  240. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  241. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  242. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  243. package/lib/test/unit/recording-store.spec.js +9 -3
  244. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  245. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  246. package/lib/test/unit/remote-session-create.spec.js +130 -0
  247. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  248. package/lib/test/unit/reset-link.test.js +14 -14
  249. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  250. package/lib/test/unit/session-assets.spec.js +9 -9
  251. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  252. package/lib/test/unit/session-create-layer.spec.js +255 -0
  253. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  254. package/lib/test/unit/session-credentials.spec.js +0 -47
  255. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  256. package/lib/test/unit/session-locator.spec.js +250 -0
  257. package/lib/test/unit/session-node-calls.spec.js +174 -0
  258. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  259. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  260. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  261. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  262. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  263. package/lib/test/unit/stream-leave-route.spec.js +15 -7
  264. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  265. package/lib/test/unit/stream-stop-ownership.spec.js +13 -5
  266. package/lib/test/unit/stream-stop-recording.spec.js +13 -5
  267. package/lib/test/unit/stream-ticket-identity.spec.js +15 -5
  268. package/lib/test/unit/upgrade-router.spec.js +446 -0
  269. package/lib/test/unit/users-router.test.js +11 -8
  270. package/lib/test/unit/verify-credential.spec.js +4 -1
  271. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  272. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  273. package/lib/tsconfig.tsbuildinfo +1 -1
  274. package/package.json +1 -2
  275. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  276. package/prisma/schema.prisma +9 -0
  277. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  278. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  279. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -67,13 +67,18 @@ class NodeDevices {
67
67
  }
68
68
  });
69
69
  }
70
- unRegisterNode(host) {
70
+ /**
71
+ * Ask the hub to forget this node's phones filed under `host`. With the
72
+ * node's id the hub takes every phone this node reported, whatever its
73
+ * host; it never takes another server's (grid.ts, removeNodeDevices).
74
+ */
75
+ unRegisterNode(host, nodeId) {
71
76
  return __awaiter(this, void 0, void 0, function* () {
72
77
  logger_1.default.info(`Unregistering node ${this.host}/xenon/api/register`);
73
78
  try {
74
79
  const client = InternalHttpClient_1.InternalHttpClient.getClient(this.tlsRejectUnauthorized);
75
80
  yield client.post(`${this.host}/xenon/api/register`, [], {
76
- params: { type: 'unregister', host },
81
+ params: Object.assign({ type: 'unregister', host }, (nodeId ? { nodeId } : {})),
77
82
  headers: this.nodeHeaders(),
78
83
  });
79
84
  logger_1.default.info(`Unregistered node ${host} from hub`);
@@ -57,7 +57,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
57
57
  const child_process_1 = require("child_process");
58
58
  const typedi_1 = require("typedi");
59
59
  const logger_1 = __importDefault(require("../../logger"));
60
- const device_store_1 = require("../../data-service/device-store");
60
+ const ownDeviceRow_1 = require("../ownDeviceRow");
61
61
  const H264Multiplexer_1 = require("./H264Multiplexer");
62
62
  const h264NalParser_1 = require("./h264NalParser");
63
63
  const androidH264Config_1 = require("../../app/routers/androidH264Config");
@@ -266,7 +266,7 @@ let AndroidH264StreamService = class AndroidH264StreamService {
266
266
  startScrcpyOrThrow(udid, onPacket) {
267
267
  return __awaiter(this, void 0, void 0, function* () {
268
268
  const { ScrcpyServerSession, scrcpyMaxSizeFromDims } = yield Promise.resolve().then(() => __importStar(require('./ScrcpyServerSession')));
269
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
269
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
270
270
  const maxSize = scrcpyMaxSizeFromDims(Number(device === null || device === void 0 ? void 0 : device.screenWidth), Number(device === null || device === void 0 ? void 0 : device.screenHeight));
271
271
  const parser = new h264NalParser_1.H264NalParser();
272
272
  const session = new ScrcpyServerSession(udid);
@@ -405,7 +405,7 @@ let AndroidH264StreamService = class AndroidH264StreamService {
405
405
  resolveCaptureSize(udid) {
406
406
  return __awaiter(this, void 0, void 0, function* () {
407
407
  try {
408
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
408
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
409
409
  const sw = Number(device === null || device === void 0 ? void 0 : device.screenWidth);
410
410
  const sh = Number(device === null || device === void 0 ? void 0 : device.screenHeight);
411
411
  if (Number.isFinite(sw) && Number.isFinite(sh) && sw > 0 && sh > 0) {
@@ -59,6 +59,7 @@ const http_1 = __importDefault(require("http"));
59
59
  const sharp_1 = __importDefault(require("sharp"));
60
60
  const logger_1 = __importDefault(require("../../logger"));
61
61
  const device_store_1 = require("../../data-service/device-store");
62
+ const ownDeviceRow_1 = require("../ownDeviceRow");
62
63
  const DeviceLockManager_1 = require("./DeviceLockManager");
63
64
  const typedi_1 = require("typedi");
64
65
  const ResourceIsolationService_1 = require("../../services/ResourceIsolationService");
@@ -272,7 +273,7 @@ let AndroidStreamService = class AndroidStreamService {
272
273
  `(${decision.reason}); discarding it and starting fresh.`);
273
274
  yield this.disposeSession(udid, existing);
274
275
  }
275
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
276
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
276
277
  if (!device)
277
278
  throw new Error(`Device ${udid} not found in DB`);
278
279
  // Resolve the real adb binary once, up front, so the capture loop never
@@ -315,7 +316,7 @@ let AndroidStreamService = class AndroidStreamService {
315
316
  catch (_d) {
316
317
  /* ignore */
317
318
  }
318
- yield portAllocator.release(mjpegPort).catch(() => undefined);
319
+ yield portAllocator.release(mjpegPort, udid).catch(() => undefined);
319
320
  yield portAllocator.blockPort(mjpegPort, 'mjpeg').catch(() => undefined);
320
321
  }
321
322
  }
@@ -346,7 +347,7 @@ let AndroidStreamService = class AndroidStreamService {
346
347
  logger_1.default.info(`[${udid}] MJPEG stream primed with first frame.`);
347
348
  }
348
349
  // Update device info in store to ensure other services (like VideoPipeline) can find the port
349
- const updatedDevice = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
350
+ const updatedDevice = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
350
351
  if (updatedDevice) {
351
352
  yield device_store_1.DeviceStoreFactory.getStore().updateDevice(udid, updatedDevice.host, {
352
353
  mjpegServerPort: mjpegPort,
@@ -393,7 +394,7 @@ let AndroidStreamService = class AndroidStreamService {
393
394
  if (this.sessions.get(udid) === session)
394
395
  this.sessions.delete(udid);
395
396
  try {
396
- yield typedi_1.Container.get(PortAllocator_1.PortAllocator).release(session.mjpegPort);
397
+ yield typedi_1.Container.get(PortAllocator_1.PortAllocator).release(session.mjpegPort, udid);
397
398
  }
398
399
  catch (e) {
399
400
  logger_1.default.warn(`[${udid}] Failed to release mjpeg port lease while restarting stream: ${e}`);
@@ -420,7 +421,7 @@ let AndroidStreamService = class AndroidStreamService {
420
421
  // 'mjpeg' lease is released here — the device-level 'system' port (leased
421
422
  // by AndroidDeviceManager) belongs to the device lifecycle, not the stream.
422
423
  try {
423
- yield typedi_1.Container.get(PortAllocator_1.PortAllocator).release(session.mjpegPort);
424
+ yield typedi_1.Container.get(PortAllocator_1.PortAllocator).release(session.mjpegPort, udid);
424
425
  }
425
426
  catch (e) {
426
427
  logger_1.default.warn(`[${udid}] Failed to release mjpeg port lease on stop: ${e}`);
@@ -16,7 +16,7 @@ exports.mayReleasePreviewHold = mayReleasePreviewHold;
16
16
  exports.releaseIdlePreviewHold = releaseIdlePreviewHold;
17
17
  const typedi_1 = require("typedi");
18
18
  const logger_1 = __importDefault(require("../../logger"));
19
- const device_store_1 = require("../../data-service/device-store");
19
+ const ownDeviceRow_1 = require("../ownDeviceRow");
20
20
  const device_service_1 = require("../../data-service/device-service");
21
21
  const manualLock_1 = require("../../services/recording/manualLock");
22
22
  const recording_store_1 = require("../../services/recording/recording-store");
@@ -43,7 +43,7 @@ function releaseIdlePreviewHold(udid) {
43
43
  return __awaiter(this, void 0, void 0, function* () {
44
44
  var _a, _b, _c, _d, _e;
45
45
  try {
46
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
46
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
47
47
  if (!device)
48
48
  return false;
49
49
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -76,6 +76,7 @@ const config_1 = require("../../config");
76
76
  const device_service_1 = require("../../data-service/device-service");
77
77
  const iOSTracker_1 = require("../iOSTracker");
78
78
  const networkAddresses_1 = require("../../helpers/networkAddresses");
79
+ const localDeviceHosts_1 = require("../localDeviceHosts");
79
80
  /**
80
81
  * Should this simulator be given wda/mjpeg ports the moment we notice it?
81
82
  *
@@ -152,9 +153,13 @@ let IOSDiscoveryService = class IOSDiscoveryService {
152
153
  console.log('[POISON PILL] REAL fetchLocalIOSDevices CALLED IN TEST MODE!');
153
154
  }
154
155
  const devices = yield this.getConnectedDevices();
156
+ // This server's own row for a phone: the exact host getDeviceInfo files
157
+ // it under. A node on this machine sees the same iPhone and shares the
158
+ // IP, so its row for the udid must not be taken for ours.
159
+ const ownHost = (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort);
155
160
  const deviceProcessingPromises = devices.map((udid) => __awaiter(this, void 0, void 0, function* () {
156
161
  try {
157
- const existingDevice = existingDeviceDetails.find((device) => device.udid === udid);
162
+ const existingDevice = existingDeviceDetails.find((device) => device.udid === udid && device.host === ownHost);
158
163
  if (existingDevice) {
159
164
  const networkIp = yield this.fetchRealDeviceNetworkIp(udid);
160
165
  return Object.assign(Object.assign({}, existingDevice), { ip: networkIp || (0, networkAddresses_1.sanitizeDeviceNetworkIp)(existingDevice.ip) || '' });
@@ -176,10 +181,10 @@ let IOSDiscoveryService = class IOSDiscoveryService {
176
181
  getDeviceInfo(udid) {
177
182
  return __awaiter(this, void 0, void 0, function* () {
178
183
  const store = device_store_1.DeviceStoreFactory.getStore();
179
- const storeDevice = yield store.findDevice({ udid });
180
- const host = this.pluginArgs.remoteMachineProxyIP
181
- ? String(this.pluginArgs.remoteMachineProxyIP)
182
- : `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`;
184
+ const host = (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort);
185
+ // Our own row only (udid AND host): it is spread over the result below,
186
+ // and another server's row would hand us its host, ports and busy state.
187
+ const storeDevice = yield store.findDevice({ udid, host });
183
188
  // A real device is going to want these, so ask now — but not at the cost
184
189
  // of the whole discovery pass. See tryAcquire: an exhausted range used to
185
190
  // throw out of here and take the device list with it.
@@ -257,9 +262,11 @@ let IOSDiscoveryService = class IOSDiscoveryService {
257
262
  }
258
263
  const store = device_store_1.DeviceStoreFactory.getStore();
259
264
  const nodeLanIp = this.resolveNodeLanIp();
265
+ // A node on this Mac lists the same simulators; only our own row's ports.
266
+ const simHost = (0, localDeviceHosts_1.iosSimulatorHost)(this.pluginArgs, this.hostPort);
260
267
  return yield Promise.all(simulators.map((d) => __awaiter(this, void 0, void 0, function* () {
261
268
  var _a;
262
- const storeDevice = yield store.findDevice({ udid: d.udid });
269
+ const storeDevice = yield store.findDevice({ udid: d.udid, host: simHost });
263
270
  const willRunWda = simulatorNeedsPortNow(d.state);
264
271
  return Object.assign(Object.assign(Object.assign({}, d), (0, appleIdentity_1.simulatorIdentity)(d.name)), { wdaLocalPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.wdaLocalPort) ||
265
272
  (willRunWda
@@ -267,7 +274,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
267
274
  : undefined), mjpegServerPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.mjpegServerPort) ||
268
275
  (willRunWda
269
276
  ? yield typedi_1.Container.get(PortAllocator_1.PortAllocator).tryAcquire('mjpeg', d.udid)
270
- : undefined), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`, ip: nodeLanIp, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 });
277
+ : undefined), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: simHost, ip: nodeLanIp, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 });
271
278
  })));
272
279
  });
273
280
  }
@@ -323,7 +330,11 @@ let IOSDiscoveryService = class IOSDiscoveryService {
323
330
  }
324
331
  }));
325
332
  tracker.on('detached', (udid) => __awaiter(this, void 0, void 0, function* () {
326
- const deviceRemoved = [{ udid, host: this.pluginArgs.bindHostOrIp }];
333
+ // The host 'attached' filed it under (getDeviceInfo), exact: a bare IP
334
+ // is matched as a substring and takes other servers' rows with it.
335
+ const deviceRemoved = [
336
+ { udid, host: (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort), nodeId: this.nodeId },
337
+ ];
327
338
  if (this.pluginArgs.hub) {
328
339
  yield new NodeDevices_1.default(this.pluginArgs.hub, {
329
340
  tlsRejectUnauthorized: this.pluginArgs.tlsRejectUnauthorized,
@@ -60,8 +60,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
60
60
  return (mod && mod.__esModule) ? mod : { "default": mod };
61
61
  };
62
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.IOS_IDLE_STOP_MS = void 0;
63
+ exports.StreamRestartRefused = exports.IOS_IDLE_STOP_MS = void 0;
64
64
  exports.shouldStopIdleIosStream = shouldStopIdleIosStream;
65
+ exports.heldByAppiumSession = heldByAppiumSession;
65
66
  const typedi_1 = require("typedi");
66
67
  const ProcessRegistry_1 = require("../../services/ProcessRegistry");
67
68
  const child_process_1 = require("child_process");
@@ -77,6 +78,7 @@ const helpers_1 = require("../../helpers");
77
78
  const singleFlight_1 = require("../../helpers/singleFlight");
78
79
  const PortAllocator_1 = require("../../services/PortAllocator");
79
80
  const device_store_1 = require("../../data-service/device-store");
81
+ const ownDeviceRow_1 = require("../ownDeviceRow");
80
82
  const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
81
83
  const tunnelProcess_1 = require("./tunnelProcess");
82
84
  const device_service_1 = require("../../data-service/device-service");
@@ -98,6 +100,25 @@ function shouldStopIdleIosStream(use) {
98
100
  return false;
99
101
  return !use.sessionId || (0, manualLock_1.isManualLock)(use.sessionId);
100
102
  }
103
+ /**
104
+ * Whether a live Appium session holds the phone: busy under a session id that
105
+ * isn't a live-preview hold. A claim still waiting for its session id isn't
106
+ * one yet.
107
+ */
108
+ function heldByAppiumSession(device) {
109
+ return !!(device === null || device === void 0 ? void 0 : device.busy) && !!device.session_id && !(0, manualLock_1.isManualLock)(device.session_id);
110
+ }
111
+ /**
112
+ * The ports go-ios's tunnel process listens on: its tunnel-info API
113
+ * (`GO_IOS_AGENT_PORT`, default 60105) and the first phone's userspace tunnel
114
+ * (API port + 1). On iOS 17+ every go-ios command that talks to the phone,
115
+ * `runwda` included, goes through that process.
116
+ */
117
+ const GO_IOS_AGENT_PORTS = [60105, 60106];
118
+ /** A start refused a restart because an Appium session holds the phone. */
119
+ class StreamRestartRefused extends Error {
120
+ }
121
+ exports.StreamRestartRefused = StreamRestartRefused;
101
122
  let IOSStreamService = class IOSStreamService {
102
123
  constructor() {
103
124
  this.sessions = new Map();
@@ -146,7 +167,7 @@ let IOSStreamService = class IOSStreamService {
146
167
  logger_1.default.warn(`Stream watchdog detected failure for ${udid}. Attempting autonomous healing...`);
147
168
  try {
148
169
  // Get device state to check for Session Shield
149
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
170
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
150
171
  if (device && device.busy) {
151
172
  logger_1.default.info(`🛡️ [Watchdog] Skipping heal for ${udid} as it has an active session.`);
152
173
  continue;
@@ -184,7 +205,7 @@ let IOSStreamService = class IOSStreamService {
184
205
  if (session.viewerCount > 0 || idleMs <= exports.IOS_IDLE_STOP_MS)
185
206
  continue;
186
207
  try {
187
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
208
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
188
209
  const recording = yield typedi_2.Container.get(recording_store_1.RecordingStore).isRecording(udid);
189
210
  const stop = shouldStopIdleIosStream({
190
211
  idleMs,
@@ -329,20 +350,28 @@ let IOSStreamService = class IOSStreamService {
329
350
  });
330
351
  }
331
352
  /**
332
- * Kill any orphan tunnel processes that might be left from previous runs
333
- * This is critical for preventing 'address already in use' errors
353
+ * Kill go-ios tunnel processes left over from earlier runs, so a new tunnel
354
+ * can bind the agent's ports ('address already in use' otherwise).
355
+ *
356
+ * Never while an Appium session holds the phone. That session may be
357
+ * driving a WDA go-ios launched (iOSCapabilities points a session at the
358
+ * stream's WDA whenever a stream runs), and on iOS 17+ that WDA reaches the
359
+ * phone through the tunnel listening on these ports. Nothing here can tell
360
+ * that tunnel from an orphan, so the sweep waits for the next stop or start
361
+ * after the session ends; a restart reaps every go-ios process at boot.
334
362
  */
335
363
  cleanupOrphanTunnels(udid) {
336
364
  return __awaiter(this, void 0, void 0, function* () {
365
+ if (yield this.appiumSessionMayUse(udid, 'go-ios tunnels'))
366
+ return;
337
367
  logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
338
368
  // Reap the tunnel process *group* for this udid so the self-forking go-ios
339
369
  // agent children (whose argv carries no udid, so a udid-scoped pkill can't
340
370
  // see them) die with their parent instead of orphaning. Group-scoped, so a
341
371
  // second device's tunnel is left untouched. See ./tunnelProcess.
342
372
  yield (0, tunnelProcess_1.reapTunnelsForUdid)(udid, execPromise);
343
- // Check common go-ios agent ports (60105, 60106) and kill if bound
344
- const agentPorts = [60105, 60106];
345
- for (const port of agentPorts) {
373
+ // Whatever still listens on the go-ios agent's ports.
374
+ for (const port of GO_IOS_AGENT_PORTS) {
346
375
  try {
347
376
  const { stdout } = yield execPromise(`lsof -ti :${port}`);
348
377
  const pids = stdout.trim().split('\n');
@@ -361,14 +390,65 @@ let IOSStreamService = class IOSStreamService {
361
390
  yield new Promise((resolve) => setTimeout(resolve, 500));
362
391
  });
363
392
  }
393
+ /**
394
+ * Whether an Appium session may be using what the caller is about to kill:
395
+ * one holds the phone, or the phone's row can't be read, in which case
396
+ * nothing is known and nothing is killed.
397
+ */
398
+ appiumSessionMayUse(udid, what) {
399
+ return __awaiter(this, void 0, void 0, function* () {
400
+ var _a;
401
+ try {
402
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
403
+ if (!heldByAppiumSession(device))
404
+ return false;
405
+ logger_1.default.info(`[${udid}] Leaving ${what} alone: Appium session ${device === null || device === void 0 ? void 0 : device.session_id} holds the device and may be using them`);
406
+ }
407
+ catch (e) {
408
+ logger_1.default.warn(`[${udid}] Leaving ${what} alone: could not read the device: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
409
+ }
410
+ return true;
411
+ });
412
+ }
413
+ /**
414
+ * Throw StreamRestartRefused when an Appium session holds the phone, as the
415
+ * watchdog's heal already skips a busy device. A restart would kill the WDA
416
+ * and go-ios tunnel the session may be driving, or, on a stream attached to
417
+ * the session's own WDA, launch a second WDA over it. A WDA that is only
418
+ * slow to answer /status (a long command) would be killed for nothing. The
419
+ * stream is left as it is; it can restart once the session ends. A row that
420
+ * can't be read refuses too.
421
+ */
422
+ refuseRestartUnderAppiumSession(udid) {
423
+ return __awaiter(this, void 0, void 0, function* () {
424
+ var _a;
425
+ let device;
426
+ try {
427
+ device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
428
+ }
429
+ catch (e) {
430
+ throw new StreamRestartRefused(`Stream for ${udid} is not answering, and the device can't be read to check for an Appium session (${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}): not restarting it.`);
431
+ }
432
+ if (!heldByAppiumSession(device))
433
+ return;
434
+ throw new StreamRestartRefused(`Stream for ${udid} is not answering, but Appium session ${device === null || device === void 0 ? void 0 : device.session_id} holds the device: not restarting the WDA and go-ios tunnel it may be driving. The stream can restart once the session ends.`);
435
+ });
436
+ }
364
437
  /**
365
438
  * Check if WDA is already running and responding
366
439
  * Principal Resilience: Retries transient connection errors (ECONNRESET) up to 2 times
367
440
  * with exponential backoff, as these often indicate WDA is restarting or tunnel is reconnecting.
441
+ *
442
+ * It says whether *a* WDA answers on the port, never whose. WDA names no
443
+ * phone: /status carries os, ios.ip, build and device (the form factor),
444
+ * and /wda/device/info's `uuid` is identifierForVendor, not the UDID. So it
445
+ * takes no udid. A caller that must know the phone relies on the port
446
+ * being one it already knows belongs to it: its own stream's leased port,
447
+ * or the Device row's port while an Appium session holds that phone.
368
448
  */
369
- isWDARunning(wdaPort_1, udid_1) {
370
- return __awaiter(this, arguments, void 0, function* (wdaPort, udid, retries = 2) {
371
- var _a, _b, _c, _d, _e, _f, _g;
449
+ isWDARunning(wdaPort_1) {
450
+ return __awaiter(this, arguments, void 0, function* (wdaPort, retries = 2) {
451
+ var _a, _b, _c, _d;
372
452
  const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
373
453
  const host = '127.0.0.1'; // Force IPv4 for local tunnels
374
454
  const maxRetries = retries;
@@ -380,15 +460,9 @@ let IOSStreamService = class IOSStreamService {
380
460
  httpAgent: new http_1.default.Agent({ keepAlive: false }),
381
461
  validateStatus: (status) => status === 200,
382
462
  });
383
- // Principal Resilience: Verify the UDID if provided to ensure we're talking to the right device
384
- const remoteUdid = (_c = (_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ios) === null || _c === void 0 ? void 0 : _c.udid;
385
- if (udid && remoteUdid && remoteUdid !== udid) {
386
- logger_1.default.warn(`[WDA] Port ${wdaPort} is used by a DIFFERENT device: ${remoteUdid} (expected ${udid})`);
387
- return false;
388
- }
389
- const isReady = ((_e = (_d = response.data) === null || _d === void 0 ? void 0 : _d.value) === null || _e === void 0 ? void 0 : _e.ready) === true;
463
+ const isReady = ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ready) === true;
390
464
  if (!isReady) {
391
- logger_1.default.debug(`[WDA] Port ${wdaPort} active but not ready. Status: ${JSON.stringify((_f = response.data) === null || _f === void 0 ? void 0 : _f.value)}`);
465
+ logger_1.default.debug(`[WDA] Port ${wdaPort} active but not ready. Status: ${JSON.stringify((_c = response.data) === null || _c === void 0 ? void 0 : _c.value)}`);
392
466
  }
393
467
  return isReady;
394
468
  }
@@ -400,7 +474,7 @@ let IOSStreamService = class IOSStreamService {
400
474
  (error.response && error.response.status >= 500);
401
475
  if (isTransientError && attempt < maxRetries) {
402
476
  const backoffMs = Math.min(500 * Math.pow(2, attempt), 2000); // 500ms, 1000ms, 2000ms max
403
- logger_1.default.debug(`[WDA] Port ${wdaPort} transient error (${error.code || ((_g = error.response) === null || _g === void 0 ? void 0 : _g.status)}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`);
477
+ logger_1.default.debug(`[WDA] Port ${wdaPort} transient error (${error.code || ((_d = error.response) === null || _d === void 0 ? void 0 : _d.status)}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`);
404
478
  yield new Promise((resolve) => setTimeout(resolve, backoffMs));
405
479
  continue;
406
480
  }
@@ -443,7 +517,7 @@ let IOSStreamService = class IOSStreamService {
443
517
  }
444
518
  if (!isWdaIproxyAlive || !isMjpegIproxyAlive) {
445
519
  logger_1.default.warn(`🛡️ [${udid}] [Watchdog] Tunnel processes are dead. Attempting tunnel-only recovery...`);
446
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
520
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
447
521
  if (device && device.ip) {
448
522
  // Double check if WDA is alive via network IP
449
523
  const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
@@ -459,7 +533,7 @@ let IOSStreamService = class IOSStreamService {
459
533
  const isRespondingViaTunnel = yield this.isWDARunning(session.wdaPort);
460
534
  if (!isRespondingViaTunnel) {
461
535
  logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA tunnel on port ${session.wdaPort} is unresponsive. checking network...`);
462
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
536
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
463
537
  if (device && device.ip) {
464
538
  const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
465
539
  if (isWdaAccessibleViaNetwork) {
@@ -543,6 +617,8 @@ let IOSStreamService = class IOSStreamService {
543
617
  logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
544
618
  return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
545
619
  }
620
+ // Never under a live Appium session (see refuseRestartUnderAppiumSession).
621
+ yield this.refuseRestartUnderAppiumSession(udid);
546
622
  // Stream exists but unhealthy - check cooldown before recovery
547
623
  if (!this.canAttemptRecovery(udid)) {
548
624
  const lastAttempt = this.recoveryCooldowns.get(udid);
@@ -578,6 +654,7 @@ let IOSStreamService = class IOSStreamService {
578
654
  logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
579
655
  return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
580
656
  }
657
+ yield this.refuseRestartUnderAppiumSession(udid);
581
658
  // Stream exists but unhealthy - check cooldown before recovery
582
659
  if (!this.canAttemptRecovery(udid)) {
583
660
  const lastAttempt = this.recoveryCooldowns.get(udid);
@@ -588,10 +665,17 @@ let IOSStreamService = class IOSStreamService {
588
665
  logger_1.default.info(`Existing session for ${udid} not responding, restarting...`);
589
666
  this.markRecoveryAttempt(udid);
590
667
  }
591
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
668
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
592
669
  if (!device)
593
670
  throw new Error(`Device ${udid} not found`);
594
- // Always resolve stream ports through the PortAllocator — never a value
671
+ // Tear down this udid's previous session BEFORE acquiring ports, never
672
+ // after. acquire() hands a udid back its own existing leases, so a
673
+ // failed start's session names the very port numbers this start is
674
+ // about to get. Stopped after acquiring, its release() deleted this
675
+ // start's leases: the stream ran on unleased ports, and the allocator
676
+ // gave its 9100 to the S9+. A no-op when there is no previous session.
677
+ yield this.stopStream(udid);
678
+ // Resolve stream ports through the PortAllocator — never a value
595
679
  // persisted on the Device row. A stale persisted mjpegServerPort (e.g.
596
680
  // 9100) bypassed the allocator and could collide with another device's
597
681
  // live lease on the same port (the Android stream leases 9100 too),
@@ -599,13 +683,29 @@ let IOSStreamService = class IOSStreamService {
599
683
  // guarantee a non-colliding port; the lease is refreshed by the watchdog
600
684
  // and released in stopStream().
601
685
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
602
- const wdaPort = yield portAllocator.acquire('wda', udid, { ttlMs: this.STREAM_PORT_TTL_MS });
603
- const mjpegPort = yield portAllocator.acquire('mjpeg', udid, {
604
- ttlMs: this.STREAM_PORT_TTL_MS,
605
- });
606
- // Principal Discovery: Check if WDA is already up (e.g. from an active Appium session)
607
- // If yes, and it belongs to our device, we simply attach to it instead of killing it.
608
- const alreadyUp = yield this.isWDARunning(wdaPort, udid);
686
+ const ttl = { ttlMs: this.STREAM_PORT_TTL_MS };
687
+ // Principal Discovery: attach to the WDA an Appium session runs on this
688
+ // device instead of launching a second one over it. The one exception
689
+ // to the rule above: that session's forwarder listens on the Device
690
+ // row's wdaLocalPort (iOSCapabilities hands it to Appium), and acquire()
691
+ // can't find it, because it refuses any port with a live listener, this
692
+ // device's own included. Only while an Appium session holds the device:
693
+ // WDA names no phone (see isWDARunning), so that session is the only
694
+ // evidence the port is this phone's. Outside one, an answer on a stale
695
+ // row's port may be another phone's WDA.
696
+ const appiumWdaPort = heldByAppiumSession(device) ? device.wdaLocalPort : undefined;
697
+ const alreadyUp = !!appiumWdaPort && (yield this.isWDARunning(appiumWdaPort));
698
+ let wdaPort;
699
+ if (appiumWdaPort && alreadyUp) {
700
+ wdaPort = appiumWdaPort;
701
+ if (!(yield portAllocator.claim('wda', udid, wdaPort, ttl))) {
702
+ logger_1.default.warn(`[${udid}] WDA port ${wdaPort} is leased to another device; attaching unleased`);
703
+ }
704
+ }
705
+ else {
706
+ wdaPort = yield portAllocator.acquire('wda', udid, ttl);
707
+ }
708
+ const mjpegPort = yield portAllocator.acquire('mjpeg', udid, ttl);
609
709
  if (alreadyUp) {
610
710
  logger_1.default.info(`[${udid}] WDA already responding on port ${wdaPort}. Attaching to existing tunnel...`);
611
711
  // WDA reachable on 127.0.0.1:wdaPort means the WDA iproxy is live, but the
@@ -653,7 +753,7 @@ let IOSStreamService = class IOSStreamService {
653
753
  return { wdaPort, mjpegPort };
654
754
  }
655
755
  // Perform aggressive cleanup of any existing processes for THIS device/ports
656
- yield this.stopStream(udid);
756
+ // (the previous session was already stopped, before the ports were acquired)
657
757
  yield this.killStaleProcesses(udid, wdaPort, mjpegPort);
658
758
  const session = {
659
759
  udid,
@@ -788,7 +888,7 @@ let IOSStreamService = class IOSStreamService {
788
888
  logger_1.default.warn(`Failed to get screen size for ${udid}: ${e}`);
789
889
  }
790
890
  // Update device info in store
791
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
891
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
792
892
  if (device) {
793
893
  const updateData = {
794
894
  wdaLocalPort: wdaPort,
@@ -836,6 +936,9 @@ let IOSStreamService = class IOSStreamService {
836
936
  throw new Error(`WDA failed to start within ${timeout / 1000}s. Check logs.`);
837
937
  }
838
938
  catch (error) {
939
+ // Not a failed start: the running stream is left exactly as it was.
940
+ if (error instanceof StreamRestartRefused)
941
+ throw error;
839
942
  const session = this.sessions.get(udid);
840
943
  if (session) {
841
944
  session.status = 'error';
@@ -908,12 +1011,29 @@ let IOSStreamService = class IOSStreamService {
908
1011
  yield new Promise((resolve) => setTimeout(resolve, 500));
909
1012
  });
910
1013
  }
911
- stopStream(udid) {
912
- return __awaiter(this, void 0, void 0, function* () {
1014
+ /**
1015
+ * Stop this udid's stream: its processes, a live-preview hold, the go-ios
1016
+ * orphan sweep (see cleanupOrphanTunnels) and its port leases.
1017
+ *
1018
+ * `forViewer` means the caller is only done watching (stream/stop,
1019
+ * stream/leave). While an Appium session holds the phone and this stream
1020
+ * launched its own WDA, nothing is stopped: a session allocated while the
1021
+ * stream ran drives that WDA, through this stream's forwarder and go-ios
1022
+ * tunnel. The session's teardown stops it (EventManager
1023
+ * stopIdleStreamForDevice), or the idle watchdog once the session has
1024
+ * ended. A restart never passes it: the WDA already failed its health check.
1025
+ */
1026
+ stopStream(udid_1) {
1027
+ return __awaiter(this, arguments, void 0, function* (udid, opts = {}) {
913
1028
  var _a, _b;
914
1029
  const session = this.sessions.get(udid);
915
1030
  if (!session)
916
1031
  return;
1032
+ if (opts.forViewer &&
1033
+ session.wdaProcess &&
1034
+ (yield this.appiumSessionMayUse(udid, "the stream's WDA and go-ios tunnel"))) {
1035
+ return;
1036
+ }
917
1037
  // Kill sidecar processes. The go-ios tunnel is detached (its own process
918
1038
  // group), so reap the whole group — a plain p.kill() would leave the
919
1039
  // self-forking agent children behind to respawn. See ./tunnelProcess.
@@ -931,7 +1051,7 @@ let IOSStreamService = class IOSStreamService {
931
1051
  // The lock could belong to an Appium automation session (session_id is a real UUID).
932
1052
  // We should only unblock if it's a manual control lock (session_id starts with 'manual_').
933
1053
  try {
934
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
1054
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
935
1055
  if (device && ((_b = device.session_id) === null || _b === void 0 ? void 0 : _b.startsWith('manual_'))) {
936
1056
  logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
937
1057
  yield (0, device_service_1.unblockDevice)(udid, device.host);
@@ -949,8 +1069,8 @@ let IOSStreamService = class IOSStreamService {
949
1069
  // devices — they were held for the lifetime of this stream.
950
1070
  try {
951
1071
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
952
- yield portAllocator.release(session.wdaPort);
953
- yield portAllocator.release(session.mjpegPort);
1072
+ yield portAllocator.release(session.wdaPort, udid);
1073
+ yield portAllocator.release(session.mjpegPort, udid);
954
1074
  }
955
1075
  catch (e) {
956
1076
  logger_1.default.warn(`Failed to release port leases for ${udid}: ${e}`);