@mentra/engine 3.2.0-dev.226 → 3.2.0-dev.232

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 (34) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/AcsMeetingService.d.ts +15 -7
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +32 -9
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/GlassesHotspotLease.d.ts +2 -0
  8. package/build/services/GlassesHotspotLease.d.ts.map +1 -0
  9. package/build/services/GlassesHotspotLease.js +13 -0
  10. package/build/services/GlassesHotspotLease.js.map +1 -0
  11. package/build/services/LocalMiniappRuntime.d.ts +2 -0
  12. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  13. package/build/services/LocalMiniappRuntime.js +35 -11
  14. package/build/services/LocalMiniappRuntime.js.map +1 -1
  15. package/build/services/ManagedWebRtcRelay.d.ts +87 -0
  16. package/build/services/ManagedWebRtcRelay.d.ts.map +1 -0
  17. package/build/services/ManagedWebRtcRelay.js +239 -0
  18. package/build/services/ManagedWebRtcRelay.js.map +1 -0
  19. package/build/services/PhoneStreamCoordinator.d.ts +5 -0
  20. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  21. package/build/services/PhoneStreamCoordinator.js +131 -68
  22. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  23. package/build/services/SoftapCallTransport.d.ts +5 -1
  24. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  25. package/build/services/SoftapCallTransport.js +23 -5
  26. package/build/services/SoftapCallTransport.js.map +1 -1
  27. package/package.json +8 -7
  28. package/src/generated/releaseMetadata.ts +5 -5
  29. package/src/services/AcsMeetingService.ts +38 -9
  30. package/src/services/GlassesHotspotLease.ts +11 -0
  31. package/src/services/LocalMiniappRuntime.ts +47 -18
  32. package/src/services/ManagedWebRtcRelay.ts +278 -0
  33. package/src/services/PhoneStreamCoordinator.ts +152 -70
  34. package/src/services/SoftapCallTransport.ts +28 -7
@@ -35,10 +35,12 @@
35
35
 
36
36
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
37
37
  import type {StreamResolvedConfig, StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
38
+ import {createManagedWebRtcRelay, type ManagedRelay} from "./ManagedWebRtcRelay"
38
39
  import {isGlassesConnected} from "./GlassesReadiness"
39
40
  import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
40
41
  import {useGlassesStore} from "../stores/glasses"
41
42
 
43
+ import {BgTimer} from "../utils/timers"
42
44
  import {slimStreamStatusEvent, streamStatusSignature} from "./slimStreamStatus"
43
45
  import {
44
46
  getManagedStreamStatus,
@@ -176,13 +178,15 @@ interface ManagedEntry {
176
178
  hlsUrl: string
177
179
  dashUrl: string
178
180
  webrtcUrl?: string
181
+ stopping?: boolean
182
+ relay?: ManagedRelay
179
183
  publisherStart?: StreamPublisherStartResult
180
184
  subscribers: Set<string>
181
185
  hlsReady: boolean
182
186
  hlsReadyResolvers: Array<(result: ManagedStartResult) => void>
183
187
  hlsReadyRejecters: Array<(err: Error) => void>
184
- cloudflareTimer?: ReturnType<typeof setTimeout>
185
- hlsTimer?: ReturnType<typeof setInterval>
188
+ cloudflareTimer?: ReturnType<typeof BgTimer.setTimeout>
189
+ hlsTimer?: ReturnType<typeof BgTimer.setInterval>
186
190
  hlsAttempts: number
187
191
  /** Cloudflare status probes made during this stream session. */
188
192
  cloudflareAttempts: number
@@ -208,7 +212,7 @@ export class StreamConflictError extends Error {
208
212
 
209
213
  interface SuspendedState {
210
214
  since: number
211
- graceTimer: ReturnType<typeof setTimeout>
215
+ graceTimer: ReturnType<typeof BgTimer.setTimeout>
212
216
  /** Consecutive Cloudflare probes that saw no publisher during this suspension. */
213
217
  publisherGoneProbes: number
214
218
  }
@@ -218,6 +222,7 @@ export class PhoneStreamCoordinator {
218
222
  private statusSubscriber: StatusSubscriber | null = null
219
223
  private idCounter = 0
220
224
  private readonly timings: TimingConfig
225
+ private readonly relayFactory: typeof createManagedWebRtcRelay
221
226
  private readonly linkSource: GlassesLinkSource
222
227
  private readonly pendingCameraChanges: () => Promise<void>
223
228
  private unsubscribeLink: (() => void) | null = null
@@ -228,7 +233,7 @@ export class PhoneStreamCoordinator {
228
233
  * the slot) so a publisher that outlived its input does not keep pushing
229
234
  * until its own watchdog fires.
230
235
  */
231
- private pendingBleStop: {streamId: string} | null = null
236
+ private pendingBleStop: {streamId: string; hotspot?: boolean} | null = null
232
237
  /**
233
238
  * Serializes state transitions (start, stop, teardown). Without it, a
234
239
  * second `start*` racing with the first can pass the `this.current === null`
@@ -245,8 +250,13 @@ export class PhoneStreamCoordinator {
245
250
 
246
251
  constructor(
247
252
  timings: CoordinatorTimings = {},
248
- deps: {linkSource?: GlassesLinkSource; pendingCameraChanges?: () => Promise<void>} = {},
253
+ deps: {
254
+ linkSource?: GlassesLinkSource
255
+ pendingCameraChanges?: () => Promise<void>
256
+ relayFactory?: typeof createManagedWebRtcRelay
257
+ } = {},
249
258
  ) {
259
+ this.relayFactory = deps.relayFactory ?? createManagedWebRtcRelay
250
260
  this.timings = {...DEFAULT_TIMINGS, ...timings}
251
261
  this.linkSource = deps.linkSource ?? storeLinkSource
252
262
  this.pendingCameraChanges = deps.pendingCameraChanges ?? (() => phoneCameraFovCoordinator.whenSettled())
@@ -300,7 +310,10 @@ export class PhoneStreamCoordinator {
300
310
  * check covers the multi-subscriber case.
301
311
  */
302
312
  owns(streamId: string): boolean {
303
- return this.current !== null && this.current.streamId === streamId
313
+ return (
314
+ this.current !== null &&
315
+ (this.current.streamId === streamId || (this.current.kind === "managed" && !!this.current.relay?.owns(streamId)))
316
+ )
304
317
  }
305
318
 
306
319
  /** Report-safe stream ownership snapshot for incident diagnostics. */
@@ -342,6 +355,7 @@ export class PhoneStreamCoordinator {
342
355
  return this.runExclusive(async () => {
343
356
  await cameraReady
344
357
  this.assertGlassesConnected()
358
+ await this.flushPendingBleStop()
345
359
  if (this.current) {
346
360
  throw new StreamConflictError(
347
361
  "STREAM_ALREADY_ACTIVE",
@@ -407,6 +421,7 @@ export class PhoneStreamCoordinator {
407
421
  const decision = await this.runExclusive(async (): Promise<JoinDecision> => {
408
422
  await cameraReady
409
423
  this.assertGlassesConnected()
424
+ await this.flushPendingBleStop()
410
425
  if (this.current && this.current.kind === "unmanaged") {
411
426
  throw new StreamConflictError(
412
427
  "STREAM_ALREADY_ACTIVE",
@@ -417,6 +432,17 @@ export class PhoneStreamCoordinator {
417
432
  // Join an existing managed stream if one is already running.
418
433
  if (this.current && this.current.kind === "managed") {
419
434
  const existing = this.current
435
+ if (existing.stopping)
436
+ throw new StreamConflictError(
437
+ "STREAM_CLEANUP_PENDING",
438
+ "Previous stream cleanup has not completed; retry stop first",
439
+ )
440
+ if (opts.ingest !== undefined && (opts.ingest === "whip") !== (existing.mode === "webrtc")) {
441
+ throw new StreamConflictError(
442
+ "STREAM_MODE_CONFLICT",
443
+ "Stop the existing stream before switching playback modes",
444
+ )
445
+ }
420
446
  // Restream destinations are immutable after provision — a second
421
447
  // caller trying to dictate destinations on an already-live stream
422
448
  // is a likely bug or a feature we don't yet support.
@@ -436,7 +462,13 @@ export class PhoneStreamCoordinator {
436
462
  // and joins instead of double-provisioning.
437
463
  const provision = await provisionManagedStream(opts.restreamDestinations)
438
464
  const streamId = this.mintId("m")
439
- const ingestUrl = pickIngestUrl(provision, opts.ingest)
465
+ let ingestUrl: string
466
+ try {
467
+ ingestUrl = pickIngestUrl(provision, opts.ingest)
468
+ } catch (error) {
469
+ await teardownManagedStream(provision.liveInputId).catch(() => undefined)
470
+ throw error
471
+ }
440
472
  const mode: ManagedEntry["mode"] = ingestUrl === provision.webrtcPublishUrl ? "webrtc" : "hls"
441
473
 
442
474
  const entry: ManagedEntry = {
@@ -466,17 +498,40 @@ export class PhoneStreamCoordinator {
466
498
  })
467
499
 
468
500
  try {
469
- const event = await BluetoothSdk.startStream({
470
- type: "start_stream",
471
- streamUrl: ingestUrl,
472
- streamId,
473
- sound: opts.sound ?? true,
474
- // See startUnmanaged: the native bridge rejects explicit `undefined`.
475
- ...(opts.video !== undefined ? {video: opts.video} : {}),
476
- ...(opts.audio !== undefined ? {audio: opts.audio} : {}),
477
- ...(typeof opts.captureAudio === "boolean" ? {captureAudio: opts.captureAudio} : {}),
478
- })
479
- entry.publisherStart = publisherStartResult(streamId, event)
501
+ if (mode === "webrtc") {
502
+ entry.relay = this.relayFactory(
503
+ {streamId, ingestUrl, ...opts},
504
+ (status, reason) => {
505
+ if (this.current === entry && !entry.stopping)
506
+ this.fanout({streamId, source: "coordinator", status, data: {reason, transport: "softap_relay"}})
507
+ },
508
+ (error) => {
509
+ void this.runExclusive(async () => {
510
+ if (this.current !== entry) return
511
+ this.fanout({streamId, source: "coordinator", status: "error", data: {reason: error.message}})
512
+ await this.teardownLocked("relay_failed")
513
+ }).catch((cleanupError) => console.warn("[STREAM] relay cleanup failed", cleanupError))
514
+ },
515
+ () => this.linkSource.isConnected(),
516
+ () => {
517
+ this.pendingBleStop = {streamId, hotspot: true}
518
+ this.attachLink()
519
+ },
520
+ )
521
+ }
522
+ const event = entry.relay
523
+ ? await entry.relay.start()
524
+ : await BluetoothSdk.startStream({
525
+ type: "start_stream",
526
+ streamUrl: ingestUrl,
527
+ streamId,
528
+ sound: opts.sound ?? true,
529
+ // See startUnmanaged: the native bridge rejects explicit `undefined`.
530
+ ...(opts.video !== undefined ? {video: opts.video} : {}),
531
+ ...(opts.audio !== undefined ? {audio: opts.audio} : {}),
532
+ ...(typeof opts.captureAudio === "boolean" ? {captureAudio: opts.captureAudio} : {}),
533
+ })
534
+ entry.publisherStart = {...publisherStartResult(streamId, event), streamId}
480
535
  console.info("[STREAM_STARTUP]", {
481
536
  streamId,
482
537
  stage: "publisher_ready",
@@ -484,8 +539,14 @@ export class PhoneStreamCoordinator {
484
539
  elapsedMs: Date.now() - startupStartedAtMs,
485
540
  })
486
541
  } catch (err) {
487
- this.current = null
488
- await teardownManagedStream(provision.liveInputId).catch(() => undefined)
542
+ entry.stopping = true
543
+ try {
544
+ await entry.relay?.stop()
545
+ this.current = null
546
+ await this.flushPendingBleStop()
547
+ } finally {
548
+ await teardownManagedStream(provision.liveInputId).catch(() => undefined)
549
+ }
489
550
  throw err
490
551
  }
491
552
 
@@ -500,6 +561,9 @@ export class PhoneStreamCoordinator {
500
561
  return {kind: "fresh", entry}
501
562
  })
502
563
 
564
+ if (this.current !== decision.entry || decision.entry.stopping) {
565
+ throw new Error("Stream stopped before playback readiness")
566
+ }
503
567
  if (decision.kind === "join" && decision.immediate) {
504
568
  return decision.immediate
505
569
  }
@@ -520,6 +584,15 @@ export class PhoneStreamCoordinator {
520
584
  }
521
585
 
522
586
  async stop(packageName: string, streamId?: string): Promise<void> {
587
+ // Cancel in-flight native preparation immediately; cleanup remains serialized below.
588
+ const pending = this.current
589
+ if (
590
+ pending?.kind === "managed" &&
591
+ (!streamId || pending.streamId === streamId) &&
592
+ pending.subscribers.size === 1 &&
593
+ pending.subscribers.has(packageName)
594
+ )
595
+ pending.relay?.cancel()
523
596
  await this.runExclusive(async () => {
524
597
  if (!this.current) return
525
598
 
@@ -549,6 +622,10 @@ export class PhoneStreamCoordinator {
549
622
  */
550
623
  handleGlassesStatus(event: StreamStatusEvent): void {
551
624
  if (!this.current) return
625
+ if (this.current.kind === "managed" && this.current.relay) {
626
+ this.current.relay.handleGlassesStatus(event)
627
+ return
628
+ }
552
629
  if (event.streamId && event.streamId !== this.current.streamId) return
553
630
 
554
631
  const includeResolvedConfig = !this.resolvedConfigForwarded && !!event.resolvedConfig
@@ -579,12 +656,7 @@ export class PhoneStreamCoordinator {
579
656
  if (event.terminal === true || isGiveUp || isStopped) {
580
657
  const reason = isGiveUp ? "glasses_gave_up" : event.status === "error" ? "glasses_error" : "glasses_stopped"
581
658
  const targetStreamId = this.current.streamId
582
- void this.runExclusive(async () => {
583
- // The stream we wanted to tear down may already be gone (e.g. another
584
- // teardown won the lock and unwound it). Guard before acting.
585
- if (this.current?.streamId !== targetStreamId) return
586
- await this.teardownLocked(reason, {sendBleStop: false})
587
- })
659
+ this.requestTeardown(targetStreamId, reason, {sendBleStop: false})
588
660
  }
589
661
  }
590
662
 
@@ -608,7 +680,9 @@ export class PhoneStreamCoordinator {
608
680
  if (this.current && this.suspended) {
609
681
  this.resumeLocked()
610
682
  } else if (!this.current && this.pendingBleStop) {
611
- this.flushPendingBleStop()
683
+ void this.runExclusive(() => this.flushPendingBleStop()).catch((error) =>
684
+ console.warn("[STREAM] deferred cleanup failed", error),
685
+ )
612
686
  }
613
687
  return
614
688
  }
@@ -619,7 +693,7 @@ export class PhoneStreamCoordinator {
619
693
  const entry = this.current
620
694
  if (!entry) return
621
695
  const since = Date.now()
622
- const graceTimer = setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs)
696
+ const graceTimer = BgTimer.setTimeout(() => this.onGraceExpired(entry.streamId), this.timings.glassesGraceMs)
623
697
  this.suspended = {since, graceTimer, publisherGoneProbes: 0}
624
698
  console.warn("[STREAM] BLE link lost; stream suspended", {
625
699
  streamId: entry.streamId,
@@ -637,7 +711,7 @@ export class PhoneStreamCoordinator {
637
711
  const entry = this.current
638
712
  const suspended = this.suspended
639
713
  if (!entry || !suspended) return
640
- clearTimeout(suspended.graceTimer)
714
+ BgTimer.clearTimeout(suspended.graceTimer)
641
715
  this.suspended = null
642
716
  const suspendedMs = Date.now() - suspended.since
643
717
  console.info("[STREAM] BLE link back; stream resumed", {streamId: entry.streamId, suspendedMs})
@@ -668,20 +742,33 @@ export class PhoneStreamCoordinator {
668
742
  status: "error",
669
743
  data: {reason: LINK_STATUS.reason, teardownReason: reason, ...detail},
670
744
  })
745
+ this.requestTeardown(streamId, reason)
746
+ }
747
+
748
+ /** Event/timer failures have no awaiting caller; retain and report cleanup errors locally. */
749
+ private requestTeardown(streamId: string, reason: string, options: {sendBleStop?: boolean} = {}): void {
671
750
  void this.runExclusive(async () => {
672
751
  if (this.current?.streamId !== streamId) return
673
- await this.teardownLocked(reason)
752
+ await this.teardownLocked(reason, options)
753
+ }).catch((error) => {
754
+ console.warn("[STREAM] cleanup failed", error)
755
+ if (this.current?.streamId === streamId) {
756
+ this.fanout({streamId, source: "coordinator", status: "error", data: {reason: "cleanup_failed"}})
757
+ }
674
758
  })
675
759
  }
676
760
 
677
- private flushPendingBleStop(): void {
761
+ private async flushPendingBleStop(): Promise<void> {
678
762
  const pending = this.pendingBleStop
679
- if (!pending) return
680
- this.pendingBleStop = null
763
+ if (!pending || this.current || !this.linkSource.isConnected()) return
681
764
  console.info("[STREAM] BLE link back; sending deferred stopStream", pending)
682
- void BluetoothSdk.stopStream()
683
- .catch((err) => console.warn("[STREAM] deferred stopStream failed:", err))
684
- .finally(() => this.detachLinkIfIdle())
765
+ await BluetoothSdk.stopStream()
766
+ if (pending.hotspot) {
767
+ const result = await BluetoothSdk.setHotspotState(false)
768
+ if (result.state !== "disabled") throw new Error("Deferred hotspot shutdown was not confirmed")
769
+ }
770
+ if (this.pendingBleStop === pending) this.pendingBleStop = null
771
+ this.detachLinkIfIdle()
685
772
  }
686
773
 
687
774
  // ===========================================================================
@@ -708,7 +795,7 @@ export class PhoneStreamCoordinator {
708
795
  const pollingStartedAtMs = Date.now()
709
796
 
710
797
  const scheduleNext = () => {
711
- if (this.current !== entry) return
798
+ if (this.current !== entry || entry.stopping) return
712
799
  const waitingForWebRtc = entry.mode === "webrtc" && !entry.hlsReady
713
800
  const elapsedMs = Date.now() - pollingStartedAtMs
714
801
  const remainingMs = Math.max(0, connectTimeoutMs - elapsedMs)
@@ -717,16 +804,17 @@ export class PhoneStreamCoordinator {
717
804
  this.timings.cloudflareStartupPollInitialMs * 2 ** Math.min(Math.max(0, entry.cloudflareAttempts - 1), 10),
718
805
  )
719
806
  const delayMs = waitingForWebRtc ? Math.min(startupDelayMs, remainingMs) : this.timings.cloudflareStatusPollMs
720
- entry.cloudflareTimer = setTimeout(() => void poll(), delayMs)
807
+ entry.cloudflareTimer = BgTimer.setTimeout(() => void poll(), delayMs)
721
808
  }
722
809
 
723
810
  const poll = async () => {
724
- if (this.current !== entry) return
811
+ if (this.current !== entry || entry.stopping) return
725
812
  const requestStartedAtMs = Date.now()
726
813
  let keepPolling = true
727
814
  entry.cloudflareAttempts += 1
728
815
  try {
729
816
  const status: CloudflareStatus = await getManagedStreamStatus(entry.liveInputId)
817
+ if (this.current !== entry || entry.stopping) return
730
818
  console.debug("[STREAM_STARTUP]", {
731
819
  streamId: entry.streamId,
732
820
  stage: "cloudflare_probe",
@@ -791,15 +879,13 @@ export class PhoneStreamCoordinator {
791
879
  data: {reason: "webrtc_not_connected"},
792
880
  })
793
881
  const targetStreamId = entry.streamId
794
- void this.runExclusive(async () => {
795
- if (this.current?.streamId !== targetStreamId) return
796
- await this.teardownLocked("webrtc_not_connected")
797
- })
882
+ this.requestTeardown(targetStreamId, "webrtc_not_connected")
798
883
  keepPolling = false
799
884
  }
800
885
  }
801
886
  }
802
887
  } catch (err) {
888
+ if (this.current !== entry || entry.stopping) return
803
889
  console.warn("[STREAM] cloudflare status poll failed:", err)
804
890
  if (entry.mode === "webrtc" && !entry.hlsReady && Date.now() - pollingStartedAtMs >= connectTimeoutMs) {
805
891
  const timeoutErr = new Error(`WebRTC ingest status could not be confirmed after ${connectTimeoutMs}ms`)
@@ -807,10 +893,7 @@ export class PhoneStreamCoordinator {
807
893
  entry.hlsReadyResolvers = []
808
894
  entry.hlsReadyRejecters = []
809
895
  const targetStreamId = entry.streamId
810
- void this.runExclusive(async () => {
811
- if (this.current?.streamId !== targetStreamId) return
812
- await this.teardownLocked("webrtc_status_unavailable")
813
- })
896
+ this.requestTeardown(targetStreamId, "webrtc_status_unavailable")
814
897
  keepPolling = false
815
898
  }
816
899
  } finally {
@@ -827,13 +910,14 @@ export class PhoneStreamCoordinator {
827
910
  // Skip the first few seconds — Cloudflare doesn't have first-frame yet,
828
911
  // and the HEAD requests would all 404 and burn battery.
829
912
  const tick = async () => {
830
- if (this.current !== entry) return
913
+ if (this.current !== entry || entry.stopping) return
831
914
  entry.hlsAttempts += 1
832
915
  try {
833
916
  // Require a real manifest (200 with a body), not just res.ok — the
834
917
  // playback edge returns 204 No Content while the input has no
835
918
  // HLS-capable frames (e.g. WebRTC ingest), and 204 is "ok".
836
919
  const res = await fetch(entry.hlsUrl, {method: "HEAD"})
920
+ if (this.current !== entry || entry.stopping) return
837
921
  if (res.status === 200) {
838
922
  entry.hlsReady = true
839
923
  console.info("[STREAM_STARTUP]", {
@@ -844,7 +928,7 @@ export class PhoneStreamCoordinator {
844
928
  elapsedMs: Date.now() - entry.startupStartedAtMs,
845
929
  })
846
930
  if (entry.hlsTimer) {
847
- clearInterval(entry.hlsTimer)
931
+ BgTimer.clearInterval(entry.hlsTimer)
848
932
  entry.hlsTimer = undefined
849
933
  }
850
934
  const result = managedStartResult(entry)
@@ -864,7 +948,7 @@ export class PhoneStreamCoordinator {
864
948
  }
865
949
  if (entry.hlsAttempts >= this.timings.hlsReadinessMaxAttempts) {
866
950
  if (entry.hlsTimer) {
867
- clearInterval(entry.hlsTimer)
951
+ BgTimer.clearInterval(entry.hlsTimer)
868
952
  entry.hlsTimer = undefined
869
953
  }
870
954
  const err = new Error(
@@ -880,16 +964,13 @@ export class PhoneStreamCoordinator {
880
964
  data: {reason: "hls_not_ready"},
881
965
  })
882
966
  const targetStreamId = entry.streamId
883
- void this.runExclusive(async () => {
884
- if (this.current?.streamId !== targetStreamId) return
885
- await this.teardownLocked("hls_not_ready")
886
- })
967
+ this.requestTeardown(targetStreamId, "hls_not_ready")
887
968
  }
888
969
  }
889
- setTimeout(() => {
970
+ BgTimer.setTimeout(() => {
890
971
  // Guard: stream may have been torn down during the initial delay.
891
- if (this.current !== entry) return
892
- entry.hlsTimer = setInterval(tick, this.timings.hlsReadinessPollMs)
972
+ if (this.current !== entry || entry.stopping) return
973
+ entry.hlsTimer = BgTimer.setInterval(tick, this.timings.hlsReadinessPollMs)
893
974
  }, this.timings.hlsReadinessInitialDelayMs)
894
975
  }
895
976
 
@@ -921,7 +1002,7 @@ export class PhoneStreamCoordinator {
921
1002
  this.resolvedConfigForwarded = false
922
1003
 
923
1004
  if (this.suspended) {
924
- clearTimeout(this.suspended.graceTimer)
1005
+ BgTimer.clearTimeout(this.suspended.graceTimer)
925
1006
  this.suspended = null
926
1007
  }
927
1008
 
@@ -929,7 +1010,7 @@ export class PhoneStreamCoordinator {
929
1010
  // lock for the native timeout). Defer it to the next reconnect instead.
930
1011
  const linkUp = this.linkSource.isConnected()
931
1012
  if (sendBleStop && !linkUp) {
932
- this.pendingBleStop = {streamId: entry.streamId}
1013
+ this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay}
933
1014
  console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
934
1015
  streamId: entry.streamId,
935
1016
  reason,
@@ -937,8 +1018,9 @@ export class PhoneStreamCoordinator {
937
1018
  }
938
1019
 
939
1020
  if (entry.kind === "managed") {
940
- if (entry.cloudflareTimer) clearTimeout(entry.cloudflareTimer)
941
- if (entry.hlsTimer) clearInterval(entry.hlsTimer)
1021
+ entry.stopping = true
1022
+ if (entry.cloudflareTimer) BgTimer.clearTimeout(entry.cloudflareTimer)
1023
+ if (entry.hlsTimer) BgTimer.clearInterval(entry.hlsTimer)
942
1024
  // Reject any still-pending HLS readiness waiters.
943
1025
  const pendingErr = new Error(`Stream torn down: ${reason}`)
944
1026
  for (const reject of entry.hlsReadyRejecters) reject(pendingErr)
@@ -946,8 +1028,11 @@ export class PhoneStreamCoordinator {
946
1028
  entry.hlsReadyRejecters = []
947
1029
  }
948
1030
 
1031
+ // Relay stop owns the BLE publisher, native peers and hotspot. Keep the entry on failure
1032
+ // so another start cannot acquire resources whose teardown has not been confirmed.
1033
+ if (entry.kind === "managed" && entry.relay) await entry.relay.stop()
949
1034
  try {
950
- if (sendBleStop && linkUp) {
1035
+ if (sendBleStop && linkUp && !(entry.kind === "managed" && entry.relay)) {
951
1036
  await BluetoothSdk.stopStream()
952
1037
  }
953
1038
  } catch (err) {
@@ -958,8 +1043,6 @@ export class PhoneStreamCoordinator {
958
1043
  // Only clear if we're still the active entry (defensive — runExclusive
959
1044
  // serializes us, so this should always be true).
960
1045
  if (this.current === entry) this.current = null
961
- this.detachLinkIfIdle()
962
-
963
1046
  if (entry.kind === "managed") {
964
1047
  // Start remote cleanup only after the publisher has stopped, but do not
965
1048
  // hold the local transition lock on an unbounded network request. The
@@ -969,6 +1052,9 @@ export class PhoneStreamCoordinator {
969
1052
  console.warn("[STREAM] teardownManagedStream failed:", err)
970
1053
  })
971
1054
  }
1055
+ // BLE can recover while native cleanup is draining; no second link event is required.
1056
+ await this.flushPendingBleStop()
1057
+ this.detachLinkIfIdle()
972
1058
  }
973
1059
  }
974
1060
  }
@@ -976,7 +1062,7 @@ export class PhoneStreamCoordinator {
976
1062
  function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp"): string {
977
1063
  // Glasses' StreamCommandHandler detects protocol from URL prefix.
978
1064
  //
979
- // Default priority: SRT > RTMP > WHIP. SRT first: Cloudflare's WebRTC (WHIP)
1065
+ // Default priority: SRT > RTMP. WHIP is explicit. SRT first: Cloudflare's WebRTC (WHIP)
980
1066
  // ingest does NOT feed HLS/DASH playback or recording — a WHIP-ingested
981
1067
  // managed stream reports "connected" while its hlsUrl serves 204 forever,
982
1068
  // which breaks the managed contract (subscribers share HLS playback). SRT
@@ -991,11 +1077,7 @@ function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp")
991
1077
  // Throw if none resolved so the caller's Promise rejects with a clear
992
1078
  // message rather than the glasses' "unknown protocol" error.
993
1079
  const url =
994
- preference === "whip"
995
- ? p.webrtcPublishUrl || p.srtUrl || p.rtmpUrl
996
- : preference === "rtmp"
997
- ? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
998
- : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
1080
+ preference === "whip" ? p.webrtcPublishUrl : preference === "rtmp" ? p.rtmpUrl || p.srtUrl : p.srtUrl || p.rtmpUrl
999
1081
  if (!url) {
1000
1082
  throw new Error("Cloudflare provision returned no usable ingest URL")
1001
1083
  }
@@ -107,6 +107,8 @@ export interface SoftapCallDeps {
107
107
  /** Join the hotspot without taking the phone's default route. Resolves to the phone's own IPv4. */
108
108
  joinScopedNetwork(ssid: string, passphrase: string, report?: SoftapStepReporter): Promise<string | undefined>
109
109
  leaveScopedNetwork(): Promise<void>
110
+ /** Interrupt a pending native join while retaining its cleanup barrier. Unsupported hosts wait. */
111
+ cancelScopedNetworkJoin?(): Promise<void>
110
112
  /**
111
113
  * Join the meeting. This is what binds the local WHIP listener and arms the ACS raw outputs, so
112
114
  * it must resolve before the glasses are told to publish.
@@ -411,7 +413,7 @@ export class SoftapCallTransport {
411
413
  throw new Error("the meeting reported no ingest URL")
412
414
  }
413
415
  this.ingestUrl = ingestUrl
414
- softapTrace("acs_joined", {ingestUrl})
416
+ softapTrace("acs_receiver_ready", {ingestUrl})
415
417
  report(`Receiver ready at ${ingestUrl}`)
416
418
  })
417
419
 
@@ -424,10 +426,10 @@ export class SoftapCallTransport {
424
426
  })
425
427
 
426
428
  await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
427
- report("Waiting for the first video frame to reach Teams")
429
+ report("Waiting for the first glasses video frame on this phone")
428
430
  await this.deps.awaitFirstFrame(report)
429
- softapTrace("first_frame_in_acs")
430
- report("Video is live in the meeting")
431
+ softapTrace("first_glasses_frame_received")
432
+ report("Glasses video is reaching this phone")
431
433
  })
432
434
 
433
435
  if (generation !== this.generation) {
@@ -500,9 +502,21 @@ export class SoftapCallTransport {
500
502
  // this call is still coming". Deliberately unbounded: a native call that never returns must
501
503
  // hold the next call back, never let it race this one's cleanup.
502
504
  if (running) {
505
+ const cancellation =
506
+ running.step === "scopedJoin" && this.deps.cancelScopedNetworkJoin
507
+ ? Promise.resolve()
508
+ .then(() => this.deps.cancelScopedNetworkJoin!())
509
+ .catch((error) => {
510
+ if (!this.teardownFailures.includes("scopedJoin")) this.teardownFailures.push("scopedJoin")
511
+ softapTraceFailure("softap_join_cancel_failed", {
512
+ reason: error instanceof Error ? error.message : String(error),
513
+ })
514
+ })
515
+ : undefined
503
516
  softapTrace("softap_stop_waiting_for_step", {step: running.step})
504
517
  const waitStartedAt = Date.now()
505
518
  await running.settled
519
+ await cancellation
506
520
  // This wait is unbounded by design, so its duration is the difference between "the leave
507
521
  // was slow" and "the leave was held by a native call that had not returned".
508
522
  softapTrace("softap_stop_step_settled", {step: running.step, waitedMs: Date.now() - waitStartedAt})
@@ -707,9 +721,10 @@ export function createSoftapCallDeps(args: {
707
721
  /** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
708
722
  awaitFirstFrame: () => Promise<void>
709
723
  subsystems: {
710
- setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string}>
711
- joinScopedNetwork: (ssid: string, passphrase: string) => Promise<string | undefined>
724
+ setHotspotState: (enabled: boolean) => Promise<{state: string; ssid?: string; password?: string; localIp?: string}>
725
+ joinScopedNetwork: (ssid: string, passphrase: string, gateway?: string) => Promise<string | undefined>
712
726
  leaveScopedNetwork: () => Promise<void>
727
+ cancelScopedNetworkJoin?: () => Promise<void>
713
728
  joinMeeting: (
714
729
  packageName: string,
715
730
  options: {
@@ -767,6 +782,7 @@ export function createSoftapCallDeps(args: {
767
782
  }): SoftapCallDeps {
768
783
  const {packageName, subsystems} = args
769
784
  const hotspotBroadcastWaitMs = args.hotspotBroadcastWaitMs ?? HOTSPOT_BROADCAST_WAIT_MS
785
+ let gatewayAddress: string | undefined
770
786
  return {
771
787
  startHotspot: async (report) => {
772
788
  const enable = async () => {
@@ -778,6 +794,7 @@ export function createSoftapCallDeps(args: {
778
794
  throw new Error("the glasses hotspot reported no password")
779
795
  }
780
796
  report?.(`Glasses report hotspot ${status.ssid} enabled`)
797
+ gatewayAddress = status.localIp
781
798
  return {ssid: status.ssid, passphrase: status.password}
782
799
  }
783
800
  try {
@@ -808,7 +825,9 @@ export function createSoftapCallDeps(args: {
808
825
  },
809
826
  joinScopedNetwork: async (ssid, passphrase, report) => {
810
827
  const joinOnce = (nextSsid: string, nextPassphrase: string) =>
811
- subsystems.joinScopedNetwork(nextSsid, nextPassphrase)
828
+ gatewayAddress
829
+ ? subsystems.joinScopedNetwork(nextSsid, nextPassphrase, gatewayAddress)
830
+ : subsystems.joinScopedNetwork(nextSsid, nextPassphrase)
812
831
  let address: string | undefined
813
832
  try {
814
833
  address = await joinOnce(ssid, passphrase)
@@ -821,6 +840,7 @@ export function createSoftapCallDeps(args: {
821
840
  await subsystems.setHotspotState(false)
822
841
  const status = await subsystems.setHotspotState(true)
823
842
  if (status.state !== "enabled" || !status.ssid || !status.password) throw error
843
+ gatewayAddress = status.localIp
824
844
  if (hotspotBroadcastWaitMs > 0) {
825
845
  report?.(
826
846
  `Giving the hotspot ${Math.round(hotspotBroadcastWaitMs / 1000)}s to start broadcasting`,
@@ -848,6 +868,7 @@ export function createSoftapCallDeps(args: {
848
868
  return address
849
869
  },
850
870
  leaveScopedNetwork: () => subsystems.leaveScopedNetwork(),
871
+ cancelScopedNetworkJoin: subsystems.cancelScopedNetworkJoin,
851
872
  joinMeeting: async ({ssid, passphrase, bindAddress}, report) => {
852
873
  // The hotspot join just took this phone off Wi-Fi, so the route Teams needs is whatever
853
874
  // Android promoted in its place. Waiting for it to validate is what stopped the ACS join