@xenon-device-management/xenon 2.7.0 → 2.8.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 (86) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-oISPBknJ.js → AnnotationOverlay-XmDGA9CJ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BOm-SKAJ.js → ApiKeyGate-BilRCzB-.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-C1WMf_3A.js → BugReportButton-_buEV98z.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-BPJ5n5fY.js → DeviceMosaicView-DTEiC4gk.js} +1 -1
  6. package/lib/public/assets/{EmptyState-CZg80GnN.js → EmptyState-BhQfPUga.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-D7zftz-q.js → FieldGroup-VPsU3LwN.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BKHKlX-8.js → FilterMenu-Br9o7dBS.js} +1 -1
  9. package/lib/public/assets/{Menu-Ccznqijj.js → Menu-dF_HlxcB.js} +1 -1
  10. package/lib/public/assets/{Modal-FxdOVK12.js → Modal-51305CEc.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BL8B1IKY.js → RecordingPage-BYQfiIEd.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CCCL2xwa.js → RecordingsPage-C7A2ZMU_.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-B3EMys8L.js → SegmentedControl--_Zp_x-E.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BwvdX-C6.js → SettingCard-DKlLEbu9.js} +1 -1
  15. package/lib/public/assets/{Table-sGNs22pM.js → Table-mknFnLyL.js} +1 -1
  16. package/lib/public/assets/{activity-rQQOy6SD.js → activity-D4657Amj.js} +1 -1
  17. package/lib/public/assets/{ai-settings-uIrII3el.js → ai-settings-_sXFJMDz.js} +1 -1
  18. package/lib/public/assets/{api-keys-B1-QmrZJ.js → api-keys-BV7AQW2N.js} +1 -1
  19. package/lib/public/assets/{apps-B8gYjYhN.js → apps-D-qPk58H.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CLT_rU6o.js → arrow-left-B6xZI-lv.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DqK8eHkB.js → arrow-right-KOusXQT_.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-D96XxKQY.js → arrow-up-right--y5SjylA.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DpBFmfin.js → auth-shell-BOGfjiRi.js} +2 -2
  24. package/lib/public/assets/{builds-page-DqD7ySkz.js → builds-page-CgobTBVN.js} +1 -1
  25. package/lib/public/assets/{button-BrVVNvSB.js → button-Ci58y6Ou.js} +1 -1
  26. package/lib/public/assets/{calendar-FX_no0De.js → calendar-BYLnN-JG.js} +1 -1
  27. package/lib/public/assets/{check-C6RAxVq9.js → check-BZFRUKhB.js} +1 -1
  28. package/lib/public/assets/{chevron-left-CcTJUfpS.js → chevron-left-C-olFL5E.js} +1 -1
  29. package/lib/public/assets/{chevron-right-C39WUDQP.js → chevron-right-CmxzLyEE.js} +1 -1
  30. package/lib/public/assets/{circle-check-Fo1eEzZe.js → circle-check-Duvlh9lf.js} +1 -1
  31. package/lib/public/assets/{circle-x-CXy5XzIu.js → circle-x-DoQgpxKQ.js} +1 -1
  32. package/lib/public/assets/{clock-PBGPHMrI.js → clock-yvaEQMYo.js} +1 -1
  33. package/lib/public/assets/{copy-ZGa-scbM.js → copy-CIcmMH4s.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BRYO7iUM.js → device-explorer-Cf-d-udL.js} +1 -1
  35. package/lib/public/assets/{download-vaODl7Bn.js → download-Ck75G4Sr.js} +1 -1
  36. package/lib/public/assets/{forgot-password-YfGbiIOM.js → forgot-password-DcOi08XK.js} +1 -1
  37. package/lib/public/assets/{funnel-C05BIqke.js → funnel-CafvAmsz.js} +1 -1
  38. package/lib/public/assets/{index-DdsuniRs.js → index-9Npot8Nw.js} +2 -2
  39. package/lib/public/assets/{index-1ST_YG3o.js → index-QT33_ARg.js} +1 -1
  40. package/lib/public/assets/{input-C6Zp2Ibb.js → input-DUA3Tk33.js} +1 -1
  41. package/lib/public/assets/{keycodes-BlkuYXfQ.js → keycodes-Dj17elyp.js} +1 -1
  42. package/lib/public/assets/{layers-B-3IDp3k.js → layers-5SyoCGaK.js} +1 -1
  43. package/lib/public/assets/{list-checks-BUUYV0Vf.js → list-checks-Blsk6Sts.js} +1 -1
  44. package/lib/public/assets/{lock-BUdNqIao.js → lock-DUyg9vcf.js} +1 -1
  45. package/lib/public/assets/{login-CD8bag9i.js → login-DWRTaaNU.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-BWQP_nfK.js → maintenance-settings-CwthiKtl.js} +1 -1
  47. package/lib/public/assets/{monitor-qfb5f9oQ.js → monitor-D7kOVM-I.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DAAlUY2l.js → mouse-pointer-2-BzwITYs-.js} +1 -1
  49. package/lib/public/assets/{network-Qib4-4Yj.js → network-3c26VFLV.js} +1 -1
  50. package/lib/public/assets/{overview-BZQf20qe.js → overview-BamopQBx.js} +1 -1
  51. package/lib/public/assets/{page-header-4jNxdU1M.js → page-header-Cr568Bj2.js} +1 -1
  52. package/lib/public/assets/{plus-DVhPNSQy.js → plus-egFVGGUW.js} +1 -1
  53. package/lib/public/assets/{profile-page-DfITRDDC.js → profile-page-BetCnNKO.js} +1 -1
  54. package/lib/public/assets/{reset-password-s2xU05oa.js → reset-password-BgjuXPN-.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DdEiXQcm.js → runbook-page-BbAREoE4.js} +1 -1
  56. package/lib/public/assets/{select-Bzs3ZUvb.js → select-BfZw_3u2.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-QvRRVchI.js → selector-detail-page-BqswNrpr.js} +1 -1
  58. package/lib/public/assets/{selector-health-DwUFxrZx.js → selector-health-Cbdio_9h.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-N0tA9q9c.js → selector-health-page-qdM7e6Lx.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-DcGGXXxL.js → session-detail-page-DFnhbs82.js} +1 -1
  61. package/lib/public/assets/{settings-BUqAjzeY.js → settings-Bg81ksOI.js} +1 -1
  62. package/lib/public/assets/{stat-tile-BwZr7q7_.js → stat-tile-CCR1O7iE.js} +1 -1
  63. package/lib/public/assets/{tablet-DiOf-kLM.js → tablet-DAHOR-iq.js} +1 -1
  64. package/lib/public/assets/{teams-DA6AfWGO.js → teams-Bz13DBCK.js} +1 -1
  65. package/lib/public/assets/{trash-2-BZuuog-n.js → trash-2-BgqAVxUr.js} +1 -1
  66. package/lib/public/assets/{upload-DLHV2k4Z.js → upload-Rvl2CCKn.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DRY4lLdd.js → use-password-reset-mode--PRWElm2.js} +1 -1
  68. package/lib/public/assets/{users-BlhcXeTG.js → users-BRkxjzNQ.js} +1 -1
  69. package/lib/public/assets/{users-Bd4LKBWY.js → users-O123SAYx.js} +1 -1
  70. package/lib/public/assets/{video-off-DIaDZGNl.js → video-off-DkNxxAWE.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-IWvqbRk2.js → webhook-settings-D6UmShyI.js} +1 -1
  72. package/lib/public/assets/{zap-4Qy4hVbF.js → zap-D6UAiMKL.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/device-managers/ios/IOSLogStreamService.js +6 -3
  75. package/lib/src/device-managers/ios/IOSStreamService.js +38 -122
  76. package/lib/src/device-managers/ios/IOSTunnels.js +376 -0
  77. package/lib/src/device-managers/ios/WDAClient.js +5 -2
  78. package/lib/src/services/PortAllocator.js +65 -0
  79. package/lib/test/unit/ios-go-ios-tunnel-env.spec.js +154 -0
  80. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +122 -32
  81. package/lib/test/unit/ios-stream-port-leases.spec.js +26 -5
  82. package/lib/test/unit/ios-tunnels.spec.js +387 -0
  83. package/lib/test/unit/port-allocator-pairs.spec.js +110 -0
  84. package/lib/test/unit/stream-own-device-row.spec.js +1 -1
  85. package/lib/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
@@ -67,6 +67,7 @@ const singleFlight_1 = require("../../helpers/singleFlight");
67
67
  const LogcatMultiplexer_1 = require("../android/LogcatMultiplexer");
68
68
  const ostraceParse_1 = require("../../services/logcat/ostraceParse");
69
69
  const LogcatStreamService_1 = require("../android/LogcatStreamService");
70
+ const IOSTunnels_1 = require("./IOSTunnels");
70
71
  /**
71
72
  * Which slice of os_trace to stream. These are pushed down to `go-ios ostrace`
72
73
  * rather than applied after the fact, because the unfiltered firehose cannot be
@@ -318,7 +319,9 @@ let IOSLogStreamService = class IOSLogStreamService {
318
319
  // measured against an iPhone 14, every value tried — bare name, full path,
319
320
  // altered case — returned 0 lines, while `--level` alone returned 48,195
320
321
  // and `--pid` returned 58,960 for that same process.
321
- const proc = this.spawnProcess(goIOS, args);
322
+ // On iOS 17+ ostrace reaches the phone through the phone's own go-ios
323
+ // tunnel, found through GO_IOS_AGENT_PORT (see IOSTunnels).
324
+ const proc = this.spawnProcess(goIOS, args, typedi_1.Container.get(IOSTunnels_1.IOSTunnels).envFor(udid));
322
325
  // A ChildProcess emitting 'error' with no listener crashes the process.
323
326
  proc.on('error', (e) => logger_1.default.warn(`[${udid}] ostrace process error: ${e.message}`));
324
327
  (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (d) => logger_1.default.debug(`[${udid}] ostrace: ${d.toString().trim()}`));
@@ -337,8 +340,8 @@ let IOSLogStreamService = class IOSLogStreamService {
337
340
  return typedi_1.Container.get(IOSStreamService).goIOSPath;
338
341
  });
339
342
  }
340
- spawnProcess(command, args) {
341
- return (0, child_process_1.spawn)(command, args);
343
+ spawnProcess(command, args, env) {
344
+ return (0, child_process_1.spawn)(command, args, { env });
342
345
  }
343
346
  /**
344
347
  * Executable → bundle id for the installed apps.
@@ -81,6 +81,7 @@ const device_store_1 = require("../../data-service/device-store");
81
81
  const ownDeviceRow_1 = require("../ownDeviceRow");
82
82
  const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
83
83
  const tunnelProcess_1 = require("./tunnelProcess");
84
+ const IOSTunnels_1 = require("./IOSTunnels");
84
85
  const device_service_1 = require("../../data-service/device-service");
85
86
  const manualLock_1 = require("../../services/recording/manualLock");
86
87
  const recording_store_1 = require("../../services/recording/recording-store");
@@ -108,13 +109,6 @@ function shouldStopIdleIosStream(use) {
108
109
  function heldByAppiumSession(device) {
109
110
  return !!(device === null || device === void 0 ? void 0 : device.busy) && !!device.session_id && !(0, manualLock_1.isManualLock)(device.session_id);
110
111
  }
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
112
  /** A start refused a restart because an Appium session holds the phone. */
119
113
  class StreamRestartRefused extends Error {
120
114
  }
@@ -151,6 +145,7 @@ let IOSStreamService = class IOSStreamService {
151
145
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
152
146
  yield portAllocator.touch(session.wdaPort, this.STREAM_PORT_TTL_MS);
153
147
  yield portAllocator.touch(session.mjpegPort, this.STREAM_PORT_TTL_MS);
148
+ yield this.tunnels().touch(udid);
154
149
  }
155
150
  catch (_a) {
156
151
  /* best-effort lease refresh */
@@ -270,128 +265,38 @@ let IOSStreamService = class IOSStreamService {
270
265
  });
271
266
  }
272
267
  /**
273
- * Check if tunnel is needed (iOS 17+) and ensure it's running
274
- */
275
- ensureTunnel(udid) {
276
- return __awaiter(this, void 0, void 0, function* () {
277
- var _a, _b;
278
- try {
279
- const { stdout } = yield execPromise(`"${this.goIOSPath}" info --udid ${udid}`, {
280
- env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
281
- });
282
- const info = JSON.parse(stdout);
283
- const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
284
- // Extract the first numeric sequence (e.g., "15.8.6" or "iOS 17.2")
285
- const versionMatch = versionStr.match(/(\d+\.?\d*)/);
286
- const version = versionMatch ? parseFloat(versionMatch[0]) : 0;
287
- if (version >= 17) {
288
- logger_1.default.info(`iOS ${version} detected for ${udid} (Raw: "${versionStr}"). Starting tunnel...`);
289
- // Check if already running in our session tracker
290
- const existing = [...this.sessions.values()].find((s) => s.udid === udid && s.tunnelProcess && s.tunnelProcess.exitCode === null);
291
- if (existing && existing.tunnelProcess)
292
- return existing.tunnelProcess;
293
- // CRITICAL: Kill any orphan tunnel processes before starting new one
294
- // This prevents 'address already in use' errors from stale processes
295
- yield this.cleanupOrphanTunnels(udid);
296
- const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
297
- const { command, args } = isolationService.wrapSpawn(this.goIOSPath, ['tunnel', 'start', '--udid', udid, '--userspace'], 'Performance');
298
- // detached: the go-ios tunnel leads its own process group so its
299
- // self-forking agent children are reaped as a group on cleanup instead
300
- // of orphaning into a runaway respawn storm. See ./tunnelProcess.
301
- const tunnelProcess = (0, child_process_1.spawn)(command, args, (0, tunnelProcess_1.tunnelSpawnOptions)(Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' })));
302
- typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: tunnelProcess });
303
- (_a = tunnelProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => logger_1.default.debug(`Tunnel [${udid}]: ${data}`));
304
- // go-ios repeatedly warns about any connected pre-iOS-17 device (which
305
- // needs no tunnel). Log that once — attributed to the real target udid,
306
- // not this tunnel's owner — and suppress the rest to avoid log spam.
307
- const loggedUnsupported = new Set();
308
- (_b = tunnelProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => {
309
- const text = String(data);
310
- const { unsupported, udid: targetUdid } = (0, iosStreamDiagnostics_1.classifyTunnelStderr)(text);
311
- if (unsupported) {
312
- const key = targetUdid !== null && targetUdid !== void 0 ? targetUdid : 'unknown';
313
- if (!loggedUnsupported.has(key)) {
314
- loggedUnsupported.add(key);
315
- logger_1.default.debug(`Tunnel [${udid}]: device ${key} is pre-iOS 17 and needs no tunnel; suppressing repeated go-ios warnings`);
316
- }
317
- return;
318
- }
319
- logger_1.default.debug(`Tunnel Err [${udid}]: ${text}`);
320
- });
321
- // Wait for tunnel to establish by checking the go-ios agent port
322
- logger_1.default.info('Waiting for tunnel agent on port 60105 to be ready...');
323
- let tunnelReady = false;
324
- const tunnelTimeout = 15000;
325
- const subStartTime = Date.now();
326
- while (Date.now() - subStartTime < tunnelTimeout) {
327
- try {
328
- tunnelReady = yield tcp_port_used_1.default.check(60105, '127.0.0.1');
329
- if (tunnelReady)
330
- break;
331
- }
332
- catch (e) {
333
- /* ignore */
334
- }
335
- yield new Promise((resolve) => setTimeout(resolve, 1000));
336
- }
337
- if (!tunnelReady) {
338
- logger_1.default.warn(`Tunnel agent port 60105 not ready after ${tunnelTimeout / 1000}s, proceeding anyway...`);
339
- }
340
- else {
341
- logger_1.default.info('Tunnel agent is ready. Settling for 2s...');
342
- yield new Promise((resolve) => setTimeout(resolve, 2000));
343
- logger_1.default.info('Tunnel agent settled.');
344
- }
345
- return tunnelProcess;
346
- }
347
- }
348
- catch (error) {
349
- logger_1.default.warn(`Failed to check version or start tunnel for ${udid}: ${error}`);
350
- }
351
- return null;
352
- });
353
- }
354
- /**
355
- * Kill go-ios tunnel processes left over from earlier runs, so a new tunnel
356
- * can bind the agent's ports ('address already in use' otherwise).
268
+ * Clear this phone's go-ios leftovers before a start: the tunnel IOSTunnels
269
+ * tracks for it, then any untracked go-ios process for it from an earlier
270
+ * run. Never another phone's. Each phone's tunnel has its own ports, so
271
+ * nothing is killed by port. Through 2.7 this also kill -9'd whatever
272
+ * listened on go-ios's default ports, 60105 and 60106, which was another
273
+ * iPhone's live tunnel.
357
274
  *
358
275
  * Never while an Appium session holds the phone. That session may be
359
276
  * driving a WDA go-ios launched (iOSCapabilities points a session at the
360
277
  * stream's WDA whenever a stream runs), and on iOS 17+ that WDA reaches the
361
- * phone through the tunnel listening on these ports. Nothing here can tell
362
- * that tunnel from an orphan, so the sweep waits for the next stop or start
363
- * after the session ends; a restart reaps every go-ios process at boot.
278
+ * phone through the phone's tunnel. Nothing here can tell that tunnel from
279
+ * an orphan, so the sweep waits for the next stop or start after the
280
+ * session ends; a restart reaps every go-ios process at boot.
364
281
  */
365
282
  cleanupOrphanTunnels(udid) {
366
283
  return __awaiter(this, void 0, void 0, function* () {
367
284
  if (yield this.appiumSessionMayUse(udid, 'go-ios tunnels'))
368
285
  return;
369
286
  logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
287
+ yield this.tunnels().stop(udid);
370
288
  // Reap the tunnel process *group* for this udid so the self-forking go-ios
371
289
  // agent children (whose argv carries no udid, so a udid-scoped pkill can't
372
290
  // see them) die with their parent instead of orphaning. Group-scoped, so a
373
291
  // second device's tunnel is left untouched. See ./tunnelProcess.
374
292
  yield (0, tunnelProcess_1.reapTunnelsForUdid)(udid, execPromise);
375
- // Whatever still listens on the go-ios agent's ports.
376
- for (const port of GO_IOS_AGENT_PORTS) {
377
- try {
378
- const { stdout } = yield execPromise(`lsof -ti :${port}`);
379
- const pids = stdout.trim().split('\n');
380
- for (const pid of pids) {
381
- if (pid) {
382
- logger_1.default.debug(`Killing orphan process ${pid} on go-ios agent port ${port}`);
383
- yield execPromise(`kill -9 ${pid}`);
384
- }
385
- }
386
- }
387
- catch (err) {
388
- /* ignore - lsof returns 1 if no port found */
389
- }
390
- }
391
293
  // Small delay to ensure OS releases sockets
392
294
  yield new Promise((resolve) => setTimeout(resolve, 500));
393
295
  });
394
296
  }
297
+ tunnels() {
298
+ return typedi_2.Container.get(IOSTunnels_1.IOSTunnels);
299
+ }
395
300
  /**
396
301
  * Whether an Appium session may be using what the caller is about to kill:
397
302
  * one holds the phone, or the phone's row can't be read, in which case
@@ -741,7 +646,7 @@ let IOSStreamService = class IOSStreamService {
741
646
  wdaProcess: null,
742
647
  forwardWDAProcess: null,
743
648
  forwardMJPEGProcess,
744
- tunnelProcess: null,
649
+ tunnelPort: null,
745
650
  wdaPort,
746
651
  mjpegPort,
747
652
  status: 'running',
@@ -762,7 +667,7 @@ let IOSStreamService = class IOSStreamService {
762
667
  wdaProcess: null,
763
668
  forwardWDAProcess: null,
764
669
  forwardMJPEGProcess: null,
765
- tunnelProcess: null,
670
+ tunnelPort: null,
766
671
  wdaPort,
767
672
  mjpegPort,
768
673
  status: 'starting',
@@ -794,8 +699,8 @@ let IOSStreamService = class IOSStreamService {
794
699
  logger_1.default.warn(`📱 Artisan WDA: Failed to provision WDA to ${udid}: ${e}`);
795
700
  }
796
701
  }
797
- // 2. Ensure tunnel for iOS 17+
798
- session.tunnelProcess = yield this.ensureTunnel(udid);
702
+ // 2. The phone's own go-ios tunnel, for iOS 17+ (see IOSTunnels)
703
+ session.tunnelPort = yield this.tunnels().ensure(udid);
799
704
  // 2. Start Port Forwarding using iproxy (more reliable on Mac)
800
705
  logger_1.default.info(`Forwarding ${udid}: ${wdaPort}->8100, ${mjpegPort}->9100 using iproxy`);
801
706
  const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
@@ -830,7 +735,9 @@ let IOSStreamService = class IOSStreamService {
830
735
  udid,
831
736
  ], 'Performance'); // WDA deserves Performance mode
832
737
  session.wdaProcess = (0, child_process_1.spawn)(wdaSpawn.command, wdaSpawn.args, {
833
- env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
738
+ // GO_IOS_AGENT_PORT: runwda reaches this phone through its own
739
+ // tunnel, not go-ios's default 60105.
740
+ env: this.tunnels().envFor(udid),
834
741
  });
835
742
  typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'wda', udid, process: session.wdaProcess });
836
743
  const logDir = path_1.default.join(os_1.default.tmpdir(), 'xenon-logs');
@@ -1027,7 +934,7 @@ let IOSStreamService = class IOSStreamService {
1027
934
  */
1028
935
  stopStream(udid_1) {
1029
936
  return __awaiter(this, arguments, void 0, function* (udid, opts = {}) {
1030
- var _a, _b;
937
+ var _a;
1031
938
  const session = this.sessions.get(udid);
1032
939
  if (!session)
1033
940
  return;
@@ -1036,9 +943,7 @@ let IOSStreamService = class IOSStreamService {
1036
943
  (yield this.appiumSessionMayUse(udid, "the stream's WDA and go-ios tunnel"))) {
1037
944
  return;
1038
945
  }
1039
- // Kill sidecar processes. The go-ios tunnel is detached (its own process
1040
- // group), so reap the whole group — a plain p.kill() would leave the
1041
- // self-forking agent children behind to respawn. See ./tunnelProcess.
946
+ // Kill sidecar processes.
1042
947
  [session.wdaProcess, session.forwardWDAProcess, session.forwardMJPEGProcess].forEach((p) => {
1043
948
  if (p)
1044
949
  try {
@@ -1048,13 +953,16 @@ let IOSStreamService = class IOSStreamService {
1048
953
  // ignore
1049
954
  }
1050
955
  });
1051
- (0, tunnelProcess_1.killProcessGroup)((_a = session.tunnelProcess) === null || _a === void 0 ? void 0 : _a.pid);
956
+ // This stream's go-ios tunnel: its whole process group and its port pair.
957
+ // A stream attached to an Appium session's WDA started none.
958
+ if (session.tunnelPort != null)
959
+ yield this.tunnels().stop(udid);
1052
960
  // Principal Fix: Only release the device lock if THIS STREAM SERVICE owns it.
1053
961
  // The lock could belong to an Appium automation session (session_id is a real UUID).
1054
962
  // We should only unblock if it's a manual control lock (session_id starts with 'manual_').
1055
963
  try {
1056
964
  const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
1057
- if (device && ((_b = device.session_id) === null || _b === void 0 ? void 0 : _b.startsWith('manual_'))) {
965
+ if (device && ((_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_'))) {
1058
966
  logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
1059
967
  yield (0, device_service_1.unblockDevice)(udid, device.host);
1060
968
  }
@@ -1185,7 +1093,7 @@ let IOSStreamService = class IOSStreamService {
1185
1093
  */
1186
1094
  reapOrphanTunnels() {
1187
1095
  return __awaiter(this, void 0, void 0, function* () {
1188
- var _a;
1096
+ var _a, _b;
1189
1097
  try {
1190
1098
  const reaped = yield (0, tunnelProcess_1.reapAllOrphanTunnels)(this.goIOSPath, execPromise);
1191
1099
  if (reaped > 0) {
@@ -1195,6 +1103,14 @@ let IOSStreamService = class IOSStreamService {
1195
1103
  catch (err) {
1196
1104
  logger_1.default.warn(`[IOSStreamService] Orphan tunnel reap failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
1197
1105
  }
1106
+ // Every go-ios process is gone, so no tunnel holds its ports. A lease left
1107
+ // by an earlier run would otherwise keep its pair for up to 1.5 hours.
1108
+ try {
1109
+ yield typedi_2.Container.get(PortAllocator_1.PortAllocator).releasePurpose('tunnel');
1110
+ }
1111
+ catch (err) {
1112
+ logger_1.default.warn(`[IOSStreamService] Releasing tunnel port leases failed: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
1113
+ }
1198
1114
  });
1199
1115
  }
1200
1116
  /**