@mentra/engine 3.2.0-dev.221 → 3.2.0-dev.223
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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +360 -10
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +836 -19
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AudioPlaybackService.d.ts +6 -0
- package/build/services/AudioPlaybackService.d.ts.map +1 -1
- package/build/services/AudioPlaybackService.js +4 -3
- package/build/services/AudioPlaybackService.js.map +1 -1
- package/build/services/GlassesMicProbe.d.ts +78 -0
- package/build/services/GlassesMicProbe.d.ts.map +1 -0
- package/build/services/GlassesMicProbe.js +258 -0
- package/build/services/GlassesMicProbe.js.map +1 -0
- package/build/services/LocalMiniappRuntime.d.ts +83 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +714 -39
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSLogPipeline.d.ts +39 -0
- package/build/services/MentraJSLogPipeline.d.ts.map +1 -1
- package/build/services/MentraJSLogPipeline.js +59 -3
- package/build/services/MentraJSLogPipeline.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +2 -2
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MicStateCoordinator.d.ts +22 -0
- package/build/services/MicStateCoordinator.d.ts.map +1 -1
- package/build/services/MicStateCoordinator.js +34 -3
- package/build/services/MicStateCoordinator.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +8 -0
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +2 -0
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +365 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -0
- package/build/services/SoftapCallTransport.js +722 -0
- package/build/services/SoftapCallTransport.js.map +1 -0
- package/build/services/SoftapCleanupBarrier.d.ts +45 -0
- package/build/services/SoftapCleanupBarrier.d.ts.map +1 -0
- package/build/services/SoftapCleanupBarrier.js +51 -0
- package/build/services/SoftapCleanupBarrier.js.map +1 -0
- package/build/utils/pcm16.d.ts +35 -0
- package/build/utils/pcm16.d.ts.map +1 -0
- package/build/utils/pcm16.js +85 -0
- package/build/utils/pcm16.js.map +1 -0
- package/build/utils/softapTrace.d.ts +39 -0
- package/build/utils/softapTrace.d.ts.map +1 -0
- package/build/utils/softapTrace.js +124 -0
- package/build/utils/softapTrace.js.map +1 -0
- package/package.json +7 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +2 -0
- package/src/services/AcsMeetingService.ts +956 -22
- package/src/services/AudioPlaybackService.ts +12 -3
- package/src/services/GlassesMicProbe.ts +300 -0
- package/src/services/LocalMiniappRuntime.ts +775 -42
- package/src/services/MentraJSLogPipeline.ts +70 -3
- package/src/services/MentraJSRouter.ts +2 -2
- package/src/services/MicStateCoordinator.ts +35 -3
- package/src/services/PhoneStreamCoordinator.ts +10 -0
- package/src/services/SoftapCallTransport.ts +928 -0
- package/src/services/SoftapCleanupBarrier.ts +72 -0
- package/src/utils/pcm16.ts +93 -0
- package/src/utils/softapTrace.ts +133 -0
|
@@ -81,7 +81,25 @@ import {resolveForegroundLocationPermission} from "./ForegroundLocationPermissio
|
|
|
81
81
|
import {advanceMiniappPingLiveness} from "./MiniappLiveness"
|
|
82
82
|
import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
|
|
83
83
|
import {LocalMiniappStorage} from "./LocalMiniappStorage"
|
|
84
|
-
import acsMeetingService, {
|
|
84
|
+
import acsMeetingService, {
|
|
85
|
+
parseAcsOutgoingVideo,
|
|
86
|
+
parseAcsVideoSource,
|
|
87
|
+
resolveAcsAudioSource,
|
|
88
|
+
type AcsOutgoingVideo,
|
|
89
|
+
type AcsVideoSource,
|
|
90
|
+
type MeetingState,
|
|
91
|
+
} from "./AcsMeetingService"
|
|
92
|
+
import {
|
|
93
|
+
createSoftapCallDeps,
|
|
94
|
+
SoftapCallError,
|
|
95
|
+
SoftapCallTransport,
|
|
96
|
+
SOFTAP_STEPS,
|
|
97
|
+
type SoftapProgress,
|
|
98
|
+
type SoftapTeardownMode,
|
|
99
|
+
} from "./SoftapCallTransport"
|
|
100
|
+
import {awaitCleanupBarrier} from "./SoftapCleanupBarrier"
|
|
101
|
+
import {softapTrace, softapTraceFailure} from "../utils/softapTrace"
|
|
102
|
+
import {PermissionFeatures, permissions} from "../facades/permissions"
|
|
85
103
|
|
|
86
104
|
// =============================================================================
|
|
87
105
|
// Types
|
|
@@ -168,23 +186,6 @@ function locationRateRank(rate: string | null | undefined): number {
|
|
|
168
186
|
return i >= 0 ? i : LOCATION_RATE_PRIORITY.indexOf("passive")
|
|
169
187
|
}
|
|
170
188
|
|
|
171
|
-
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {
|
|
172
|
-
return new Promise((resolve) => {
|
|
173
|
-
let settled = false
|
|
174
|
-
const done = (value: T | null) => {
|
|
175
|
-
if (settled) return
|
|
176
|
-
settled = true
|
|
177
|
-
BgTimer.clearTimeout(timer)
|
|
178
|
-
resolve(value)
|
|
179
|
-
}
|
|
180
|
-
const timer = BgTimer.setTimeout(() => done(null), timeoutMs)
|
|
181
|
-
promise.then(
|
|
182
|
-
(value) => done(value),
|
|
183
|
-
() => done(null),
|
|
184
|
-
)
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
189
|
const LOG_TAG = "LOCAL_MINIAPP"
|
|
189
190
|
const DIAGNOSTIC_MAX_LIST_ITEMS = 100
|
|
190
191
|
const DIAGNOSTIC_MAX_STRING_LENGTH = 512
|
|
@@ -219,6 +220,14 @@ const MINIAPP_AUTH_REFRESH_MIN_DELAY_MS = 5_000
|
|
|
219
220
|
const MINIAPP_AUTH_RETRY_BASE_MS = 1_000
|
|
220
221
|
const MINIAPP_AUTH_RETRY_MAX_MS = 15_000
|
|
221
222
|
const FOREGROUND_LIVENESS_PROBE_TIMEOUT_MS = 2_500
|
|
223
|
+
/**
|
|
224
|
+
* How long a SoftAP call waits for the first frame to reach ACS before failing the join.
|
|
225
|
+
*
|
|
226
|
+
* Generous because it covers the glasses' whole publish path — WHIP POST, host-only ICE, and the
|
|
227
|
+
* first decode — but bounded, since a call that connects and never paints has to fail as a join
|
|
228
|
+
* rather than sit in "connecting" while the wearer waits.
|
|
229
|
+
*/
|
|
230
|
+
const SOFTAP_FIRST_FRAME_MS = 20_000
|
|
222
231
|
const REQUEST_WIFI_SETUP_TYPE = "miniapp_request_wifi_setup"
|
|
223
232
|
// Unregister after this many missed pongs. Generous on purpose: a busy
|
|
224
233
|
// context (heavy interim translation traffic) or OS scheduling while idle can
|
|
@@ -374,6 +383,46 @@ function resetPermissionWarnings(packageName: string): void {
|
|
|
374
383
|
}
|
|
375
384
|
}
|
|
376
385
|
|
|
386
|
+
/**
|
|
387
|
+
* One attempt to start a SoftAP call, from the first permission check to the last release.
|
|
388
|
+
*
|
|
389
|
+
* The unit is the attempt rather than the transport because the transport only exists for the
|
|
390
|
+
* second half of it. Everything the wearer can cancel — the Nearby-devices prompt, the wait for
|
|
391
|
+
* mobile data, the ACS sign-in — happens before `transport.start()`, and each of those leaves
|
|
392
|
+
* something behind (a signed-in agent, a process-wide cellular pin) that only the attempt knows
|
|
393
|
+
* about.
|
|
394
|
+
*/
|
|
395
|
+
type SoftapAttempt = {
|
|
396
|
+
id: number
|
|
397
|
+
packageName: string
|
|
398
|
+
startedAt: number
|
|
399
|
+
/** Set by leave, end, or a superseding join. Checked after every await the join performs. */
|
|
400
|
+
cancelled: boolean
|
|
401
|
+
/** Set only after the preceding attempt has fully settled. */
|
|
402
|
+
ownsResources: boolean
|
|
403
|
+
transport: SoftapCallTransport | null
|
|
404
|
+
/** The checklist as the miniapp last saw it; preflight rows live here until `start()` takes over. */
|
|
405
|
+
progress: SoftapProgress
|
|
406
|
+
scopedLostUnsub: (() => void) | null
|
|
407
|
+
/**
|
|
408
|
+
* The join itself, settled either way. A native call still in flight owns state the teardown
|
|
409
|
+
* cannot see — a `createCallAgent` mid-sign-in — so the barrier covers this as well.
|
|
410
|
+
*/
|
|
411
|
+
body: Promise<void> | null
|
|
412
|
+
/** Single-flight teardown, so a leave and the join's own failure path cannot both unwind. */
|
|
413
|
+
teardown: Promise<void> | null
|
|
414
|
+
/** Opens only once the attempt *and* its cleanup are finished. The next join waits on it. */
|
|
415
|
+
settled: Promise<void>
|
|
416
|
+
settledDone: boolean
|
|
417
|
+
markSettled: () => void
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** How long a stalled SoftAP cleanup goes unremarked before it is logged. It is never abandoned. */
|
|
421
|
+
const SOFTAP_CLEANUP_STALL_LOG_MS = 10_000
|
|
422
|
+
|
|
423
|
+
/** How long the next call waits for the previous one's cleanup before telling the wearer about it. */
|
|
424
|
+
const SOFTAP_CLEANUP_NARRATE_AFTER_MS = 250
|
|
425
|
+
|
|
377
426
|
// =============================================================================
|
|
378
427
|
// LocalMiniappRuntime
|
|
379
428
|
// =============================================================================
|
|
@@ -381,6 +430,24 @@ function resetPermissionWarnings(packageName: string): void {
|
|
|
381
430
|
class LocalMiniappRuntime {
|
|
382
431
|
private static instance: LocalMiniappRuntime | null = null
|
|
383
432
|
|
|
433
|
+
/**
|
|
434
|
+
* The SoftAP call being started or running, if any.
|
|
435
|
+
*
|
|
436
|
+
* An *attempt*, not a transport, because the cancellable part of a SoftAP call begins long
|
|
437
|
+
* before the transport exists: a Nearby-devices prompt and an ACS sign-in run first, and a
|
|
438
|
+
* wearer who taps Cancel during either of them has to be obeyed. The attempt is registered
|
|
439
|
+
* before the first `await` and is what every later stage checks itself against.
|
|
440
|
+
*/
|
|
441
|
+
private softapAttempt: SoftapAttempt | null = null
|
|
442
|
+
private softapAttemptSeq = 0
|
|
443
|
+
/**
|
|
444
|
+
* Why the previous SoftAP call's cleanup failed, if it did.
|
|
445
|
+
*
|
|
446
|
+
* Read once by the next join, which refuses rather than building on leaked state: a hotspot that
|
|
447
|
+
* would not turn off is precisely what makes the next call fail somewhere far less legible.
|
|
448
|
+
*/
|
|
449
|
+
private softapCleanupError: string | null = null
|
|
450
|
+
|
|
384
451
|
/** Connected miniapps keyed by packageName. */
|
|
385
452
|
private connectedApps: Map<string, ConnectedMiniapp> = new Map()
|
|
386
453
|
|
|
@@ -1327,6 +1394,9 @@ class LocalMiniappRuntime {
|
|
|
1327
1394
|
case MiniappRequestType.MEETING_LEAVE:
|
|
1328
1395
|
void this.handleMeetingLeave(packageName, requestId)
|
|
1329
1396
|
break
|
|
1397
|
+
case MiniappRequestType.MEETING_END:
|
|
1398
|
+
void this.handleMeetingEnd(packageName, requestId)
|
|
1399
|
+
break
|
|
1330
1400
|
case MiniappRequestType.MEETING_SET_MUTED:
|
|
1331
1401
|
void this.handleMeetingSetMuted(packageName, payload, requestId)
|
|
1332
1402
|
break
|
|
@@ -1397,7 +1467,7 @@ class LocalMiniappRuntime {
|
|
|
1397
1467
|
// Register if not already
|
|
1398
1468
|
const existing = this.connectedApps.get(packageName)
|
|
1399
1469
|
if (!existing) {
|
|
1400
|
-
console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName},
|
|
1470
|
+
console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName}, dropping — isolate already torn down`)
|
|
1401
1471
|
return
|
|
1402
1472
|
}
|
|
1403
1473
|
|
|
@@ -1423,37 +1493,25 @@ class LocalMiniappRuntime {
|
|
|
1423
1493
|
existing.authDelivered = false
|
|
1424
1494
|
this.clearMiniappAuthDeliveryRetry(packageName)
|
|
1425
1495
|
const authPromise = this.requestMiniappAuth(packageName)
|
|
1426
|
-
const initialAuth = await withTimeout(authPromise, 1_500)
|
|
1427
|
-
const userId = initialAuth?.mentraUserId ?? ""
|
|
1428
|
-
if (initialAuth) {
|
|
1429
|
-
existing.authDelivered = true
|
|
1430
|
-
this.scheduleMiniappAuthRefresh(packageName, initialAuth)
|
|
1431
|
-
}
|
|
1432
1496
|
|
|
1497
|
+
// Send CONNECT_ACK in this turn, before any await. A 1.5s auth wait let a
|
|
1498
|
+
// concurrent unregister (dev respawn, tray stop, glasses-not-ready teardown)
|
|
1499
|
+
// drop the ACK; Mentra Call then dies with CONNECT_ACK timeout even though
|
|
1500
|
+
// CONNECT landed. Auth still arrives on AUTH_UPDATE.
|
|
1433
1501
|
this.sendToMiniapp(
|
|
1434
1502
|
packageName,
|
|
1435
1503
|
{
|
|
1436
1504
|
type: MiniappResponseType.CONNECT_ACK,
|
|
1437
|
-
userId,
|
|
1505
|
+
userId: "",
|
|
1438
1506
|
packageName,
|
|
1439
1507
|
capabilities,
|
|
1440
1508
|
permissions: declaredPermissions,
|
|
1441
1509
|
configuration: getMiniappConfiguration(packageName),
|
|
1442
1510
|
hostFeatures: {captureAudio: true},
|
|
1443
|
-
...(initialAuth ? {auth: initialAuth} : {}),
|
|
1444
1511
|
},
|
|
1445
1512
|
requestId,
|
|
1446
1513
|
)
|
|
1447
|
-
|
|
1448
|
-
// The mint timed out or (more importantly) REJECTED — the latter happens
|
|
1449
|
-
// when the cloud client hasn't finished its first-boot Core token exchange
|
|
1450
|
-
// yet, which is exactly the case when a dev miniapp is scanned/launched
|
|
1451
|
-
// right after app start. CONNECT_ACK already went out without auth; keep
|
|
1452
|
-
// trying to mint (and re-drive on cloud-connect) so the app authenticates
|
|
1453
|
-
// to its backend on its own, instead of staying dead until a full manual
|
|
1454
|
-
// Cloud V2 reconnect re-runs the whole handshake.
|
|
1455
|
-
this.deliverInitialMiniappAuth(packageName, authPromise, 0)
|
|
1456
|
-
}
|
|
1514
|
+
this.deliverInitialMiniappAuth(packageName, authPromise, 0)
|
|
1457
1515
|
this.sendCloudStatusToMiniapp(packageName)
|
|
1458
1516
|
|
|
1459
1517
|
// Handshake complete — unblock any launcher.waitForConnect() callers.
|
|
@@ -3633,13 +3691,21 @@ class LocalMiniappRuntime {
|
|
|
3633
3691
|
}
|
|
3634
3692
|
const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
|
|
3635
3693
|
const token = typeof payload.token === "string" ? payload.token : ""
|
|
3636
|
-
const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
|
|
3637
|
-
const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
|
|
3638
3694
|
const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
|
|
3639
|
-
if (!meetingUrl || !token
|
|
3695
|
+
if (!meetingUrl || !token) {
|
|
3640
3696
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3641
3697
|
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3642
|
-
message: "meetingUrl
|
|
3698
|
+
message: "meetingUrl and token are required",
|
|
3699
|
+
})
|
|
3700
|
+
return
|
|
3701
|
+
}
|
|
3702
|
+
let videoSource: AcsVideoSource
|
|
3703
|
+
try {
|
|
3704
|
+
videoSource = parseAcsVideoSource(payload.videoSource)
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3707
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3708
|
+
message: error instanceof Error ? error.message : "Invalid meeting video source",
|
|
3643
3709
|
})
|
|
3644
3710
|
return
|
|
3645
3711
|
}
|
|
@@ -3654,10 +3720,47 @@ class LocalMiniappRuntime {
|
|
|
3654
3720
|
})
|
|
3655
3721
|
return
|
|
3656
3722
|
}
|
|
3723
|
+
// SoftAP is a sequence, not a single call: the hotspot and the scoped network have to exist
|
|
3724
|
+
// before the ACS join binds a listener, and the glasses can only be told where to publish
|
|
3725
|
+
// after that. The miniapp asks for the transport and the host owns the ordering.
|
|
3726
|
+
if (videoSource.type === "softap") {
|
|
3727
|
+
// The miniapp's `requestId` is the only identifier the two logs share until the attempt
|
|
3728
|
+
// exists, so it is what joins the miniapp's own join line to the host's.
|
|
3729
|
+
softapTrace("meeting_join_request", {
|
|
3730
|
+
packageName,
|
|
3731
|
+
requestId: requestId ?? "none",
|
|
3732
|
+
transport: videoSource.type,
|
|
3733
|
+
hasDisplayName: Boolean(displayName),
|
|
3734
|
+
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
3735
|
+
})
|
|
3736
|
+
const startedAt = Date.now()
|
|
3737
|
+
try {
|
|
3738
|
+
const state = await this.joinSoftapMeeting(packageName, {meetingUrl, token, displayName, video})
|
|
3739
|
+
softapTrace("meeting_join_result", {
|
|
3740
|
+
packageName,
|
|
3741
|
+
requestId: requestId ?? "none",
|
|
3742
|
+
state: state.state,
|
|
3743
|
+
micTransport: state.micTransport ?? "unknown",
|
|
3744
|
+
durationMs: Date.now() - startedAt,
|
|
3745
|
+
})
|
|
3746
|
+
this.sendResult(packageName, requestId, true, state)
|
|
3747
|
+
} catch (error) {
|
|
3748
|
+
softapTraceFailure("meeting_join_result", {
|
|
3749
|
+
packageName,
|
|
3750
|
+
requestId: requestId ?? "none",
|
|
3751
|
+
step: error instanceof SoftapCallError ? error.step : "unknown",
|
|
3752
|
+
code: error instanceof SoftapCallError ? error.code : "INTERNAL",
|
|
3753
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3754
|
+
durationMs: Date.now() - startedAt,
|
|
3755
|
+
})
|
|
3756
|
+
throw error
|
|
3757
|
+
}
|
|
3758
|
+
return
|
|
3759
|
+
}
|
|
3657
3760
|
const state = await acsMeetingService.join(packageName, {
|
|
3658
3761
|
meetingUrl,
|
|
3659
3762
|
token,
|
|
3660
|
-
|
|
3763
|
+
videoSource,
|
|
3661
3764
|
displayName,
|
|
3662
3765
|
...(video ? {video} : {}),
|
|
3663
3766
|
})
|
|
@@ -3670,11 +3773,583 @@ class LocalMiniappRuntime {
|
|
|
3670
3773
|
}
|
|
3671
3774
|
}
|
|
3672
3775
|
|
|
3776
|
+
/**
|
|
3777
|
+
* Runs the SoftAP call sequence and returns the meeting state it reached.
|
|
3778
|
+
*
|
|
3779
|
+
* Everything is hung off one [SoftapAttempt] registered before the first `await`, because the
|
|
3780
|
+
* cancellable part of this starts immediately and the transport only exists halfway through.
|
|
3781
|
+
* The attempt is also what `meeting.leave` unwinds: leaving only the ACS call would strand the
|
|
3782
|
+
* glasses publishing into a listener nobody reads, the phone on a hotspot it no longer needs,
|
|
3783
|
+
* and this process pinned to cellular.
|
|
3784
|
+
*/
|
|
3785
|
+
private async joinSoftapMeeting(
|
|
3786
|
+
packageName: string,
|
|
3787
|
+
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
|
|
3788
|
+
): Promise<MeetingState> {
|
|
3789
|
+
// Reserve before awaiting retirement: a second Start or Cancel must see this request,
|
|
3790
|
+
// including while it is waiting for its predecessor's native cleanup.
|
|
3791
|
+
const previous = this.softapAttempt
|
|
3792
|
+
const attempt = this.createSoftapAttempt(packageName)
|
|
3793
|
+
this.softapAttempt = attempt
|
|
3794
|
+
const body = this.runSoftapAttempt(attempt, previous, args)
|
|
3795
|
+
// Settled, never rejected: this exists so the *next* attempt can tell when this one's native
|
|
3796
|
+
// work has actually stopped, and a rejection here is already handled below.
|
|
3797
|
+
attempt.body = body.then(
|
|
3798
|
+
() => undefined,
|
|
3799
|
+
() => undefined,
|
|
3800
|
+
)
|
|
3801
|
+
try {
|
|
3802
|
+
return await body
|
|
3803
|
+
} catch (error) {
|
|
3804
|
+
softapTraceFailure("softap_attempt_failed", {
|
|
3805
|
+
attempt: attempt.id,
|
|
3806
|
+
step: error instanceof SoftapCallError ? error.step : "unknown",
|
|
3807
|
+
code: error instanceof SoftapCallError ? error.code : "INTERNAL",
|
|
3808
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
3809
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3810
|
+
})
|
|
3811
|
+
// Unwind whatever the attempt reached — including the preflight-owned agent and cellular
|
|
3812
|
+
// pin, which the transport never sees — before the failure surfaces.
|
|
3813
|
+
await this.retireSoftapAttempt({attempt}).catch(() => undefined)
|
|
3814
|
+
throw error
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
private createSoftapAttempt(packageName: string): SoftapAttempt {
|
|
3819
|
+
const id = ++this.softapAttemptSeq
|
|
3820
|
+
let resolveSettled!: () => void
|
|
3821
|
+
const settled = new Promise<void>((resolve) => {
|
|
3822
|
+
resolveSettled = resolve
|
|
3823
|
+
})
|
|
3824
|
+
const attempt: SoftapAttempt = {
|
|
3825
|
+
id,
|
|
3826
|
+
packageName,
|
|
3827
|
+
startedAt: Date.now(),
|
|
3828
|
+
cancelled: false,
|
|
3829
|
+
ownsResources: false,
|
|
3830
|
+
transport: null,
|
|
3831
|
+
progress: {
|
|
3832
|
+
traceId: `preflight-${id}`,
|
|
3833
|
+
phase: "starting",
|
|
3834
|
+
steps: SOFTAP_STEPS.map((step) => ({step, status: "pending" as const})),
|
|
3835
|
+
elapsedMs: 0,
|
|
3836
|
+
},
|
|
3837
|
+
scopedLostUnsub: null,
|
|
3838
|
+
body: null,
|
|
3839
|
+
teardown: null,
|
|
3840
|
+
settled,
|
|
3841
|
+
settledDone: false,
|
|
3842
|
+
markSettled: () => {
|
|
3843
|
+
if (attempt.settledDone) return
|
|
3844
|
+
attempt.settledDone = true
|
|
3845
|
+
softapTrace("softap_attempt_settled", {attempt: id, attemptAgeMs: Date.now() - attempt.startedAt})
|
|
3846
|
+
resolveSettled()
|
|
3847
|
+
},
|
|
3848
|
+
}
|
|
3849
|
+
softapTrace("softap_attempt_created", {attempt: id, packageName})
|
|
3850
|
+
return attempt
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* Throws if this attempt has been cancelled or superseded.
|
|
3855
|
+
*
|
|
3856
|
+
* Called after every `await` in the join. The wearer's Cancel cannot interrupt a native call
|
|
3857
|
+
* that is already in flight, so the next best thing — and the thing that actually makes Cancel
|
|
3858
|
+
* work — is to refuse to take another step once they have asked.
|
|
3859
|
+
*
|
|
3860
|
+
* @param after the await this checkpoint follows. The whole join surfaces as one CANCELLED
|
|
3861
|
+
* error, so without a name in the log there is no way to tell a Cancel during the permission
|
|
3862
|
+
* prompt from one during a 35-second Teams sign-in.
|
|
3863
|
+
*/
|
|
3864
|
+
private checkpointSoftapAttempt(attempt: SoftapAttempt, after: string): void {
|
|
3865
|
+
if (attempt.cancelled || this.softapAttempt !== attempt) {
|
|
3866
|
+
softapTraceFailure("softap_attempt_cancelled", {
|
|
3867
|
+
attempt: attempt.id,
|
|
3868
|
+
after,
|
|
3869
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
3870
|
+
// Two different causes with one outcome: the wearer asked to leave, or a second join
|
|
3871
|
+
// took the slot. They need different fixes, so they are not collapsed here.
|
|
3872
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
3873
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3874
|
+
})
|
|
3875
|
+
throw new SoftapCallError("hotspot", "CANCELLED", "The call was cancelled while it was starting")
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
/** Publishes a checklist snapshot, unless this attempt has already been superseded. */
|
|
3880
|
+
private emitSoftapProgress(attempt: SoftapAttempt, progress: SoftapProgress): void {
|
|
3881
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
3882
|
+
// A dropped checklist is why a stalled join used to look like a stuck UI: the wearer's last
|
|
3883
|
+
// row was written by an attempt that no longer owned the call, and nothing said so.
|
|
3884
|
+
softapTrace("softap_progress_dropped", {
|
|
3885
|
+
attempt: attempt.id,
|
|
3886
|
+
phase: progress.phase,
|
|
3887
|
+
traceId: progress.traceId,
|
|
3888
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
3889
|
+
current: this.softapAttempt?.id ?? "none",
|
|
3890
|
+
})
|
|
3891
|
+
return
|
|
3892
|
+
}
|
|
3893
|
+
attempt.progress = progress
|
|
3894
|
+
const current = acsMeetingService.getState()
|
|
3895
|
+
this.sendToMiniapp(attempt.packageName, {
|
|
3896
|
+
type: MiniappResponseType.MEETING_STATE,
|
|
3897
|
+
...current,
|
|
3898
|
+
// Before the ACS join there is no native state yet, so the phase reads `connecting`: the
|
|
3899
|
+
// join *is* in progress, and `idle` would make the miniapp think the call ended.
|
|
3900
|
+
state: current.state === "idle" ? "connecting" : current.state,
|
|
3901
|
+
softap: progress,
|
|
3902
|
+
})
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/**
|
|
3906
|
+
* Narrates preflight work on the first row while it is still pending.
|
|
3907
|
+
*
|
|
3908
|
+
* Permissions, mobile data, and the ACS sign-in take most of a minute between them and happen
|
|
3909
|
+
* before the transport exists, so without this the wearer watches five pending rows and
|
|
3910
|
+
* concludes the app has hung. No new step and no protocol change: it is the `hotspot` row's
|
|
3911
|
+
* detail, which the connecting screen renders as its one sub-line until step 1 starts.
|
|
3912
|
+
*/
|
|
3913
|
+
private narrateSoftapPreflight(attempt: SoftapAttempt, detail: string): void {
|
|
3914
|
+
this.emitSoftapProgress(attempt, {
|
|
3915
|
+
...attempt.progress,
|
|
3916
|
+
elapsedMs: Date.now() - attempt.startedAt,
|
|
3917
|
+
steps: attempt.progress.steps.map((step) => (step.step === "hotspot" ? {...step, detail} : step)),
|
|
3918
|
+
})
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
private async runSoftapAttempt(
|
|
3922
|
+
attempt: SoftapAttempt,
|
|
3923
|
+
previous: SoftapAttempt | null,
|
|
3924
|
+
args: {meetingUrl: string; token: string; displayName?: string; video?: AcsOutgoingVideo},
|
|
3925
|
+
): Promise<MeetingState> {
|
|
3926
|
+
const packageName = attempt.packageName
|
|
3927
|
+
if (previous) {
|
|
3928
|
+
softapTrace("softap_join_superseding", {
|
|
3929
|
+
previousAttempt: previous.id,
|
|
3930
|
+
previousAgeMs: Date.now() - previous.startedAt,
|
|
3931
|
+
previousPhase: previous.progress.phase,
|
|
3932
|
+
packageName,
|
|
3933
|
+
})
|
|
3934
|
+
await this.retireSoftapAttempt({attempt: previous}).catch(() => undefined)
|
|
3935
|
+
await awaitCleanupBarrier({
|
|
3936
|
+
settled: previous.settled,
|
|
3937
|
+
settledDone: previous.settledDone,
|
|
3938
|
+
narrate: (detail) => this.narrateSoftapPreflight(attempt, detail),
|
|
3939
|
+
narrateAfterMs: SOFTAP_CLEANUP_NARRATE_AFTER_MS,
|
|
3940
|
+
attempt: attempt.id,
|
|
3941
|
+
waitingFor: `attempt ${previous.id} teardown`,
|
|
3942
|
+
})
|
|
3943
|
+
}
|
|
3944
|
+
this.checkpointSoftapAttempt(attempt, "cleanup barrier")
|
|
3945
|
+
const cleanupError = this.softapCleanupError
|
|
3946
|
+
if (cleanupError) {
|
|
3947
|
+
// Report once, allowing a retry after the wearer power-cycles the glasses.
|
|
3948
|
+
this.softapCleanupError = null
|
|
3949
|
+
softapTraceFailure("softap_join_refused", {packageName, reason: cleanupError})
|
|
3950
|
+
throw new Error(`Previous call cleanup failed: ${cleanupError}. Power-cycle the glasses hotspot and try again.`)
|
|
3951
|
+
}
|
|
3952
|
+
attempt.ownsResources = true
|
|
3953
|
+
this.narrateSoftapPreflight(attempt, "Checking the Nearby devices permission…")
|
|
3954
|
+
const permissionStartedAt = Date.now()
|
|
3955
|
+
if (!(await permissions.check(PermissionFeatures.LOCAL_WIFI))) {
|
|
3956
|
+
this.checkpointSoftapAttempt(attempt, "permission check")
|
|
3957
|
+
softapTrace("softap_permission_prompt", {attempt: attempt.id, permission: "LOCAL_WIFI"})
|
|
3958
|
+
const granted = await permissions.request(PermissionFeatures.LOCAL_WIFI)
|
|
3959
|
+
this.checkpointSoftapAttempt(attempt, "permission prompt")
|
|
3960
|
+
softapTrace("softap_permission_result", {
|
|
3961
|
+
attempt: attempt.id,
|
|
3962
|
+
granted,
|
|
3963
|
+
// The prompt is modal, so this is the wearer's own reaction time and the single largest
|
|
3964
|
+
// unexplained gap in a first-run join.
|
|
3965
|
+
durationMs: Date.now() - permissionStartedAt,
|
|
3966
|
+
})
|
|
3967
|
+
if (!granted) {
|
|
3968
|
+
throw new Error("Nearby devices permission is required to join the glasses hotspot")
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
this.checkpointSoftapAttempt(attempt, "permissions")
|
|
3972
|
+
const transport = new SoftapCallTransport(
|
|
3973
|
+
createSoftapCallDeps({
|
|
3974
|
+
packageName,
|
|
3975
|
+
meetingUrl: args.meetingUrl,
|
|
3976
|
+
token: args.token,
|
|
3977
|
+
displayName: args.displayName,
|
|
3978
|
+
awaitFirstFrame: () => acsMeetingService.waitForFirstFrame(SOFTAP_FIRST_FRAME_MS),
|
|
3979
|
+
subsystems: {
|
|
3980
|
+
setHotspotState: async (enabled) => {
|
|
3981
|
+
const status = await BluetoothSdk.setHotspotState(enabled)
|
|
3982
|
+
if (status.state === "enabled") {
|
|
3983
|
+
return {state: status.state, ssid: status.ssid, password: status.password}
|
|
3984
|
+
}
|
|
3985
|
+
return {state: status.state}
|
|
3986
|
+
},
|
|
3987
|
+
joinScopedNetwork: (ssid, passphrase) => acsMeetingService.joinScopedNetwork(ssid, passphrase),
|
|
3988
|
+
leaveScopedNetwork: () => acsMeetingService.leaveScopedNetwork(),
|
|
3989
|
+
probeGateway: async () => {
|
|
3990
|
+
const verdict = await acsMeetingService.probeScopedGateway()
|
|
3991
|
+
// No native probe means no verdict, and the orchestrator must not read that as "down".
|
|
3992
|
+
return verdict ?? {reachable: true, detail: "probe unsupported on this host"}
|
|
3993
|
+
},
|
|
3994
|
+
awaitValidatedDefaultNetwork: () => acsMeetingService.awaitValidatedDefaultNetwork(),
|
|
3995
|
+
onGlassesStreamStatus: (listener) => {
|
|
3996
|
+
const sub = BluetoothSdk.addListener("stream_status", (event) => {
|
|
3997
|
+
listener({
|
|
3998
|
+
status: event.status,
|
|
3999
|
+
streamId: event.streamId,
|
|
4000
|
+
reason: "reason" in event && typeof event.reason === "string" ? event.reason : undefined,
|
|
4001
|
+
error: "error" in event && typeof event.error === "string" ? event.error : undefined,
|
|
4002
|
+
})
|
|
4003
|
+
})
|
|
4004
|
+
return () => sub.remove()
|
|
4005
|
+
},
|
|
4006
|
+
joinMeeting: (pkg, options) =>
|
|
4007
|
+
acsMeetingService.join(pkg, {
|
|
4008
|
+
meetingUrl: options.meetingUrl,
|
|
4009
|
+
token: options.token,
|
|
4010
|
+
videoSource: options.videoSource,
|
|
4011
|
+
displayName: options.displayName,
|
|
4012
|
+
...(args.video ? {video: args.video} : {}),
|
|
4013
|
+
}),
|
|
4014
|
+
leaveMeeting: (pkg) => acsMeetingService.leave(pkg),
|
|
4015
|
+
endMeeting: async (pkg) => {
|
|
4016
|
+
await acsMeetingService.endForEveryone(pkg)
|
|
4017
|
+
},
|
|
4018
|
+
ingestUrl: () => acsMeetingService.softApIngestUrl(),
|
|
4019
|
+
glassesLc3Uplink: () => acsMeetingService.glassesLc3UplinkActive(),
|
|
4020
|
+
startPublishing: (pkg, options) => phoneStreamCoordinator.startUnmanaged(pkg, options),
|
|
4021
|
+
stopPublishing: (pkg) => phoneStreamCoordinator.stop(pkg),
|
|
4022
|
+
},
|
|
4023
|
+
}),
|
|
4024
|
+
)
|
|
4025
|
+
attempt.transport = transport
|
|
4026
|
+
// A hotspot that vanishes mid-call is a real failure and a normal teardown is not, so both
|
|
4027
|
+
// guards are checked: the attempt must still be the current one (a stale event from a finished
|
|
4028
|
+
// call must never touch the next one) and it must not already be terminating. The unsubscribe
|
|
4029
|
+
// lives on the attempt, so retiring attempt N cannot clear attempt N+1's listener. Teardown
|
|
4030
|
+
// itself is left to the miniapp's terminal path, which calls `meeting.leave` — one owner.
|
|
4031
|
+
attempt.scopedLostUnsub = acsMeetingService.onScopedNetworkLost((error) => {
|
|
4032
|
+
if (this.softapAttempt !== attempt || attempt.cancelled) {
|
|
4033
|
+
console.log("[LocalMiniappRuntime] scoped loss for a superseded SoftAP call; ignoring", error)
|
|
4034
|
+
softapTrace("softap_scoped_loss_dropped", {
|
|
4035
|
+
attempt: attempt.id,
|
|
4036
|
+
code: error.code,
|
|
4037
|
+
cause: attempt.cancelled ? "retired" : "superseded",
|
|
4038
|
+
current: this.softapAttempt?.id ?? "none",
|
|
4039
|
+
})
|
|
4040
|
+
return
|
|
4041
|
+
}
|
|
4042
|
+
if (transport.isTerminating()) {
|
|
4043
|
+
console.log("[LocalMiniappRuntime] scoped loss during teardown; expected", error)
|
|
4044
|
+
softapTrace("softap_scoped_loss_dropped", {attempt: attempt.id, code: error.code, cause: "terminating"})
|
|
4045
|
+
return
|
|
4046
|
+
}
|
|
4047
|
+
console.warn("[LocalMiniappRuntime] SoftAP hotspot lost mid-call", error)
|
|
4048
|
+
softapTraceFailure("softap_scoped_loss_reported", {
|
|
4049
|
+
attempt: attempt.id,
|
|
4050
|
+
code: error.code,
|
|
4051
|
+
reason: error.message,
|
|
4052
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
4053
|
+
})
|
|
4054
|
+
const current = acsMeetingService.getState()
|
|
4055
|
+
this.sendToMiniapp(packageName, {
|
|
4056
|
+
type: MiniappResponseType.MEETING_STATE,
|
|
4057
|
+
...current,
|
|
4058
|
+
state: "error",
|
|
4059
|
+
error: `SOFTAP_NETWORK_LOST: ${error.message}`,
|
|
4060
|
+
softap: transport.progress(),
|
|
4061
|
+
})
|
|
4062
|
+
})
|
|
4063
|
+
|
|
4064
|
+
// Sign in on the phone's current internet *before* the glasses hotspot takes DNS. On device,
|
|
4065
|
+
// createCallAgent after the scoped join timed out at 20s and only finished once SoftAP was
|
|
4066
|
+
// released. It also waits for validated mobile data, which is why the wearer is told one
|
|
4067
|
+
// honest thing about the whole window rather than nothing at all.
|
|
4068
|
+
this.narrateSoftapPreflight(attempt, "Signing in to Teams…")
|
|
4069
|
+
const prepareStartedAt = Date.now()
|
|
4070
|
+
softapTrace("softap_prepare_agent_begin", {attempt: attempt.id})
|
|
4071
|
+
try {
|
|
4072
|
+
await acsMeetingService.prepareAgent({token: args.token, displayName: args.displayName})
|
|
4073
|
+
} catch (error) {
|
|
4074
|
+
softapTraceFailure("softap_prepare_agent_failed", {
|
|
4075
|
+
attempt: attempt.id,
|
|
4076
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4077
|
+
durationMs: Date.now() - prepareStartedAt,
|
|
4078
|
+
})
|
|
4079
|
+
throw error
|
|
4080
|
+
}
|
|
4081
|
+
softapTrace("softap_prepare_agent_done", {attempt: attempt.id, durationMs: Date.now() - prepareStartedAt})
|
|
4082
|
+
this.checkpointSoftapAttempt(attempt, "prepareAgent")
|
|
4083
|
+
await transport.start({
|
|
4084
|
+
// What the wearer has been reading for the last minute, so the first snapshot does not
|
|
4085
|
+
// blank it.
|
|
4086
|
+
initialSteps: attempt.progress.steps,
|
|
4087
|
+
// Every transition reaches the miniapp as a meeting-state event carrying the checklist.
|
|
4088
|
+
onProgress: (progress) => this.emitSoftapProgress(attempt, progress),
|
|
4089
|
+
})
|
|
4090
|
+
this.checkpointSoftapAttempt(attempt, "transport.start")
|
|
4091
|
+
const state = acsMeetingService.getState()
|
|
4092
|
+
softapTrace("softap_attempt_live", {
|
|
4093
|
+
attempt: attempt.id,
|
|
4094
|
+
state: state.state,
|
|
4095
|
+
micTransport: state.micTransport ?? "unknown",
|
|
4096
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
4097
|
+
})
|
|
4098
|
+
return state
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/**
|
|
4102
|
+
* Cancels a SoftAP attempt and does not return until everything it owns has been released.
|
|
4103
|
+
*
|
|
4104
|
+
* This is the barrier the whole design rests on: "leave returned" has to mean "nothing from that
|
|
4105
|
+
* call is still in flight", or the next Start races the previous call's cleanup through the same
|
|
4106
|
+
* hotspot. There is deliberately no deadline — a native call that never returns holds the next
|
|
4107
|
+
* call back rather than letting it race — and a watchdog only says so in the log.
|
|
4108
|
+
*
|
|
4109
|
+
* Safe to call when there is no attempt, and safe to call twice: the teardown is single-flight
|
|
4110
|
+
* per attempt, so a leave and the join's own failure path share one unwind.
|
|
4111
|
+
*/
|
|
4112
|
+
private async retireSoftapAttempt(options: {attempt?: SoftapAttempt; mode?: SoftapTeardownMode} = {}): Promise<void> {
|
|
4113
|
+
const attempt = options.attempt ?? this.softapAttempt
|
|
4114
|
+
if (!attempt) {
|
|
4115
|
+
// Not an error — every terminal path calls this without checking — but a Leave that found
|
|
4116
|
+
// nothing to retire and a Leave that tore a call down are very different log entries.
|
|
4117
|
+
softapTrace("softap_retire_noop", {mode: options.mode ?? "leave"})
|
|
4118
|
+
return
|
|
4119
|
+
}
|
|
4120
|
+
attempt.cancelled = true
|
|
4121
|
+
// Keep the retiring attempt discoverable until both its join and cleanup settle.
|
|
4122
|
+
// A newer reservation replaces it immediately, but must still wait on its barrier.
|
|
4123
|
+
softapTrace("softap_retire_begin", {
|
|
4124
|
+
attempt: attempt.id,
|
|
4125
|
+
mode: options.mode ?? "leave",
|
|
4126
|
+
attemptAgeMs: Date.now() - attempt.startedAt,
|
|
4127
|
+
phase: attempt.progress.phase,
|
|
4128
|
+
// Second and later callers join the first one's teardown rather than running their own, and
|
|
4129
|
+
// that sharing is what makes a leave racing a failed join safe.
|
|
4130
|
+
teardownAlreadyRunning: Boolean(attempt.teardown),
|
|
4131
|
+
})
|
|
4132
|
+
if (!attempt.teardown) {
|
|
4133
|
+
attempt.teardown = this.teardownSoftapAttempt(attempt, options.mode)
|
|
4134
|
+
// The barrier closes over the join body as well as the teardown, and opens only when both
|
|
4135
|
+
// have stopped. A watchdog reports a cleanup that is taking too long; it never declares one
|
|
4136
|
+
// finished, because "probably done by now" is exactly the assumption that broke restarts.
|
|
4137
|
+
const stallWatchdog = setTimeout(() => {
|
|
4138
|
+
console.warn("[LocalMiniappRuntime] softap_cleanup_stalled", {
|
|
4139
|
+
attempt: attempt.id,
|
|
4140
|
+
afterMs: SOFTAP_CLEANUP_STALL_LOG_MS,
|
|
4141
|
+
})
|
|
4142
|
+
}, SOFTAP_CLEANUP_STALL_LOG_MS)
|
|
4143
|
+
void Promise.allSettled([attempt.teardown, attempt.body ?? Promise.resolve()]).then(() => {
|
|
4144
|
+
clearTimeout(stallWatchdog)
|
|
4145
|
+
attempt.markSettled()
|
|
4146
|
+
if (this.softapAttempt === attempt) this.softapAttempt = null
|
|
4147
|
+
})
|
|
4148
|
+
}
|
|
4149
|
+
const awaitedFrom = Date.now()
|
|
4150
|
+
try {
|
|
4151
|
+
await attempt.teardown
|
|
4152
|
+
} catch (error) {
|
|
4153
|
+
// Only a refused End reaches here; the local unwind has completed either way, which is the
|
|
4154
|
+
// distinction the wearer's copy depends on.
|
|
4155
|
+
softapTraceFailure("softap_retire_done", {
|
|
4156
|
+
attempt: attempt.id,
|
|
4157
|
+
waitedMs: Date.now() - awaitedFrom,
|
|
4158
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4159
|
+
})
|
|
4160
|
+
throw error
|
|
4161
|
+
}
|
|
4162
|
+
softapTrace("softap_retire_done", {attempt: attempt.id, waitedMs: Date.now() - awaitedFrom})
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
/**
|
|
4166
|
+
* Releases everything one attempt could own, in the reverse of the order it was taken.
|
|
4167
|
+
*
|
|
4168
|
+
* The scoped network and the ACS agent are released unconditionally, not only when the transport
|
|
4169
|
+
* built them: `prepareAgent` signs in and pins this whole process to cellular *before* the
|
|
4170
|
+
* transport exists, so a Cancel during sign-in would otherwise leave both behind. Both releases
|
|
4171
|
+
* are idempotent.
|
|
4172
|
+
*
|
|
4173
|
+
* Failures are collected rather than swallowed. Reporting a clean teardown that did not happen
|
|
4174
|
+
* is what lets the next call start on leaked state.
|
|
4175
|
+
*/
|
|
4176
|
+
private async teardownSoftapAttempt(attempt: SoftapAttempt, mode?: SoftapTeardownMode): Promise<void> {
|
|
4177
|
+
// A cancelled reservation waiting behind another call never acquired any shared resources.
|
|
4178
|
+
// Its cleanup must not release the preceding call's native agent or network.
|
|
4179
|
+
if (!attempt.ownsResources) return
|
|
4180
|
+
// Terminal intent before the first release, so the scoped network we are about to drop cannot
|
|
4181
|
+
// be reported as a hotspot that walked away.
|
|
4182
|
+
acsMeetingService.beginScopedTeardown()
|
|
4183
|
+
const failures: string[] = []
|
|
4184
|
+
let endFailure: unknown = null
|
|
4185
|
+
const teardownStartedAt = Date.now()
|
|
4186
|
+
softapTrace("softap_teardown_begin", {
|
|
4187
|
+
attempt: attempt.id,
|
|
4188
|
+
mode: mode ?? "leave",
|
|
4189
|
+
hasTransport: Boolean(attempt.transport),
|
|
4190
|
+
steps: attempt.transport?.activeSteps().join(",") ?? "",
|
|
4191
|
+
})
|
|
4192
|
+
/**
|
|
4193
|
+
* One line per resource, whether it released or not.
|
|
4194
|
+
*
|
|
4195
|
+
* The releases below are sequential and every one of them can block on a native call, so a
|
|
4196
|
+
* teardown that takes half a minute is only readable if each step says how long it took. A
|
|
4197
|
+
* step that never reports is the one that hung.
|
|
4198
|
+
*/
|
|
4199
|
+
const releaseStep = async (step: string, release: () => Promise<void>): Promise<unknown> => {
|
|
4200
|
+
const startedAt = Date.now()
|
|
4201
|
+
try {
|
|
4202
|
+
await release()
|
|
4203
|
+
softapTrace("softap_teardown_step", {
|
|
4204
|
+
attempt: attempt.id,
|
|
4205
|
+
step,
|
|
4206
|
+
outcome: "ok",
|
|
4207
|
+
durationMs: Date.now() - startedAt,
|
|
4208
|
+
})
|
|
4209
|
+
return null
|
|
4210
|
+
} catch (error) {
|
|
4211
|
+
softapTraceFailure("softap_teardown_step", {
|
|
4212
|
+
attempt: attempt.id,
|
|
4213
|
+
step,
|
|
4214
|
+
outcome: "failed",
|
|
4215
|
+
durationMs: Date.now() - startedAt,
|
|
4216
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4217
|
+
})
|
|
4218
|
+
return error
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
const transport = attempt.transport
|
|
4222
|
+
if (transport) {
|
|
4223
|
+
// The only thing `stop()` throws is a refused End — the local teardown still completed,
|
|
4224
|
+
// so this is the caller's business, not a reason to block the next call.
|
|
4225
|
+
endFailure = await releaseStep("transport.stop", () => transport.stop(mode ? {mode} : {}))
|
|
4226
|
+
const undoFailures = transport.lastTeardownFailures()
|
|
4227
|
+
if (undoFailures.length > 0) failures.push(`could not release ${undoFailures.join(", ")}`)
|
|
4228
|
+
}
|
|
4229
|
+
const scopedFailure = await releaseStep("leaveScopedNetwork", () => acsMeetingService.leaveScopedNetwork())
|
|
4230
|
+
if (scopedFailure) {
|
|
4231
|
+
failures.push(
|
|
4232
|
+
`leaveScopedNetwork: ${scopedFailure instanceof Error ? scopedFailure.message : String(scopedFailure)}`,
|
|
4233
|
+
)
|
|
4234
|
+
}
|
|
4235
|
+
// Stay pinned across this wait. Unpinning onto a leftover glasses AP is what made the next
|
|
4236
|
+
// Start Call's `teams:create` return `Request failed (503)` — Cloudflare, no internet, not a
|
|
4237
|
+
// Teams configuration problem.
|
|
4238
|
+
const defaultNetworkStartedAt = Date.now()
|
|
4239
|
+
try {
|
|
4240
|
+
const network = await acsMeetingService.awaitValidatedDefaultNetwork()
|
|
4241
|
+
softapTrace("softap_teardown_step", {
|
|
4242
|
+
attempt: attempt.id,
|
|
4243
|
+
step: "awaitValidatedDefaultNetwork",
|
|
4244
|
+
outcome: "ok",
|
|
4245
|
+
durationMs: Date.now() - defaultNetworkStartedAt,
|
|
4246
|
+
// Null is "this host cannot tell", which is a different fact from an unusable route, and
|
|
4247
|
+
// the cellular pin is dropped on the strength of this answer.
|
|
4248
|
+
network: network?.detail ?? "unknown",
|
|
4249
|
+
usable: network?.usable ?? "unknown",
|
|
4250
|
+
})
|
|
4251
|
+
} catch (error) {
|
|
4252
|
+
console.warn("[LocalMiniappRuntime] default network after SoftAP teardown is unverified", {
|
|
4253
|
+
attempt: attempt.id,
|
|
4254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4255
|
+
})
|
|
4256
|
+
softapTraceFailure("softap_teardown_step", {
|
|
4257
|
+
attempt: attempt.id,
|
|
4258
|
+
step: "awaitValidatedDefaultNetwork",
|
|
4259
|
+
outcome: "failed",
|
|
4260
|
+
durationMs: Date.now() - defaultNetworkStartedAt,
|
|
4261
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4262
|
+
})
|
|
4263
|
+
}
|
|
4264
|
+
const acsLeaveStartedAt = Date.now()
|
|
4265
|
+
try {
|
|
4266
|
+
const outcome = await acsMeetingService.leaveAndAwait(attempt.packageName)
|
|
4267
|
+
softapTrace("softap_teardown_step", {
|
|
4268
|
+
attempt: attempt.id,
|
|
4269
|
+
step: "acsLeaveAndAwait",
|
|
4270
|
+
// `completed=false` is not a failure and is not recorded as one: the build simply cannot
|
|
4271
|
+
// confirm its own cleanup, so the next call's barrier is the only guarantee left.
|
|
4272
|
+
outcome: outcome.completed ? "ok" : "unconfirmed",
|
|
4273
|
+
reason: outcome.reason ?? "",
|
|
4274
|
+
durationMs: Date.now() - acsLeaveStartedAt,
|
|
4275
|
+
})
|
|
4276
|
+
if (!outcome.completed) {
|
|
4277
|
+
console.warn("[LocalMiniappRuntime] ACS cleanup completion is unverified on this build", {
|
|
4278
|
+
attempt: attempt.id,
|
|
4279
|
+
reason: outcome.reason,
|
|
4280
|
+
})
|
|
4281
|
+
}
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
softapTraceFailure("softap_teardown_step", {
|
|
4284
|
+
attempt: attempt.id,
|
|
4285
|
+
step: "acsLeaveAndAwait",
|
|
4286
|
+
outcome: "failed",
|
|
4287
|
+
durationMs: Date.now() - acsLeaveStartedAt,
|
|
4288
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
4289
|
+
})
|
|
4290
|
+
failures.push(`ACS leave: ${error instanceof Error ? error.message : String(error)}`)
|
|
4291
|
+
}
|
|
4292
|
+
const hadScopedLostListener = Boolean(attempt.scopedLostUnsub)
|
|
4293
|
+
attempt.scopedLostUnsub?.()
|
|
4294
|
+
attempt.scopedLostUnsub = null
|
|
4295
|
+
softapTrace("softap_teardown_step", {
|
|
4296
|
+
attempt: attempt.id,
|
|
4297
|
+
step: "scopedLostListener",
|
|
4298
|
+
outcome: hadScopedLostListener ? "ok" : "not taken",
|
|
4299
|
+
})
|
|
4300
|
+
if (failures.length > 0) {
|
|
4301
|
+
this.softapCleanupError = failures.join("; ")
|
|
4302
|
+
console.warn("[LocalMiniappRuntime] softap_cleanup_failed", {attempt: attempt.id, failures})
|
|
4303
|
+
}
|
|
4304
|
+
// The recorded failures are what the *next* join refuses on, so they are logged as the list
|
|
4305
|
+
// that will be quoted back to the wearer rather than only as the individual step failures.
|
|
4306
|
+
softapTrace("softap_teardown_done", {
|
|
4307
|
+
attempt: attempt.id,
|
|
4308
|
+
durationMs: Date.now() - teardownStartedAt,
|
|
4309
|
+
refusesNextCall: failures.length > 0,
|
|
4310
|
+
failures: failures.join("; "),
|
|
4311
|
+
endRefused: Boolean(endFailure),
|
|
4312
|
+
})
|
|
4313
|
+
if (endFailure) throw endFailure
|
|
4314
|
+
}
|
|
4315
|
+
|
|
3673
4316
|
private async handleMeetingLeave(packageName: string, requestId?: string): Promise<void> {
|
|
4317
|
+
const startedAt = Date.now()
|
|
4318
|
+
softapTrace("meeting_leave_request", {
|
|
4319
|
+
packageName,
|
|
4320
|
+
requestId: requestId ?? "none",
|
|
4321
|
+
attempt: this.softapAttempt?.id ?? "none",
|
|
4322
|
+
})
|
|
3674
4323
|
try {
|
|
4324
|
+
// A SoftAP call owns the hotspot, the scoped network, and the cellular pin as well as the
|
|
4325
|
+
// ACS call, and retiring the attempt releases all of them. Returning before that finishes is
|
|
4326
|
+
// what let the next Start race this teardown.
|
|
4327
|
+
if (this.softapAttempt) {
|
|
4328
|
+
await this.retireSoftapAttempt()
|
|
4329
|
+
softapTrace("meeting_leave_result", {
|
|
4330
|
+
packageName,
|
|
4331
|
+
requestId: requestId ?? "none",
|
|
4332
|
+
path: "softap",
|
|
4333
|
+
durationMs: Date.now() - startedAt,
|
|
4334
|
+
})
|
|
4335
|
+
this.sendResult(packageName, requestId, true)
|
|
4336
|
+
return
|
|
4337
|
+
}
|
|
3675
4338
|
await acsMeetingService.leave(packageName)
|
|
4339
|
+
softapTrace("meeting_leave_result", {
|
|
4340
|
+
packageName,
|
|
4341
|
+
requestId: requestId ?? "none",
|
|
4342
|
+
path: "acs",
|
|
4343
|
+
durationMs: Date.now() - startedAt,
|
|
4344
|
+
})
|
|
3676
4345
|
this.sendResult(packageName, requestId, true)
|
|
3677
4346
|
} catch (err) {
|
|
4347
|
+
softapTraceFailure("meeting_leave_result", {
|
|
4348
|
+
packageName,
|
|
4349
|
+
requestId: requestId ?? "none",
|
|
4350
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
4351
|
+
durationMs: Date.now() - startedAt,
|
|
4352
|
+
})
|
|
3678
4353
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3679
4354
|
code: MiniappErrorCode.INTERNAL,
|
|
3680
4355
|
message: err instanceof Error ? err.message : "ACS meeting leave failed",
|
|
@@ -3682,15 +4357,73 @@ class LocalMiniappRuntime {
|
|
|
3682
4357
|
}
|
|
3683
4358
|
}
|
|
3684
4359
|
|
|
4360
|
+
/**
|
|
4361
|
+
* End the meeting for everyone, then tear this device down.
|
|
4362
|
+
*
|
|
4363
|
+
* A rejection here always means "the others may still be in the meeting", never "you are still in
|
|
4364
|
+
* it": both paths below complete local teardown before the error surfaces.
|
|
4365
|
+
*/
|
|
4366
|
+
private async handleMeetingEnd(packageName: string, requestId?: string): Promise<void> {
|
|
4367
|
+
const startedAt = Date.now()
|
|
4368
|
+
const softap = Boolean(this.softapAttempt)
|
|
4369
|
+
softapTrace("meeting_end_request", {
|
|
4370
|
+
packageName,
|
|
4371
|
+
requestId: requestId ?? "none",
|
|
4372
|
+
attempt: this.softapAttempt?.id ?? "none",
|
|
4373
|
+
})
|
|
4374
|
+
try {
|
|
4375
|
+
if (this.softapAttempt) {
|
|
4376
|
+
await this.retireSoftapAttempt({mode: "end"})
|
|
4377
|
+
} else {
|
|
4378
|
+
await acsMeetingService.endForEveryone(packageName)
|
|
4379
|
+
}
|
|
4380
|
+
softapTrace("meeting_end_result", {
|
|
4381
|
+
packageName,
|
|
4382
|
+
requestId: requestId ?? "none",
|
|
4383
|
+
path: softap ? "softap" : "acs",
|
|
4384
|
+
durationMs: Date.now() - startedAt,
|
|
4385
|
+
})
|
|
4386
|
+
this.sendResult(packageName, requestId, true)
|
|
4387
|
+
} catch (err) {
|
|
4388
|
+
// A rejection here always means the others may still be in the meeting, never that this
|
|
4389
|
+
// device is — the log has to be readable that way round or it accuses the wrong side.
|
|
4390
|
+
softapTraceFailure("meeting_end_result", {
|
|
4391
|
+
packageName,
|
|
4392
|
+
requestId: requestId ?? "none",
|
|
4393
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
4394
|
+
durationMs: Date.now() - startedAt,
|
|
4395
|
+
})
|
|
4396
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
4397
|
+
code: MiniappErrorCode.INTERNAL,
|
|
4398
|
+
message: err instanceof Error ? err.message : "Could not end the meeting for everyone",
|
|
4399
|
+
})
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
|
|
3685
4403
|
private async handleMeetingSetMuted(
|
|
3686
4404
|
packageName: string,
|
|
3687
4405
|
payload: Record<string, unknown>,
|
|
3688
4406
|
requestId?: string,
|
|
3689
4407
|
): Promise<void> {
|
|
4408
|
+
const startedAt = Date.now()
|
|
3690
4409
|
try {
|
|
3691
4410
|
const state = await acsMeetingService.setMuted(packageName, Boolean(payload.muted))
|
|
4411
|
+
softapTrace("meeting_set_muted", {
|
|
4412
|
+
packageName,
|
|
4413
|
+
requestId: requestId ?? "none",
|
|
4414
|
+
requested: Boolean(payload.muted),
|
|
4415
|
+
muted: state.muted,
|
|
4416
|
+
durationMs: Date.now() - startedAt,
|
|
4417
|
+
})
|
|
3692
4418
|
this.sendResult(packageName, requestId, true, state)
|
|
3693
4419
|
} catch (err) {
|
|
4420
|
+
softapTraceFailure("meeting_set_muted", {
|
|
4421
|
+
packageName,
|
|
4422
|
+
requestId: requestId ?? "none",
|
|
4423
|
+
requested: Boolean(payload.muted),
|
|
4424
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
4425
|
+
durationMs: Date.now() - startedAt,
|
|
4426
|
+
})
|
|
3694
4427
|
this.sendResult(packageName, requestId, false, undefined, {
|
|
3695
4428
|
code: MiniappErrorCode.INTERNAL,
|
|
3696
4429
|
message: err instanceof Error ? err.message : "ACS mute failed",
|