@mentra/engine 3.2.0-dev.213 → 3.2.0-dev.216

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.
@@ -12,6 +12,7 @@ interface OverrideEntry {
12
12
  fov: number
13
13
  roiPosition: "center" | "bottom" | "top"
14
14
  order: number
15
+ releasing?: boolean
15
16
  }
16
17
 
17
18
  let leaseCounter = 0
@@ -41,6 +42,11 @@ export class PhoneCameraFovCoordinator {
41
42
 
42
43
  constructor(private readonly legacyRestartSettleMs = LEGACY_CAMERA_RESTART_SETTLE_MS) {}
43
44
 
45
+ /** Capture a barrier for FOV work already submitted, excluding future requests. */
46
+ whenSettled(): Promise<void> {
47
+ return this.queue.then(() => undefined)
48
+ }
49
+
44
50
  /** Report-safe FOV ownership snapshot for incident diagnostics. */
45
51
  getDiagnosticSnapshot(): Record<string, unknown> {
46
52
  return {
@@ -114,6 +120,11 @@ export class PhoneCameraFovCoordinator {
114
120
  if (!leaseId) return
115
121
  const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)
116
122
  if (!entry) return
123
+ if (entry.releasing) {
124
+ const owner = [...this.overrides.entries()].find(([, candidate]) => candidate === entry)![0]
125
+ await this.releaseOverride(owner)
126
+ return
127
+ }
117
128
  await BluetoothSdk.setCameraFovOverride({
118
129
  leaseId,
119
130
  fov: entry.fov,
@@ -124,56 +135,69 @@ export class PhoneCameraFovCoordinator {
124
135
  })
125
136
  }
126
137
 
127
- releaseForApp(packageName: string): Promise<void> {
138
+ releaseForApp(packageName: string, cameraCleanup: Promise<void> = Promise.resolve()): Promise<void> {
139
+ // Reserve the queue slot now, before waiting for capture teardown. A respawn
140
+ // must not install a new crop that this old incarnation's cleanup releases.
128
141
  return this.enqueue(async () => {
129
- const removed = this.overrides.get(packageName)
130
- if (!removed) return
131
- if (removed.leaseId !== this.effectiveLeaseId) {
132
- this.overrides.delete(packageName)
133
- return
134
- }
142
+ await cameraCleanup
143
+ await this.releaseOverride(packageName)
144
+ })
145
+ }
135
146
 
136
- const next = [...this.overrides.entries()]
137
- .filter(([candidatePackage]) => candidatePackage !== packageName)
138
- .map(([, entry]) => entry)
139
- .sort((a, b) => b.order - a.order)[0]
140
- if (this.legacyMode) {
141
- if (next) {
142
- await this.applyLegacyFov({fov: next.fov, roiPosition: next.roiPosition})
143
- this.overrides.delete(packageName)
144
- this.effectiveLeaseId = next.leaseId
145
- } else {
146
- // Legacy commands are one-way and have no lease-release protocol.
147
- // Ask native to replay its unchanged persistent base setting, then
148
- // probe the modern override path again for the next ownership cycle.
149
- await BluetoothSdk.restoreLegacyCameraFov()
150
- await this.waitForLegacyCameraRestart()
151
- this.overrides.delete(packageName)
152
- this.clearRefresh()
153
- this.effectiveLeaseId = undefined
154
- this.legacyMode = false
155
- }
156
- return
157
- }
147
+ private async releaseOverride(packageName: string): Promise<void> {
148
+ const removed = this.overrides.get(packageName)
149
+ if (!removed) return
150
+ if (removed.leaseId !== this.effectiveLeaseId) {
151
+ this.overrides.delete(packageName)
152
+ return
153
+ }
154
+
155
+ // A failed release must not renew the closing owner's lease forever. Keep
156
+ // enough identity to retry on reconnect, but let ASG's existing TTL restore
157
+ // the saved crop if the phone cannot complete cleanup (including camera_busy).
158
+ removed.releasing = true
159
+ this.clearRefresh()
160
+
161
+ const next = [...this.overrides.entries()]
162
+ .filter(([candidatePackage, entry]) => candidatePackage !== packageName && !entry.releasing)
163
+ .map(([, entry]) => entry)
164
+ .sort((a, b) => b.order - a.order)[0]
165
+ if (this.legacyMode) {
158
166
  if (next) {
159
- await BluetoothSdk.setCameraFovOverride({
160
- leaseId: next.leaseId,
161
- fov: next.fov,
162
- roiPosition: next.roiPosition,
163
- ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,
164
- })
165
- // Commit phone-side ownership only after ASG accepted the replacement. On failure the
166
- // closing app remains effective here, allowing unregister/reconnect cleanup to retry.
167
+ await this.applyLegacyFov({fov: next.fov, roiPosition: next.roiPosition})
167
168
  this.overrides.delete(packageName)
168
169
  this.effectiveLeaseId = next.leaseId
169
- this.scheduleRefresh()
170
170
  } else {
171
- await BluetoothSdk.releaseCameraFovOverride(removed.leaseId)
171
+ // Legacy commands are one-way and have no lease-release protocol.
172
+ // Ask native to replay its unchanged persistent base setting, then
173
+ // probe the modern override path again for the next ownership cycle.
174
+ await BluetoothSdk.restoreLegacyCameraFov()
175
+ await this.waitForLegacyCameraRestart()
172
176
  this.overrides.delete(packageName)
173
177
  this.clearRefresh()
174
178
  this.effectiveLeaseId = undefined
179
+ this.legacyMode = false
175
180
  }
176
- })
181
+ return
182
+ }
183
+ if (next) {
184
+ await BluetoothSdk.setCameraFovOverride({
185
+ leaseId: next.leaseId,
186
+ fov: next.fov,
187
+ roiPosition: next.roiPosition,
188
+ ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,
189
+ })
190
+ // Commit phone-side ownership only after ASG accepted the replacement. On failure the
191
+ // closing app remains effective here, allowing unregister/reconnect cleanup to retry.
192
+ this.overrides.delete(packageName)
193
+ this.effectiveLeaseId = next.leaseId
194
+ this.scheduleRefresh()
195
+ } else {
196
+ await BluetoothSdk.releaseCameraFovOverride(removed.leaseId)
197
+ this.overrides.delete(packageName)
198
+ this.clearRefresh()
199
+ this.effectiveLeaseId = undefined
200
+ }
177
201
  }
178
202
 
179
203
  private scheduleRefresh(): void {
@@ -185,7 +209,7 @@ export class PhoneCameraFovCoordinator {
185
209
  void this.enqueue(async () => {
186
210
  if (this.effectiveLeaseId !== leaseId) return
187
211
  const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)
188
- if (!entry) return
212
+ if (!entry || entry.releasing) return
189
213
  await BluetoothSdk.setCameraFovOverride({
190
214
  leaseId,
191
215
  fov: entry.fov,
@@ -36,6 +36,7 @@
36
36
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
37
37
  import type {StreamResolvedConfig, StreamStartRequest, StreamStatusEvent} from "@mentra/bluetooth-sdk/internal"
38
38
  import {isGlassesConnected} from "./GlassesReadiness"
39
+ import {phoneCameraFovCoordinator} from "./PhoneCameraFovCoordinator"
39
40
  import {useGlassesStore} from "../stores/glasses"
40
41
 
41
42
  import {slimStreamStatusEvent, streamStatusSignature} from "./slimStreamStatus"
@@ -210,6 +211,7 @@ export class PhoneStreamCoordinator {
210
211
  private idCounter = 0
211
212
  private readonly timings: TimingConfig
212
213
  private readonly linkSource: GlassesLinkSource
214
+ private readonly pendingCameraChanges: () => Promise<void>
213
215
  private unsubscribeLink: (() => void) | null = null
214
216
  private suspended: SuspendedState | null = null
215
217
  /**
@@ -233,9 +235,13 @@ export class PhoneStreamCoordinator {
233
235
  /** Send full resolvedConfig only once per stream session. */
234
236
  private resolvedConfigForwarded = false
235
237
 
236
- constructor(timings: CoordinatorTimings = {}, deps: {linkSource?: GlassesLinkSource} = {}) {
238
+ constructor(
239
+ timings: CoordinatorTimings = {},
240
+ deps: {linkSource?: GlassesLinkSource; pendingCameraChanges?: () => Promise<void>} = {},
241
+ ) {
237
242
  this.timings = {...DEFAULT_TIMINGS, ...timings}
238
243
  this.linkSource = deps.linkSource ?? storeLinkSource
244
+ this.pendingCameraChanges = deps.pendingCameraChanges ?? (() => phoneCameraFovCoordinator.whenSettled())
239
245
  }
240
246
 
241
247
  /**
@@ -322,7 +328,12 @@ export class PhoneStreamCoordinator {
322
328
  throw new StreamConflictError("STREAM_URL_REQUIRED", "streamUrl is required")
323
329
  }
324
330
  this.assertGlassesConnected()
331
+ // Capture before entering the stream queue: a later FOV release may itself
332
+ // await stop(), and must not become a circular dependency of this start.
333
+ const cameraReady = this.pendingCameraChanges()
325
334
  return this.runExclusive(async () => {
335
+ await cameraReady
336
+ this.assertGlassesConnected()
326
337
  if (this.current) {
327
338
  throw new StreamConflictError(
328
339
  "STREAM_ALREADY_ACTIVE",
@@ -365,6 +376,7 @@ export class PhoneStreamCoordinator {
365
376
  }
366
377
 
367
378
  async startManaged(packageName: string, opts: StartManagedOptions): Promise<ManagedStartResult> {
379
+ const cameraReady = this.pendingCameraChanges()
368
380
  const startupStartedAtMs = Date.now()
369
381
  // streamId doesn't exist yet — it's minted a few lines below, once we
370
382
  // know this is a fresh provision rather than a join onto an existing one.
@@ -383,6 +395,8 @@ export class PhoneStreamCoordinator {
383
395
  | {kind: "fresh"; entry: ManagedEntry}
384
396
 
385
397
  const decision = await this.runExclusive(async (): Promise<JoinDecision> => {
398
+ await cameraReady
399
+ this.assertGlassesConnected()
386
400
  if (this.current && this.current.kind === "unmanaged") {
387
401
  throw new StreamConflictError(
388
402
  "STREAM_ALREADY_ACTIVE",
@@ -26,6 +26,7 @@ export function slimStreamStatusEvent(
26
26
  if (event.sid) slim.sid = event.sid
27
27
  if (typeof event.revision === "number") slim.revision = event.revision
28
28
  if (typeof event.terminal === "boolean") slim.terminal = event.terminal
29
+ if ("willRetry" in event && typeof event.willRetry === "boolean") slim.willRetry = event.willRetry
29
30
  if (event.errorDetails) slim.errorDetails = event.errorDetails
30
31
  if (event.kind === "reconnect") {
31
32
  if ("reason" in event) slim.reason = event.reason