@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
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.0",
15
- "releaseIdentity": "3.2.0-dev.261",
16
- "releaseSetId": "mentra-3.2.0-dev.261",
17
- "sourceCommit": "ebd405ff0f311035faccac6e19b0a5a38b3a1483",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.261.json",
19
- "otaManifestSha256": "e9d88a258f276ee9ddc5396e9c5745f91f924449b802248e6d37dfe9bb8bb3ee"
15
+ "releaseIdentity": "3.2.0-dev.262",
16
+ "releaseSetId": "mentra-3.2.0-dev.262",
17
+ "sourceCommit": "545ed70c7afa46ca09b895d2dcd651b45510dbb5",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.262.json",
19
+ "otaManifestSha256": "d1c4e63ae2c1cd8de479d4bbc57219d2b85e904c26a491441265f5182220125f"
20
20
  })
@@ -382,6 +382,11 @@ type NativeModule = {
382
382
  updateVideoSource(whepUrl: string): Promise<void>
383
383
  /** Force a WHEP rebuild on the current URL. Absent on natives that predate it. */
384
384
  restartVideoSource?(): Promise<void>
385
+ /**
386
+ * SoftAP: destroy the current ingest listener and bind a new one. Resolves with
387
+ * the new URL. Absent on natives that predate rebind; disabled natives reject.
388
+ */
389
+ rebindSoftApIngest?(): Promise<string>
385
390
  /**
386
391
  * Join the glasses hotspot as a scoped, internet-less network; resolves with this phone's
387
392
  * address on it. Absent on natives that predate SoftAP.
@@ -569,6 +574,13 @@ class AcsMeetingService {
569
574
  private lastMediaRestartAt = 0
570
575
  /** Callers parked in [waitForFirstFrame], woken by the next `mediaSource` verdict. */
571
576
  private readonly firstFrameWaiters = new Set<(error?: Error) => void>()
577
+ /**
578
+ * Bumped by [invalidateDecodedMedia]. A `live` verdict only counts for the wait that
579
+ * started after that bump if [liveEpoch] matches.
580
+ */
581
+ private decodedMediaEpoch = 0
582
+ /** [decodedMediaEpoch] at the moment the phone last reported `mediaSource: live`. */
583
+ private liveEpoch = -1
572
584
  /** Mid-call republish waiters: live resolves true, leave/timeout resolves false. Failed is ignored. */
573
585
  private readonly mediaLiveWaiters = new Set<(live: boolean) => void>()
574
586
  private scopedLostSub: {remove: () => void} | null = null
@@ -657,6 +669,32 @@ class AcsMeetingService {
657
669
  await getNative()?.forceCloseIngest?.()
658
670
  }
659
671
 
672
+ /**
673
+ * Destroy the current SoftAP ingest listener and bind a new one. The caller
674
+ * rejoins the hotspot first; native reads the current scoped address.
675
+ */
676
+ async rebindSoftApIngest(): Promise<string> {
677
+ const native = getNative()
678
+ if (!native?.rebindSoftApIngest) {
679
+ throw new Error("SoftAP ingest rebind is not available on this native")
680
+ }
681
+ this.invalidateDecodedMedia()
682
+ const url = await native.rebindSoftApIngest()
683
+ this.ingestUrl = url
684
+ return url
685
+ }
686
+
687
+ /**
688
+ * Forget a previously decoded first frame. Recovery must wait for a frame
689
+ * from the new ingest generation, not short-circuit on the last live verdict.
690
+ */
691
+ invalidateDecodedMedia(): void {
692
+ this.decodedMediaEpoch++
693
+ if (this.lastState.mediaSource === "live") {
694
+ this.lastState = {...this.lastState, mediaSource: "connecting", mediaSourceReason: "ingest_rebind"}
695
+ }
696
+ }
697
+
660
698
  /**
661
699
  * Is this phone's Wi-Fi radio on?
662
700
  *
@@ -795,9 +833,18 @@ class AcsMeetingService {
795
833
  *
796
834
  * @param timeoutMs how long to wait before treating the silence as a failure
797
835
  */
798
- waitForFirstFrame(timeoutMs: number): Promise<void> {
799
- if (this.lastState.mediaSource === "live") {
800
- softapTrace("glasses_first_frame_already_received", {mediaSource: this.lastState.mediaSource})
836
+ waitForFirstFrame(timeoutMs: number, options: {fresh?: boolean} = {}): Promise<void> {
837
+ const liveThisGeneration =
838
+ this.lastState.mediaSource === "live" && this.liveEpoch === this.decodedMediaEpoch
839
+ // Join short-circuits on any live. Recovery (`fresh`) short-circuits only on a live
840
+ // from this ingest generation: the new WHIP first frame often lands during publish,
841
+ // before this wait starts. Waiting for a later `onState` then times out while video
842
+ // is already flowing, and the miniapp hangs up the ACS meeting.
843
+ if (liveThisGeneration) {
844
+ softapTrace(
845
+ options.fresh ? "glasses_first_frame_this_generation" : "glasses_first_frame_already_received",
846
+ {mediaSource: this.lastState.mediaSource, epoch: this.decodedMediaEpoch},
847
+ )
801
848
  return Promise.resolve()
802
849
  }
803
850
  const startedAt = Date.now()
@@ -1542,6 +1589,7 @@ class AcsMeetingService {
1542
1589
  participants: participants?.length,
1543
1590
  endReason: endReason ? `${endReason.code ?? "?"}/${endReason.subcode ?? "?"}` : undefined,
1544
1591
  })
1592
+ if (mediaSource === "live") this.liveEpoch = this.decodedMediaEpoch
1545
1593
  this.settleFirstFrameWaiters(mediaSource)
1546
1594
  this.onState?.(packageName, state)
1547
1595
  // A remote hang-up, an ACS error or a dropped call never goes through `leave`, so without
@@ -35,7 +35,7 @@ import {storage as mmkvStorage} from "../utils/storage/storage"
35
35
  import {BgTimer} from "../utils/timers"
36
36
  import devServerBridge from "./DevServerBridge"
37
37
  import {islandNotifications} from "./NotificationsEmitter"
38
- import {isGlassesConnected} from "./GlassesReadiness"
38
+ import {isGlassesConnected, isGlassesReady, waitForGlassesReady} from "./GlassesReadiness"
39
39
  import {toMiniappConnectionData} from "./GlassesStatusProjection"
40
40
  import audioPlaybackService from "./AudioPlaybackService"
41
41
  import {phoneLocationService} from "./PhoneLocationService"
@@ -85,7 +85,7 @@ import type {ClientApp} from "../types/applet"
85
85
  import {useAppStatusStore} from "../stores/apps"
86
86
  import appRegistry, {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
87
87
  import {resolveForegroundLocationPermission} from "./ForegroundLocationPermission"
88
- import {advanceMiniappPingLiveness} from "./MiniappLiveness"
88
+ import {advanceMiniappPingLiveness, shouldHoldMiniappPingLiveness} from "./MiniappLiveness"
89
89
  import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
90
90
  import {LocalMiniappStorage} from "./LocalMiniappStorage"
91
91
  import acsMeetingService, {
@@ -102,6 +102,7 @@ import {
102
102
  createSoftapCallDeps,
103
103
  SoftapCallError,
104
104
  SoftapCallTransport,
105
+ RETURN_DEADLINE_MS,
105
106
  SOFTAP_STEPS,
106
107
  type SoftapProgress,
107
108
  type SoftapTeardownMode,
@@ -237,6 +238,11 @@ const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500
237
238
  * rather than sit in "connecting" while the wearer waits.
238
239
  */
239
240
  const SOFTAP_FIRST_FRAME_MS = 20_000
241
+ /**
242
+ * How long the BLE link must stay up before SoftAP recovery trusts it. Covers the gap between
243
+ * the hotspot dying and the phone noticing the link died with it.
244
+ */
245
+ const GLASSES_LINK_SETTLE_MS = 4_000
240
246
  const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup"
241
247
  // Unregister after this many missed pongs. Generous on purpose: a busy
242
248
  // context (heavy interim translation traffic) or OS scheduling while idle can
@@ -414,6 +420,14 @@ type SoftapAttempt = {
414
420
  /** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
415
421
  progress: SoftapProgress
416
422
  scopedLostUnsub: (() => void) | null
423
+ /**
424
+ * Mid-call media rebuild. Single-flight while [recoveryActive] is true; the promise is
425
+ * cleared when that run settles so a later walk-away can start another. Joined to the
426
+ * attempt barrier so a late re-arm cannot resurrect resources after Leave/End.
427
+ */
428
+ recovery: Promise<void> | null
429
+ recoveryActive: boolean
430
+ recoveryDeadlineAt: number | null
417
431
  /**
418
432
  * The join itself, settled either way. A native call still in flight owns state the teardown
419
433
  * cannot see — a `createCallAgent` mid-sign-in — so the barrier covers this as well.
@@ -444,6 +458,27 @@ const SOFTAP_HOTSPOT_OFF_ACK_MS = 5_000
444
458
  /** After a forced close there is nothing left to wait for, so this only catches a real failure. */
445
459
  const SOFTAP_FORCED_VERIFY_MS = 500
446
460
 
461
+ /**
462
+ * Rejection codes meaning the glasses replaced their Wi-Fi protocol session, not that the
463
+ * command failed.
464
+ *
465
+ * The glasses emit `wifi_protocol_session_ready` on every `glasses_ready`, which fires on BLE
466
+ * reconnects and ASG restarts — routine during a SoftAP teardown. The SDK cancels every pending
467
+ * Wi-Fi request when that lands, so a hotspot command in flight is lost even though nothing is
468
+ * actually wrong. `wifi_session_disconnected` is deliberately absent: that one is raised from the
469
+ * store update where the glasses went away, so there is nothing left to re-send to.
470
+ */
471
+ const HOTSPOT_SESSION_REFRESH_CODES = new Set(["wifi_session_restarted", "wifi_session_changed"])
472
+
473
+ /**
474
+ * Let the restarted ASG command path settle before re-sending.
475
+ *
476
+ * `handleSetHotspotState` returns false when its network manager is not up yet, which drops the
477
+ * command silently, so an immediate re-send would just race the same initialization. Mirrors the
478
+ * retry spacing `disableHotspotWithRetry` uses for the sibling ASG-replacement case.
479
+ */
480
+ const HOTSPOT_SESSION_RETRY_DELAY_MS = 500
481
+
447
482
  /** Reject with [reason] if [work] has not settled in [ms]. The work itself is not cancellable. */
448
483
  function withTimeout<T>(work: Promise<T>, ms: number, reason: string): Promise<T> {
449
484
  return new Promise<T>((resolve, reject) => {
@@ -3654,6 +3689,7 @@ class LocalMiniappRuntime {
3654
3689
  this.sendToMiniapp(owner, {
3655
3690
  type: MiniappResponseType.MEETING_STATE,
3656
3691
  ...state,
3692
+ ...this.softapRecoveryFields(attempt?.packageName === owner ? attempt : null),
3657
3693
  })
3658
3694
  if (attempt?.ownsResources && attempt.transport?.shouldRepublish(state.mediaSource)) {
3659
3695
  void attempt.transport.republish(state.mediaSourceReason ?? "mediaSource failed")
@@ -3865,8 +3901,12 @@ class LocalMiniappRuntime {
3865
3901
  phase: "starting",
3866
3902
  steps: SOFTAP_STEPS.map((step) => ({step, status: "pending" as const})),
3867
3903
  elapsedMs: 0,
3904
+ mediaGeneration: 0,
3868
3905
  },
3869
3906
  scopedLostUnsub: null,
3907
+ recovery: null,
3908
+ recoveryActive: false,
3909
+ recoveryDeadlineAt: null,
3870
3910
  body: null,
3871
3911
  teardown: null,
3872
3912
  settled,
@@ -3932,9 +3972,27 @@ class LocalMiniappRuntime {
3932
3972
  // join *is* in progress, and `idle` would make the miniapp think the call ended.
3933
3973
  state: current.state === "idle" ? "connecting" : current.state,
3934
3974
  softap: progress,
3975
+ ...this.softapRecoveryFields(attempt),
3935
3976
  })
3936
3977
  }
3937
3978
 
3979
+ private softapRecoveryFields(attempt: SoftapAttempt | null | undefined): {recovery?: {
3980
+ active: boolean
3981
+ generation?: number
3982
+ deadlineAt?: number
3983
+ phase?: string
3984
+ }} {
3985
+ if (!attempt?.recoveryDeadlineAt) return {}
3986
+ return {
3987
+ recovery: {
3988
+ active: attempt.recoveryActive,
3989
+ generation: attempt.transport?.currentMediaGeneration(),
3990
+ deadlineAt: attempt.recoveryDeadlineAt,
3991
+ phase: attempt.transport?.currentPhase(),
3992
+ },
3993
+ }
3994
+ }
3995
+
3938
3996
  /**
3939
3997
  * Narrates preflight work on the first row while it is still pending.
3940
3998
  *
@@ -3978,10 +4036,12 @@ class LocalMiniappRuntime {
3978
4036
  this.checkpointSoftapAttempt(attempt, "cleanup barrier")
3979
4037
  const cleanupError = this.softapCleanupError
3980
4038
  if (cleanupError) {
3981
- // Report once, allowing a retry after the wearer power-cycles the glasses.
4039
+ // Cleared on read, so this refuses at most once and the next attempt always gets through —
4040
+ // which is why the wearer is told to retry rather than to go power-cycle anything. The
4041
+ // native reason stays in the trace: it names a leaked port, not something they can act on.
3982
4042
  this.softapCleanupError = null
3983
4043
  softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
3984
- throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
4044
+ throw new Error("Previous call cleanup has not finished yet. Please try joining again.")
3985
4045
  }
3986
4046
  attempt.releaseHotspot = acquireGlassesHotspot()
3987
4047
  attempt.ownsResources = true
@@ -4011,7 +4071,8 @@ class LocalMiniappRuntime {
4011
4071
  token: args.token,
4012
4072
  displayName: args.displayName,
4013
4073
  video: args.video,
4014
- awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
4074
+ awaitFirstFrame: (_report, options) =>
4075
+ acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS, options),
4015
4076
  waitUntilLive: (timeoutMs) => acsMeetingService.waitUntilMediaLive(timeoutMs),
4016
4077
  subsystems: {
4017
4078
  setHotspotState: async (enabled) => {
@@ -4077,6 +4138,14 @@ class LocalMiniappRuntime {
4077
4138
  glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
4078
4139
  startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
4079
4140
  stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
4141
+ discardPendingBleStop: () => phoneStreamCoordinator.discardPendingBleStop(),
4142
+ rebindIngest: async (report) => {
4143
+ report?.("Rebinding the glasses video receiver on the hotspot")
4144
+ const ingestUrl = await acsMeetingService.rebindSoftApIngest()
4145
+ if (!ingestUrl) throw new Error("the rebound meeting reported no ingest URL")
4146
+ report?.(`Receiver rebound at ${ingestUrl}`)
4147
+ return {ingestUrl}
4148
+ },
4080
4149
  },
4081
4150
  }),
4082
4151
  )
@@ -4109,14 +4178,7 @@ class LocalMiniappRuntime {
4109
4178
  reason: error.message,
4110
4179
  attemptAgeMs: Date.now() - attempt.startedAt,
4111
4180
  })
4112
- const current = acsMeetingService.getState()
4113
- this.sendToMiniapp(packageName, {
4114
- type: MiniappResponseType.MEETING_STATE,
4115
- ...current,
4116
- state: "error",
4117
- error: `SOFTAP_NETWORK_LOST: ${error.message}`,
4118
- softap: transport.progress(),
4119
- })
4181
+ this.beginSoftapRecovery(attempt, transport, error)
4120
4182
  })
4121
4183
 
4122
4184
  // Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
@@ -4156,6 +4218,163 @@ class LocalMiniappRuntime {
4156
4218
  return state
4157
4219
  }
4158
4220
 
4221
+ /**
4222
+ * SoftAP loss is a media outage. Keep ACS, destroy generation N, wait for the glasses,
4223
+ * then build generation N+1. Duplicate losses do not renew the deadline.
4224
+ */
4225
+ private beginSoftapRecovery(
4226
+ attempt: SoftapAttempt,
4227
+ transport: SoftapCallTransport,
4228
+ error: {code: string; message: string},
4229
+ ): void {
4230
+ // Gate on the live flag, not the promise: a settled Promise is still truthy, so a second
4231
+ // walk-away after a successful re-arm was dropped as "in-flight" and the glasses kept
4232
+ // POSTing WHIP at a hotspot the phone had already left.
4233
+ if (attempt.recoveryActive) {
4234
+ softapTrace("softap_recovery_dropped", {attempt: attempt.id, cause: "in-flight"})
4235
+ return
4236
+ }
4237
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
4238
+ softapTrace("softap_recovery_dropped", {
4239
+ attempt: attempt.id,
4240
+ cause: attempt.cancelled ? "retired" : "superseded",
4241
+ })
4242
+ return
4243
+ }
4244
+ const lostAt = Date.now()
4245
+ attempt.recoveryDeadlineAt = lostAt + RETURN_DEADLINE_MS
4246
+ attempt.recoveryActive = true
4247
+ this.emitSoftapProgress(attempt, transport.progress())
4248
+ softapTrace("softap_recovery_begin", {
4249
+ attempt: attempt.id,
4250
+ deadlineAt: attempt.recoveryDeadlineAt,
4251
+ mediaGeneration: transport.currentMediaGeneration(),
4252
+ reason: error.message,
4253
+ })
4254
+
4255
+ let run!: Promise<void>
4256
+ run = (async () => {
4257
+ try {
4258
+ await transport.recover(`${error.code}: ${error.message}`, {
4259
+ wait: async () => {
4260
+ const remaining = (attempt.recoveryDeadlineAt ?? 0) - Date.now()
4261
+ if (remaining <= 0) {
4262
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time")
4263
+ }
4264
+ const ready = await this.waitForGlassesReturn(remaining, () => attempt.cancelled)
4265
+ if (attempt.cancelled || this.softapAttempt !== attempt) {
4266
+ throw new SoftapCallError("hotspot", "CANCELLED", "SoftAP recovery was cancelled")
4267
+ }
4268
+ if (!ready) {
4269
+ throw new SoftapCallError("hotspot", "RETURN_DEADLINE", "The glasses did not return in time")
4270
+ }
4271
+ },
4272
+ })
4273
+ if (attempt.cancelled || this.softapAttempt !== attempt) return
4274
+ this.emitSoftapProgress(attempt, transport.progress())
4275
+ const current = acsMeetingService.getState()
4276
+ this.sendToMiniapp(attempt.packageName, {
4277
+ type: MiniappResponseType.MEETING_STATE,
4278
+ ...current,
4279
+ softap: transport.progress(),
4280
+ ...this.softapRecoveryFields(attempt),
4281
+ })
4282
+ softapTrace("softap_recovery_live", {
4283
+ attempt: attempt.id,
4284
+ mediaGeneration: transport.currentMediaGeneration(),
4285
+ elapsedMs: Date.now() - lostAt,
4286
+ })
4287
+ } catch (recoveryError) {
4288
+ if (attempt.cancelled || this.softapAttempt !== attempt) return
4289
+ const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
4290
+ softapTraceFailure("softap_recovery_failed", {
4291
+ attempt: attempt.id,
4292
+ reason: message,
4293
+ elapsedMs: Date.now() - lostAt,
4294
+ })
4295
+ const current = acsMeetingService.getState()
4296
+ this.sendToMiniapp(attempt.packageName, {
4297
+ type: MiniappResponseType.MEETING_STATE,
4298
+ ...current,
4299
+ state: "error",
4300
+ error: `SOFTAP_NETWORK_LOST: ${error.message}`,
4301
+ softap: transport.progress(),
4302
+ recovery: {
4303
+ active: false,
4304
+ generation: transport.currentMediaGeneration(),
4305
+ deadlineAt: attempt.recoveryDeadlineAt ?? undefined,
4306
+ phase: transport.currentPhase(),
4307
+ },
4308
+ })
4309
+ } finally {
4310
+ attempt.recoveryActive = false
4311
+ if (attempt.recovery === run) attempt.recovery = null
4312
+ }
4313
+ })()
4314
+ attempt.recovery = run
4315
+ }
4316
+
4317
+ /**
4318
+ * Wait for a link the hotspot commands can actually use.
4319
+ *
4320
+ * The hotspot goes away a couple of seconds before the phone declares the BLE link dead, so the
4321
+ * snapshot at loss time still reads "ready". Taking that at face value sent `set_hotspot_state`
4322
+ * into a dying link twice, and each one burned its full timeout before the wearer was even back.
4323
+ * Requiring the link to hold steady turns that into waiting, which is what the budget is for.
4324
+ */
4325
+ private async waitForGlassesReturn(timeoutMs: number, cancelled: () => boolean): Promise<boolean> {
4326
+ const deadlineAt = Date.now() + timeoutMs
4327
+ while (!cancelled()) {
4328
+ const remaining = deadlineAt - Date.now()
4329
+ if (remaining <= 0) return false
4330
+ if (!isGlassesReady(useGlassesStore.getState().connection) && !(await this.awaitGlassesReady(remaining, cancelled))) {
4331
+ return false
4332
+ }
4333
+ const settleMs = Math.min(GLASSES_LINK_SETTLE_MS, Math.max(0, deadlineAt - Date.now()))
4334
+ if (await this.glassesLinkHoldsSteady(settleMs, cancelled)) return true
4335
+ }
4336
+ return false
4337
+ }
4338
+
4339
+ private awaitGlassesReady(timeoutMs: number, cancelled: () => boolean): Promise<boolean> {
4340
+ const controller = new AbortController()
4341
+ const poll = setInterval(() => {
4342
+ if (cancelled()) controller.abort()
4343
+ }, 250)
4344
+ return waitForGlassesReady({
4345
+ getConnection: () => useGlassesStore.getState().connection,
4346
+ subscribe: (listener) => useGlassesStore.subscribe((state) => state.connection, listener),
4347
+ timeoutMs,
4348
+ signal: controller.signal,
4349
+ }).finally(() => clearInterval(poll))
4350
+ }
4351
+
4352
+ /** True when the link stays ready for [ms]; false the moment it drops again. */
4353
+ private glassesLinkHoldsSteady(ms: number, cancelled: () => boolean): Promise<boolean> {
4354
+ if (ms <= 0) return Promise.resolve(isGlassesReady(useGlassesStore.getState().connection))
4355
+ return new Promise<boolean>((resolve) => {
4356
+ let done = false
4357
+ const settle = (steady: boolean) => {
4358
+ if (done) return
4359
+ done = true
4360
+ clearTimeout(timer)
4361
+ clearInterval(poll)
4362
+ unsubscribe()
4363
+ resolve(steady)
4364
+ }
4365
+ const unsubscribe = useGlassesStore.subscribe(
4366
+ (state) => state.connection,
4367
+ (connection) => {
4368
+ if (!isGlassesReady(connection)) settle(false)
4369
+ },
4370
+ )
4371
+ const poll = setInterval(() => {
4372
+ if (cancelled()) settle(false)
4373
+ }, 250)
4374
+ const timer = setTimeout(() => settle(true), ms)
4375
+ })
4376
+ }
4377
+
4159
4378
  /**
4160
4379
  * Cancels a SoftAP attempt and does not return until everything it owns has been released.
4161
4380
  *
@@ -4198,7 +4417,11 @@ class LocalMiniappRuntime {
4198
4417
  afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
4199
4418
  })
4200
4419
  }, SOFTAP_CLEANUP_STALL_LOG_MS)
4201
- void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
4420
+ void Promise.allSettled([
4421
+ attempt.teardown,
4422
+ attempt.body ?? Promise.resolve(),
4423
+ attempt.recovery ?? Promise.resolve(),
4424
+ ]).then(() => {
4202
4425
  clearTimeout(stallWatchdog)
4203
4426
  attempt.markSettled()
4204
4427
  if (this.softapAttempt === attempt) this.softapAttempt = null
@@ -4282,7 +4505,16 @@ class LocalMiniappRuntime {
4282
4505
  // so this is the caller's business, not a reason to block the next call.
4283
4506
  endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? {mode} : {}))
4284
4507
  const undoFailures = transport.lastTeardownFailures()
4285
- if (undoFailures.length > 0) failures.push(`could not release ${undoFailures.join(", ")}`)
4508
+ // A hotspot that did not ACK off is leftover ON — the next join wants it on and will
4509
+ // call setHotspotState(true). Refusing that join is how we got hotspot_off_ack_timeout
4510
+ // as a wearer-facing "power-cycle the glasses" dead end.
4511
+ const blockingUndos = undoFailures.filter((step) => step !== "hotspot")
4512
+ if (blockingUndos.length > 0) failures.push(`could not release ${blockingUndos.join(", ")}`)
4513
+ if (undoFailures.includes("hotspot")) {
4514
+ console.warn("[LocalMiniappRuntime] glasses hotspot undo timed out; next join will raise it again", {
4515
+ attempt: attempt.id,
4516
+ })
4517
+ }
4286
4518
  }
4287
4519
  const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork())
4288
4520
  if (scopedFailure) {
@@ -4384,11 +4616,30 @@ class LocalMiniappRuntime {
4384
4616
  if (endFailure) throw endFailure
4385
4617
  }
4386
4618
 
4619
+ /**
4620
+ * Send one hotspot command, re-sending it once if a session refresh cancelled it.
4621
+ *
4622
+ * The retry lives inside the unit chained onto [glassesHotspotCommand] rather than around it,
4623
+ * which is what holds the invariant: one logical hotspot operation issues at most two native
4624
+ * commands, and no later operation runs between them. A retry outside the queue would let an
4625
+ * `on` land between the two halves of an `off` and leave the glasses in the opposite state.
4626
+ *
4627
+ * Re-sending is free because ASG compares against current state first and answers a same-state
4628
+ * command with its current status instead of cycling the AP.
4629
+ */
4387
4630
  private setGlassesHotspotState(enabled: boolean) {
4388
- const run = this.glassesHotspotCommand.then(
4389
- () => BluetoothSdk.setHotspotState(enabled),
4390
- () => BluetoothSdk.setHotspotState(enabled),
4391
- )
4631
+ const send = async () => {
4632
+ try {
4633
+ return await BluetoothSdk.setHotspotState(enabled)
4634
+ } catch (error) {
4635
+ const code = (error as {code?: string} | null | undefined)?.code
4636
+ if (!code || !HOTSPOT_SESSION_REFRESH_CODES.has(code)) throw error
4637
+ softapTrace("softap_hotspot_session_retry", {enabled, code})
4638
+ await new Promise<void>((resolve) => setTimeout(resolve, HOTSPOT_SESSION_RETRY_DELAY_MS))
4639
+ return await BluetoothSdk.setHotspotState(enabled)
4640
+ }
4641
+ }
4642
+ const run = this.glassesHotspotCommand.then(send, send)
4392
4643
  this.glassesHotspotCommand = run.then(
4393
4644
  () => undefined,
4394
4645
  () => undefined,
@@ -4406,9 +4657,9 @@ class LocalMiniappRuntime {
4406
4657
  * failure — the listener was still inside its 3s tombstone and the glasses AP was still up.
4407
4658
  *
4408
4659
  * A bound that expires is not success. It means the resource is still held by a call nobody is
4409
- * in any more, so it is taken by force and re-verified. Only if *that* fails does the next join
4410
- * get refused, which is a worse outcome than a slow teardown and a better one than two calls
4411
- * sharing a port.
4660
+ * in any more, so it is taken by force and re-verified. An ingest port that is still held
4661
+ * refuses the next join. A glasses hotspot that missed its off-ack does not the next join
4662
+ * wants the AP up and will raise it again.
4412
4663
  *
4413
4664
  * @returns failures to record; a non-empty list refuses the next join.
4414
4665
  */
@@ -4501,9 +4752,17 @@ class LocalMiniappRuntime {
4501
4752
  SOFTAP_HOTSPOT_OFF_ACK_MS,
4502
4753
  "hotspot_off_ack_timeout",
4503
4754
  )
4504
- if (status.state !== "disabled") failures.push(`the glasses hotspot is still ${status.state}`)
4755
+ if (status.state !== "disabled") {
4756
+ console.warn("[LocalMiniappRuntime] glasses hotspot still reported after disable; next join will raise it", {
4757
+ attempt: attempt.id,
4758
+ state: status.state,
4759
+ })
4760
+ }
4505
4761
  } catch (error) {
4506
- failures.push(`glasses hotspot off: ${error instanceof Error ? error.message : String(error)}`)
4762
+ console.warn("[LocalMiniappRuntime] glasses hotspot off ack missed; next join will raise it", {
4763
+ attempt: attempt.id,
4764
+ reason: error instanceof Error ? error.message : String(error),
4765
+ })
4507
4766
  }
4508
4767
  }
4509
4768
  softapTrace("softap_teardown_forced_done", {
@@ -5766,13 +6025,20 @@ class LocalMiniappRuntime {
5766
6025
  const toRemove: string[] = []
5767
6026
 
5768
6027
  for (const [packageName, app] of this.connectedApps) {
5769
- const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD)
5770
- if (liveness.shouldUnregister) {
5771
- console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`)
5772
- toRemove.push(packageName)
5773
- continue
6028
+ const holdPingLiveness = shouldHoldMiniappPingLiveness({
6029
+ packageName,
6030
+ softapPackageName: this.softapAttempt?.packageName,
6031
+ softapCancelled: this.softapAttempt?.cancelled,
6032
+ })
6033
+ if (!holdPingLiveness) {
6034
+ const liveness = advanceMiniappPingLiveness(app.unansweredPingRounds, PING_TIMEOUT_THRESHOLD)
6035
+ if (liveness.shouldUnregister) {
6036
+ console.warn(`${LOG_TAG}: ${packageName} missed ${PING_TIMEOUT_THRESHOLD} pings, unregistering`)
6037
+ toRemove.push(packageName)
6038
+ continue
6039
+ }
6040
+ app.unansweredPingRounds = liveness.unansweredPingRounds
5774
6041
  }
5775
- app.unansweredPingRounds = liveness.unansweredPingRounds
5776
6042
 
5777
6043
  // Send PING — SDK auto-replies with PONG
5778
6044
  this.sendToMiniapp(packageName, {
@@ -3,6 +3,24 @@ export interface MiniappPingLivenessDecision {
3
3
  unansweredPingRounds: number
4
4
  }
5
5
 
6
+ /**
7
+ * SoftAP join can spend a minute in native `prepareAgent` before the glasses
8
+ * hotspot even turns on. The JS bundle stays alive but may miss pongs while
9
+ * ACS is on the native thread. Unregistering for missed pings in that window
10
+ * cancels the join and leaves the UI stuck on the hotspot row.
11
+ */
12
+ export function shouldHoldMiniappPingLiveness(args: {
13
+ packageName: string
14
+ softapPackageName?: string | null
15
+ softapCancelled?: boolean
16
+ }): boolean {
17
+ return Boolean(
18
+ args.softapPackageName &&
19
+ args.softapPackageName === args.packageName &&
20
+ !args.softapCancelled,
21
+ )
22
+ }
23
+
6
24
  /**
7
25
  * Advance liveness by one scheduled ping round.
8
26
  *