@mentra/engine 3.2.0-dev.261 → 3.2.0-dev.262

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 (33) 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 +25 -1
  4. package/build/services/AcsMeetingService.d.ts.map +1 -1
  5. package/build/services/AcsMeetingService.js +41 -3
  6. package/build/services/AcsMeetingService.js.map +1 -1
  7. package/build/services/LocalMiniappRuntime.d.ts +32 -3
  8. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  9. package/build/services/LocalMiniappRuntime.js +284 -30
  10. package/build/services/LocalMiniappRuntime.js.map +1 -1
  11. package/build/services/MiniappLiveness.d.ts +11 -0
  12. package/build/services/MiniappLiveness.d.ts.map +1 -1
  13. package/build/services/MiniappLiveness.js +11 -0
  14. package/build/services/MiniappLiveness.js.map +1 -1
  15. package/build/services/PhoneStreamCoordinator.d.ts +13 -1
  16. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  17. package/build/services/PhoneStreamCoordinator.js +27 -4
  18. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  19. package/build/services/SoftapCallTransport.d.ts +87 -7
  20. package/build/services/SoftapCallTransport.d.ts.map +1 -1
  21. package/build/services/SoftapCallTransport.js +229 -28
  22. package/build/services/SoftapCallTransport.js.map +1 -1
  23. package/build/utils/miniappGlobals.d.ts.map +1 -1
  24. package/build/utils/miniappGlobals.js +13 -11
  25. package/build/utils/miniappGlobals.js.map +1 -1
  26. package/package.json +8 -8
  27. package/src/generated/releaseMetadata.ts +5 -5
  28. package/src/services/AcsMeetingService.ts +51 -3
  29. package/src/services/LocalMiniappRuntime.ts +296 -30
  30. package/src/services/MiniappLiveness.ts +18 -0
  31. package/src/services/PhoneStreamCoordinator.ts +27 -5
  32. package/src/services/SoftapCallTransport.ts +274 -32
  33. package/src/utils/miniappGlobals.ts +13 -11
@@ -230,9 +230,12 @@ export class PhoneStreamCoordinator {
230
230
  * A stream was torn down while the BLE link was down, so the glasses never
231
231
  * got `stopStream`. Sent on the next reconnect (if no new stream has claimed
232
232
  * the slot) so a publisher that outlived its input does not keep pushing
233
- * until its own watchdog fires.
233
+ * until its own watchdog fires. `generation` belongs to the publisher that
234
+ * queued it; a later SoftAP media hop must not inherit this stop.
234
235
  */
235
- private pendingBleStop: {streamId: string; hotspot?: boolean} | null = null
236
+ private pendingBleStop: {streamId: string; hotspot?: boolean; generation: number; discarded?: boolean} | null = null
237
+ /** Bumped each time an unmanaged/managed publisher claims the slot. */
238
+ private publisherGeneration = 0
236
239
  /**
237
240
  * Serializes state transitions (start, stop, teardown). Without it, a
238
241
  * second `start*` racing with the first can pass the `this.current === null`
@@ -363,6 +366,7 @@ export class PhoneStreamCoordinator {
363
366
  }
364
367
 
365
368
  const streamId = this.mintId("u")
369
+ ++this.publisherGeneration
366
370
  const entry: UnmanagedEntry = {
367
371
  kind: "unmanaged",
368
372
  streamId,
@@ -461,6 +465,7 @@ export class PhoneStreamCoordinator {
461
465
  // and joins instead of double-provisioning.
462
466
  const provision = await provisionManagedStream(opts.restreamDestinations)
463
467
  const streamId = this.mintId("m")
468
+ ++this.publisherGeneration
464
469
  let ingestUrl: string
465
470
  try {
466
471
  ingestUrl = pickIngestUrl(provision, opts.ingest)
@@ -513,7 +518,7 @@ export class PhoneStreamCoordinator {
513
518
  },
514
519
  () => this.linkSource.isConnected(),
515
520
  () => {
516
- this.pendingBleStop = {streamId, hotspot: true}
521
+ this.pendingBleStop = {streamId, hotspot: true, generation: this.publisherGeneration}
517
522
  this.attachLink()
518
523
  },
519
524
  )
@@ -615,6 +620,22 @@ export class PhoneStreamCoordinator {
615
620
  })
616
621
  }
617
622
 
623
+ /**
624
+ * Drop a deferred BLE `stopStream` that belonged to a publisher that is gone.
625
+ *
626
+ * SoftAP recovery destroys generation N and rebuilds N+1. If failSuspended already
627
+ * tore the publisher down after `glassesGraceMs`, `stop()` is a no-op but a pending
628
+ * stop would still flush into the new hop on reconnect. Call this from SoftAP
629
+ * `stopPublishing` so the deferred command dies with its generation.
630
+ */
631
+ discardPendingBleStop(): void {
632
+ const pending = this.pendingBleStop
633
+ if (!pending) return
634
+ pending.discarded = true
635
+ this.pendingBleStop = null
636
+ this.detachLinkIfIdle()
637
+ }
638
+
618
639
  /**
619
640
  * Called by MantleManager when a `stream_status` event arrives from glasses
620
641
  * and the registry says it's phone-owned.
@@ -759,9 +780,10 @@ export class PhoneStreamCoordinator {
759
780
 
760
781
  private async flushPendingBleStop(): Promise<void> {
761
782
  const pending = this.pendingBleStop
762
- if (!pending || this.current || !this.linkSource.isConnected()) return
783
+ if (!pending || pending.discarded || this.current || !this.linkSource.isConnected()) return
763
784
  console.info("[STREAM] BLE link back; sending deferred stopStream", pending)
764
785
  await BluetoothSdk.stopStream()
786
+ if (pending.discarded || this.pendingBleStop !== pending || this.current) return
765
787
  if (pending.hotspot) {
766
788
  const result = await BluetoothSdk.setHotspotState(false)
767
789
  if (result.state !== "disabled") throw new Error("Deferred hotspot shutdown was not confirmed")
@@ -1009,7 +1031,7 @@ export class PhoneStreamCoordinator {
1009
1031
  // lock for the native timeout). Defer it to the next reconnect instead.
1010
1032
  const linkUp = this.linkSource.isConnected()
1011
1033
  if (sendBleStop && !linkUp) {
1012
- this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay}
1034
+ this.pendingBleStop = {streamId: entry.streamId, hotspot: entry.kind === "managed" && !!entry.relay, generation: this.publisherGeneration}
1013
1035
  console.warn("[STREAM] BLE link down during teardown; stopStream deferred", {
1014
1036
  streamId: entry.streamId,
1015
1037
  reason,
@@ -33,7 +33,23 @@ export type SoftapStep = (typeof SOFTAP_STEPS)[number]
33
33
  * `starting` covers every step up to `live` because the caller's only useful distinction is
34
34
  * "not yet usable" versus "carrying media"; the step names are for diagnostics, not for branching.
35
35
  */
36
- export type SoftapPhase = "idle" | "starting" | "live" | "stopping" | "failed"
36
+ export type SoftapPhase = "idle" | "starting" | "recovering" | "live" | "stopping" | "failed"
37
+
38
+ /**
39
+ * SoftAP loss is a media outage, not call termination. The wearer has this long to
40
+ * come back in range before the host should give up rebuilding the hop.
41
+ */
42
+ export const RETURN_DEADLINE_MS = 60_000
43
+
44
+ /** Budget to re-arm hotspot, scoped join, ingest, and publish after the glasses return. */
45
+ export const REARM_BUDGET_MS = 45_000
46
+
47
+ /** Snapshot a host can use to decide whether to keep waiting or stand the call down. */
48
+ export interface SoftapRecoveryState {
49
+ phase: SoftapPhase
50
+ mediaGeneration: number
51
+ reason?: string
52
+ }
37
53
 
38
54
  /** A failure, named by the step that produced it so the UI and the logs agree on the cause. */
39
55
  export class SoftapCallError extends Error {
@@ -125,6 +141,17 @@ function isScopedJoinUnavailable(error: unknown): boolean {
125
141
  )
126
142
  }
127
143
 
144
+ /**
145
+ * The glasses never answered, as opposed to answering "disabled".
146
+ *
147
+ * A timeout is not evidence the command was lost: BLE can redeliver it after the phone has given
148
+ * up, so the AP may be coming up at the very moment the retry would ask for it to go down.
149
+ */
150
+ function isHotspotAnswerTimeout(error: unknown): boolean {
151
+ const message = error instanceof Error ? error.message : String(error)
152
+ return /timed out waiting for glasses response|glasses did not answer/i.test(message)
153
+ }
154
+
128
155
  export type SoftapStepStatus = "pending" | "running" | "done" | "failed"
129
156
 
130
157
  /**
@@ -150,6 +177,8 @@ export interface SoftapProgress {
150
177
  steps: SoftapStepState[]
151
178
  /** ms since `start()` was called. */
152
179
  elapsedMs: number
180
+ /** Media hop generation. Bumped on recover / media-only rebuild; ACS join is not. */
181
+ mediaGeneration: number
153
182
  }
154
183
 
155
184
  /** Sub-status callback a step can use to narrate what it is doing while it runs. */
@@ -196,15 +225,26 @@ export interface SoftapCallDeps {
196
225
  * tears down correctly, it just cannot honour `stop({mode: "end"})`.
197
226
  */
198
227
  endMeeting?(): Promise<void>
228
+ /**
229
+ * Rebind the local WHIP listener onto the standing ACS session. Substitutes for
230
+ * [joinMeeting] on a media-only rebuild: the meeting stays up, the ingest URL is new.
231
+ */
232
+ rebindIngest?(report?: SoftapStepReporter): Promise<{ingestUrl: string}>
199
233
  /** Tell the glasses to publish to [ingestUrl] in host-only ICE mode. */
200
- startPublishing(args: {ingestUrl: string; traceId: string}, report?: SoftapStepReporter): Promise<void>
234
+ startPublishing(
235
+ args: {ingestUrl: string; traceId: string; mediaGeneration?: number},
236
+ report?: SoftapStepReporter,
237
+ ): Promise<void>
201
238
  stopPublishing(): Promise<void>
202
239
  /**
203
240
  * Resolves when a frame has reached ACS, rejects if the feed failed or the deadline passed.
204
241
  * Separate from [joinMeeting] because an answered negotiation is not a working call: a session
205
242
  * that never delivers a frame reads as healthy behind a frozen tile.
243
+ *
244
+ * [fresh] ignores a standing `live` verdict. Recovery must wait for a frame from the new
245
+ * ingest generation; the previous hop's last decoded frame is not that.
206
246
  */
207
- awaitFirstFrame(report?: SoftapStepReporter): Promise<void>
247
+ awaitFirstFrame(report?: SoftapStepReporter, options?: {fresh?: boolean}): Promise<void>
208
248
  /**
209
249
  * Mid-call camera recovery. Resolves `true` only when ingest is live again.
210
250
  * A standing `failed` is the reason we are republishing, so it must not abort the wait.
@@ -215,6 +255,11 @@ export interface SoftapCallDeps {
215
255
  }
216
256
 
217
257
  export interface SoftapCallOptions {
258
+ /**
259
+ * Rebuild the media hop only. Skips `joinMeeting` and calls [SoftapCallDeps.rebindIngest]
260
+ * for the `acsJoin` step so the ACS session, endpoints, and mute intent stay up.
261
+ */
262
+ mediaOnly?: boolean
218
263
  /** Override the minted trace id, so a caller can correlate with logs it already started. */
219
264
  traceId?: string
220
265
  /**
@@ -274,6 +319,11 @@ export type SoftapTeardownMode = "leave" | "end"
274
319
  export interface SoftapStopOptions {
275
320
  mode?: SoftapTeardownMode
276
321
  keepProgress?: boolean
322
+ /**
323
+ * Tear down the media hop and keep the ACS session. Skips only the `acsJoin` undo
324
+ * (`leaveOrEndMeeting`) and leaves `acsJoin` in [completed].
325
+ */
326
+ preserveMeeting?: boolean
277
327
  }
278
328
 
279
329
  export class SoftapEndNotSupportedError extends Error {
@@ -341,6 +391,24 @@ export class SoftapCallTransport {
341
391
  private republishing: Promise<void> | null = null
342
392
  /** Bumped by `stop` so an in-flight republish cannot start_stream after Leave. */
343
393
  private republishGeneration = 0
394
+ /**
395
+ * Media hop generation. Distinct from [generation]: ACS join is preserved across recoveries,
396
+ * but hotspot, scoped network, ingest, and publisher belong to this number.
397
+ */
398
+ private mediaGeneration = 0
399
+ /** Why the current recover was requested. Cleared when the hop is live again. */
400
+ private recoveryReason: string | undefined
401
+ /** In-flight [recover]. Concurrent recoveries share one rebuild. */
402
+ private recovering: Promise<void> | null = null
403
+ /** A [stop] without preserveMeeting landed during recover; recover must not rebuild. */
404
+ private recoveryAbort = false
405
+ /** Skip `acsJoin` undo so the ACS session survives a media-only teardown. */
406
+ private meetingPreserved = false
407
+ /**
408
+ * Identity of the publisher the most recent `startPublishing` created.
409
+ * A cancelled republish may only `stopPublishing` if it still owns this token.
410
+ */
411
+ private publisherToken: object | null = null
344
412
 
345
413
  constructor(private readonly deps: SoftapCallDeps) {}
346
414
 
@@ -348,6 +416,18 @@ export class SoftapCallTransport {
348
416
  return this.phase
349
417
  }
350
418
 
419
+ currentMediaGeneration(): number {
420
+ return this.mediaGeneration
421
+ }
422
+
423
+ recoveryState(): SoftapRecoveryState {
424
+ return {
425
+ phase: this.phase,
426
+ mediaGeneration: this.mediaGeneration,
427
+ ...(this.recoveryReason ? {reason: this.recoveryReason} : {}),
428
+ }
429
+ }
430
+
351
431
  /**
352
432
  * The glasses camera died after this call was already live. Re-issue `start_stream` at the
353
433
  * existing ingest URL — do not rebind the WHIP listener, or the glasses POST to a dead port.
@@ -366,10 +446,14 @@ export class SoftapCallTransport {
366
446
  if (this.phase !== "live" || this.terminating || !this.ingestUrl) return Promise.resolve()
367
447
  if (this.republishing) return this.republishing
368
448
  const generation = this.republishGeneration
369
- this.republishing = this.runRepublish(reason, generation).finally(() => {
370
- if (this.republishGeneration === generation) this.republishing = null
449
+ let run!: Promise<void>
450
+ run = this.runRepublish(reason, generation).finally(() => {
451
+ // Clear when *this* run still owns the field, even if stop() bumped the generation.
452
+ // Gating on generation left a settled promise stranded and the next republish was a no-op.
453
+ if (this.republishing === run) this.republishing = null
371
454
  })
372
- return this.republishing
455
+ this.republishing = run
456
+ return run
373
457
  }
374
458
 
375
459
  private async runRepublish(reason: string, generation: number): Promise<void> {
@@ -385,12 +469,18 @@ export class SoftapCallTransport {
385
469
  this.ingestUrl === ingestUrl
386
470
  ) {
387
471
  attempt += 1
472
+ let startedPublisher = false
473
+ const token = {}
388
474
  try {
389
475
  await this.deps.stopPublishing()
390
476
  if (generation !== this.republishGeneration || this.terminating) return
391
- await this.deps.startPublishing({ingestUrl, traceId: this.traceId})
477
+ await this.startPublishingOwned(token, {ingestUrl, traceId: this.traceId})
478
+ startedPublisher = true
392
479
  if (generation !== this.republishGeneration || this.terminating) {
393
- await this.deps.stopPublishing().catch(() => undefined)
480
+ // Only stop a publisher this run started, and only if a successor has not taken it.
481
+ if (startedPublisher && this.publisherToken === token) {
482
+ await this.deps.stopPublishing().catch(() => undefined)
483
+ }
394
484
  return
395
485
  }
396
486
  softapTrace("glasses_republish_sent", {attempt, ingestUrl})
@@ -413,6 +503,93 @@ export class SoftapCallTransport {
413
503
  }
414
504
  }
415
505
 
506
+ /**
507
+ * Rebuild the glasses→phone hop after SoftAP loss. ACS, mute intent, and media endpoints stay.
508
+ *
509
+ * Phase flips to `recovering` before any await so [shouldRepublish] stands down immediately —
510
+ * not after a BLE wait that could still issue `start_stream` at the dying hop.
511
+ */
512
+ recover(reason: string, options: {wait?: () => Promise<void>} = {}): Promise<void> {
513
+ if (this.recovering) return this.recovering
514
+ if (this.phase !== "live") {
515
+ return Promise.reject(
516
+ new SoftapCallError("live", "NOT_RECOVERABLE", `Cannot recover a SoftAP call that is ${this.phase}`),
517
+ )
518
+ }
519
+ this.phase = "recovering"
520
+ this.recoveryReason = reason
521
+ this.recoveryAbort = false
522
+ this.mediaGeneration++
523
+ this.emitProgress()
524
+ softapTrace("softap_media_recover", {reason, mediaGeneration: this.mediaGeneration})
525
+
526
+ let run!: Promise<void>
527
+ run = this.runRecover(options).finally(() => {
528
+ if (this.recovering === run) this.recovering = null
529
+ })
530
+ this.recovering = run
531
+ return run
532
+ }
533
+
534
+ private async runRecover(options: {wait?: () => Promise<void>}): Promise<void> {
535
+ const onProgress = this.onProgress
536
+ // Wait for BLE *before* `set_hotspot_state false`. Doing stop first while the glasses are
537
+ // out of range queues the off command; when BLE returns it fires into a still-up hotspot
538
+ // and races the media-only start.
539
+ if (options.wait) await options.wait()
540
+ if (this.recoveryAbort) {
541
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
542
+ }
543
+ await this.stop({preserveMeeting: true, keepProgress: true})
544
+ if (this.recoveryAbort) {
545
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
546
+ }
547
+ // The return deadline bounds [options.wait]; the rebuild gets its own budget from the moment
548
+ // the glasses are back. Charging the rebuild for the time the wearer spent away left a return
549
+ // at 48s with 12s to re-arm a hotspot that takes ~30s, and the call was dropped as it worked.
550
+ const rearmMs = REARM_BUDGET_MS
551
+ let budgetTimer: ReturnType<typeof setTimeout> | undefined
552
+ const budget = new Promise<never>((_, reject) => {
553
+ budgetTimer = setTimeout(() => {
554
+ void this.stop({preserveMeeting: true}).catch(() => undefined)
555
+ reject(new SoftapCallError("hotspot", "REARM_BUDGET", "SoftAP media rebuild exceeded its budget"))
556
+ }, rearmMs)
557
+ })
558
+ try {
559
+ await Promise.race([this.start({mediaOnly: true, onProgress}), budget])
560
+ } finally {
561
+ if (budgetTimer !== undefined) clearTimeout(budgetTimer)
562
+ }
563
+ this.completed = [...SOFTAP_STEPS]
564
+ this.recoveryReason = undefined
565
+ this.emitProgress()
566
+ }
567
+
568
+ /** Issue `start_stream` and take publisher ownership so a stale republish cannot stop a successor. */
569
+ private async startPublishingOwned(
570
+ token: object,
571
+ args: {ingestUrl: string; traceId: string},
572
+ report?: SoftapStepReporter,
573
+ ): Promise<void> {
574
+ const mediaGeneration = this.mediaGeneration
575
+ const stamped: SoftapStepReporter | undefined = report
576
+ ? (detail) => {
577
+ if (mediaGeneration !== this.mediaGeneration) {
578
+ softapTrace("softap_stale_callback", {
579
+ source: "startPublishing",
580
+ mediaGeneration,
581
+ current: this.mediaGeneration,
582
+ detail,
583
+ })
584
+ return
585
+ }
586
+ report(detail)
587
+ }
588
+ : undefined
589
+ await this.deps.startPublishing({...args, mediaGeneration}, stamped)
590
+ this.publisherToken = token
591
+ }
592
+
416
593
  /**
417
594
  * True once a teardown has been decided. Anything that would otherwise report a failure — a lost
418
595
  * hotspot, a dropped ACS call — must check this first: after the wearer asks to leave, those are
@@ -429,6 +606,7 @@ export class SoftapCallTransport {
429
606
  phase: this.phase,
430
607
  steps: this.steps.map((step) => ({...step})),
431
608
  elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0,
609
+ mediaGeneration: this.mediaGeneration,
432
610
  }
433
611
  }
434
612
 
@@ -473,11 +651,10 @@ export class SoftapCallTransport {
473
651
  }
474
652
 
475
653
  /**
476
- * Steps whose undo threw during the last teardown, so the caller can refuse the next call.
654
+ * Steps whose undo threw during the last teardown.
477
655
  *
478
- * Teardown deliberately swallows these to keep unwinding but a hotspot that would not turn off
479
- * is exactly the state the next call cannot be built on, and starting anyway is what produced
480
- * "Cannot start glasses hotspot" followed by a scoped join that never found the SSID.
656
+ * Teardown swallows these so unwind can finish. The host refuses the next join for ingest /
657
+ * scoped-network leaks; a hotspot that missed its off-ack is leftover ON and is raised again.
481
658
  */
482
659
  lastTeardownFailures(): SoftapStep[] {
483
660
  // Logged on read rather than only on write, because this is the moment the list turns into a
@@ -538,17 +715,22 @@ export class SoftapCallTransport {
538
715
  softapTraceFailure("softap_call_refused", {reason: "cancelled before start"})
539
716
  throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP call was cancelled before it started")
540
717
  }
541
- if (this.phase !== "idle" && this.phase !== "failed") {
718
+ const mediaOnly = options.mediaOnly === true
719
+ const allowed =
720
+ this.phase === "idle" || this.phase === "failed" || (mediaOnly && this.phase === "recovering")
721
+ if (!allowed) {
542
722
  softapTraceFailure("softap_call_refused", {reason: "already active", phase: this.phase})
543
723
  throw new SoftapCallError("hotspot", "ALREADY_ACTIVE", `A SoftAP call is already ${this.phase}`)
544
724
  }
545
725
  this.startedEver = true
546
726
  const generation = ++this.generation
547
- this.phase = "starting"
727
+ if (mediaOnly) this.mediaGeneration++
728
+ this.phase = mediaOnly ? "recovering" : "starting"
548
729
  this.terminating = false
549
730
  this.teardownMode = "leave"
550
731
  this.endFailure = null
551
- this.completed = []
732
+ this.meetingPreserved = mediaOnly
733
+ this.completed = mediaOnly ? this.completed.filter((step) => step === "acsJoin") : []
552
734
  this.ingestUrl = null
553
735
  this.hotspot = null
554
736
  this.teardownFailures = []
@@ -556,12 +738,17 @@ export class SoftapCallTransport {
556
738
  this.stepStartedAt.clear()
557
739
  this.startedAt = Date.now()
558
740
  this.onProgress = options.onProgress
559
- const traceId = beginSoftapTrace(options.traceId)
741
+ // Media-only rebuilds mint a fresh trace id: the previous hop is gone and its id must not
742
+ // be reused, even if the caller still holds it.
743
+ const traceId = beginSoftapTrace(mediaOnly ? undefined : options.traceId)
560
744
  this.traceId = traceId
561
- softapTrace("softap_call_start", {traceId})
745
+ softapTrace("softap_call_start", {traceId, mediaOnly, mediaGeneration: this.mediaGeneration})
562
746
  this.emitProgress()
563
747
 
564
748
  try {
749
+ if (mediaOnly && !this.deps.rebindIngest) {
750
+ throw new SoftapCallError("acsJoin", "ACS_JOIN_FAILED", "rebindIngest is required for a media-only rebuild")
751
+ }
565
752
  await this.preflightWifi()
566
753
 
567
754
  await this.step(generation, "hotspot", "HOTSPOT_FAILED", async (report) => {
@@ -590,6 +777,17 @@ export class SoftapCallTransport {
590
777
  })
591
778
 
592
779
  await this.step(generation, "acsJoin", "ACS_JOIN_FAILED", async (report) => {
780
+ if (mediaOnly) {
781
+ report(bindAddress ? `Rebinding the video receiver on ${bindAddress}` : "Rebinding the video receiver")
782
+ const {ingestUrl} = await this.deps.rebindIngest!(report)
783
+ if (!ingestUrl) {
784
+ throw new Error("the meeting reported no ingest URL")
785
+ }
786
+ this.ingestUrl = ingestUrl
787
+ softapTrace("acs_receiver_rebound", {ingestUrl})
788
+ report(`Receiver ready at ${ingestUrl}`)
789
+ return
790
+ }
593
791
  report(bindAddress ? `Opening video receiver on ${bindAddress}, then joining Teams` : "Joining Teams")
594
792
  const {ingestUrl} = await this.deps.joinMeeting(
595
793
  {
@@ -612,14 +810,14 @@ export class SoftapCallTransport {
612
810
  const ingestUrl = this.requireIngestUrl()
613
811
  await this.step(generation, "publish", "PUBLISH_FAILED", async (report) => {
614
812
  report("Telling the glasses to start the camera and publish to the phone")
615
- await this.deps.startPublishing({ingestUrl, traceId}, report)
813
+ await this.startPublishingOwned({}, {ingestUrl, traceId}, report)
616
814
  softapTrace("glasses_publishing", {ingestUrl})
617
815
  report("Glasses camera is streaming to the phone")
618
816
  })
619
817
 
620
818
  await this.step(generation, "live", "NO_FIRST_FRAME", async (report) => {
621
819
  report("Waiting for the first glasses video frame on this phone")
622
- await this.deps.awaitFirstFrame(report)
820
+ await this.deps.awaitFirstFrame(report, mediaOnly ? {fresh: true} : undefined)
623
821
  softapTrace("first_glasses_frame_received")
624
822
  report("Glasses video is reaching this phone")
625
823
  })
@@ -631,14 +829,17 @@ export class SoftapCallTransport {
631
829
  softapTraceFailure("softap_call_abandoned_at_live", {generation, current: this.generation})
632
830
  return
633
831
  }
832
+ if (mediaOnly) this.completed = [...SOFTAP_STEPS]
634
833
  this.phase = "live"
834
+ this.recoveryReason = undefined
635
835
  softapTrace("softap_call_live")
636
836
  this.emitProgress()
637
837
  } catch (error) {
638
838
  // Unwind before rethrowing. A caller that sees a rejection is entitled to assume nothing was
639
839
  // left running, and a hotspot left up is both a battery cost and a second call's failure.
640
- await this.stop({keepProgress: true})
641
- this.phase = "failed"
840
+ // Media-only failures keep ACS; a user Leave during recover does not (`recoveryAbort`).
841
+ await this.stop({keepProgress: true, preserveMeeting: mediaOnly && !this.recoveryAbort})
842
+ if (!(mediaOnly && this.recoveryAbort)) this.phase = "failed"
642
843
  this.emitProgress()
643
844
  throw error
644
845
  }
@@ -663,12 +864,17 @@ export class SoftapCallTransport {
663
864
  this.terminating = true
664
865
  this.republishGeneration++
665
866
  if (options.mode) this.teardownMode = options.mode
867
+ if (!options.preserveMeeting) this.recoveryAbort = true
666
868
  if (this.stopping) {
667
- softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode})
668
- return this.stopping
869
+ softapTrace("softap_stop_joined_in_flight", {mode: this.teardownMode, preserveMeeting: !!options.preserveMeeting})
870
+ await this.stopping
871
+ // A Leave that joined a preserveMeeting teardown still has to drop ACS.
872
+ if (options.preserveMeeting || (!this.completed.includes("acsJoin") && this.completed.length === 0 && !this.running)) {
873
+ return
874
+ }
669
875
  }
670
876
  const running = this.running
671
- if (this.completed.length === 0 && this.phase === "idle" && !running) {
877
+ if (this.completed.length === 0 && (this.phase === "idle" || this.phase === "failed") && !running) {
672
878
  // Nothing was built, so there is nothing to unwind — but a start() that has not run yet
673
879
  // still has to be refused, and a generation bump still has to invalidate anything holding
674
880
  // the old one.
@@ -684,12 +890,22 @@ export class SoftapCallTransport {
684
890
  }
685
891
 
686
892
  this.generation++
687
- this.phase = "stopping"
893
+ this.meetingPreserved = options.preserveMeeting === true
894
+ this.phase = this.meetingPreserved ? "recovering" : "stopping"
688
895
  this.endFailure = null
689
- softapTrace("softap_call_stop", {steps: this.completed.join(","), mode: this.teardownMode})
896
+ softapTrace("softap_call_stop", {
897
+ steps: this.completed.join(","),
898
+ mode: this.teardownMode,
899
+ preserveMeeting: this.meetingPreserved,
900
+ })
690
901
  this.emitProgress()
691
902
 
692
903
  this.stopping = (async () => {
904
+ const inFlightRepublish = this.republishing
905
+ if (inFlightRepublish) {
906
+ softapTrace("softap_stop_draining_republish")
907
+ await inFlightRepublish.catch(() => undefined)
908
+ }
693
909
  // The generation bump above has already told the in-flight step to release whatever it
694
910
  // produced. Waiting for that release is what makes a resolved `stop()` mean "nothing from
695
911
  // this call is still coming". Deliberately unbounded: a native call that never returns must
@@ -717,7 +933,12 @@ export class SoftapCallTransport {
717
933
  // The late step may have recorded a failed self-undo while we waited. Preserve it,
718
934
  // and any earlier teardown result, until start() explicitly begins a new attempt.
719
935
  const failures: SoftapStep[] = [...this.teardownFailures]
936
+ const kept: SoftapStep[] = []
720
937
  for (const step of [...this.completed].reverse()) {
938
+ if (this.meetingPreserved && step === "acsJoin") {
939
+ if (!kept.includes("acsJoin")) kept.unshift(step)
940
+ continue
941
+ }
721
942
  const undoStartedAt = Date.now()
722
943
  try {
723
944
  await this.undo(step)
@@ -734,10 +955,10 @@ export class SoftapCallTransport {
734
955
  })
735
956
  }
736
957
  }
737
- this.completed = []
958
+ this.completed = kept
738
959
  this.hotspot = null
739
960
  this.ingestUrl = null
740
- this.phase = "idle"
961
+ this.phase = this.meetingPreserved && !this.recoveryAbort ? "recovering" : "idle"
741
962
  this.teardownFailures = failures
742
963
  softapTrace("softap_call_stopped", {undoFailures: failures.join(",")})
743
964
  resetSoftapTrace()
@@ -769,6 +990,7 @@ export class SoftapCallTransport {
769
990
  case "publish":
770
991
  return this.deps.stopPublishing()
771
992
  case "acsJoin":
993
+ if (this.meetingPreserved) return
772
994
  return this.leaveOrEndMeeting()
773
995
  case "scopedJoin":
774
996
  return this.deps.leaveScopedNetwork()
@@ -865,7 +1087,7 @@ export class SoftapCallTransport {
865
1087
  await this.undoSafely(step)
866
1088
  throw new SoftapCallError(step, "CANCELLED", `SoftAP call was cancelled during ${step}`)
867
1089
  }
868
- this.completed.push(step)
1090
+ if (!this.completed.includes(step)) this.completed.push(step)
869
1091
  softapTrace("softap_step_done", {step, durationMs: Date.now() - startedAt})
870
1092
  this.setStep(step, {status: "done", durationMs: Date.now() - startedAt})
871
1093
  }
@@ -917,7 +1139,7 @@ export function createSoftapCallDeps(args: {
917
1139
  */
918
1140
  video?: {width: number; height: number; fps: number; maxBitrateBps: number}
919
1141
  /** Resolves when the meeting reports a frame reached ACS; rejects on a failed feed. */
920
- awaitFirstFrame: () => Promise<void>
1142
+ awaitFirstFrame: (report?: SoftapStepReporter, options?: {fresh?: boolean}) => Promise<void>
921
1143
  /**
922
1144
  * Mid-call camera recovery. Resolves `true` only when ingest is live again.
923
1145
  * A standing `failed` is the reason we are republishing, so it must not abort the wait.
@@ -951,9 +1173,16 @@ export function createSoftapCallDeps(args: {
951
1173
  traceId: string
952
1174
  captureAudio?: boolean
953
1175
  video?: SoftapVideoPolicy
1176
+ mediaGeneration?: number
954
1177
  },
955
1178
  ) => Promise<unknown>
956
1179
  stopPublishing: (packageName: string) => Promise<void>
1180
+ /** Drop a deferred BLE stopStream that belonged to a destroyed SoftAP media generation. */
1181
+ discardPendingBleStop?: () => void
1182
+ /**
1183
+ * Rebind WHIP ingest on the standing ACS session. Required for media-only rebuilds.
1184
+ */
1185
+ rebindIngest?: (report?: SoftapStepReporter) => Promise<{ingestUrl: string}>
957
1186
  /**
958
1187
  * Whether the host is taking the wearer's voice off the glasses over BLE LC3 for this call.
959
1188
  *
@@ -1013,11 +1242,19 @@ export function createSoftapCallDeps(args: {
1013
1242
  return await enable()
1014
1243
  } catch (error) {
1015
1244
  if (error instanceof Error && /no password/.test(error.message)) throw error
1245
+ const unanswered = isHotspotAnswerTimeout(error)
1016
1246
  // Cancel-then-start races the previous disable: the glasses report disabled (or no SSID)
1017
1247
  // and the UI said "Couldn't start glasses hotspot" before step 2 ran on a leftover AP.
1018
1248
  softapTraceFailure("hotspot_enable_retry", {
1019
1249
  reason: error instanceof Error ? error.message : String(error),
1250
+ unanswered,
1020
1251
  })
1252
+ if (unanswered) {
1253
+ // Enable is idempotent; disabling here is what turned a late-delivered enable back off
1254
+ // and cost the whole rebuild budget after a walk-away.
1255
+ report?.("Glasses did not answer; asking again without turning the hotspot off")
1256
+ return await enable()
1257
+ }
1021
1258
  report?.("Glasses hotspot did not start; turning it off and trying again")
1022
1259
  await subsystems.setHotspotState(false)
1023
1260
  if (hotspotBroadcastWaitMs > 0) {
@@ -1115,7 +1352,8 @@ export function createSoftapCallDeps(args: {
1115
1352
  },
1116
1353
  leaveMeeting: () => subsystems.leaveMeeting(packageName),
1117
1354
  ...(subsystems.endMeeting ? {endMeeting: () => subsystems.endMeeting!(packageName)} : {}),
1118
- startPublishing: async ({ingestUrl, traceId}, report) => {
1355
+ ...(subsystems.rebindIngest ? {rebindIngest: subsystems.rebindIngest} : {}),
1356
+ startPublishing: async ({ingestUrl, traceId, mediaGeneration}, report) => {
1119
1357
  // Narrate the glasses side while the BLE start command is in flight. `initializing` means
1120
1358
  // the glasses accepted the command and are opening the camera; `streaming` means the WHIP
1121
1359
  // offer was answered and ICE connected; anything else is the reason it did not.
@@ -1162,13 +1400,17 @@ export function createSoftapCallDeps(args: {
1162
1400
  ice: {stun: ""},
1163
1401
  traceId,
1164
1402
  captureAudio: !lc3Uplink,
1403
+ ...(typeof mediaGeneration === "number" ? {mediaGeneration} : {}),
1165
1404
  ...(policy ? {video: policy} : {}),
1166
1405
  })
1167
1406
  } finally {
1168
1407
  unsubscribe?.()
1169
1408
  }
1170
1409
  },
1171
- stopPublishing: () => subsystems.stopPublishing(packageName),
1410
+ stopPublishing: async () => {
1411
+ await subsystems.stopPublishing(packageName)
1412
+ subsystems.discardPendingBleStop?.()
1413
+ },
1172
1414
  awaitFirstFrame: args.awaitFirstFrame,
1173
1415
  waitUntilLive: args.waitUntilLive,
1174
1416
  }