@mentra/cloud-client 0.1.0-beta.0 → 3.1.0-dev.10
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/package.json +2 -2
- package/src/client.ts +9 -3
- package/src/config.ts +8 -0
- package/src/http.ts +4 -6
- package/src/index.ts +4 -0
- package/src/modules/core/core.ts +17 -0
- package/src/modules/core/support-profile.ts +45 -0
- package/src/modules/runtime/camera.ts +7 -3
- package/src/modules/runtime/connection.ts +64 -20
- package/src/modules/runtime/runtime.ts +19 -6
- package/src/modules/runtime/subscriptions.ts +5 -1
- package/src/timers.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/cloud-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0-dev.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "bun test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@mentra/cloud-protocol": "
|
|
15
|
+
"@mentra/cloud-protocol": "3.1.0-dev.10",
|
|
16
16
|
"tweetnacl": "^1.0.3"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { CloudClientConfig } from "./config";
|
|
|
20
20
|
import { createHttpClient } from "./http";
|
|
21
21
|
import { CloudClientError } from "./errors";
|
|
22
22
|
import type { ConnectionInit } from "@mentra/cloud-protocol";
|
|
23
|
+
import { systemTimers } from "./timers";
|
|
23
24
|
|
|
24
25
|
// The module implementations. Each is owned by another agent under ./modules/**;
|
|
25
26
|
// this file only constructs them, matching the constructor signatures fixed in
|
|
@@ -117,6 +118,7 @@ export class CloudClient {
|
|
|
117
118
|
|
|
118
119
|
// Reconnect/backoff lives here so the socket's timing is tuned in one spot.
|
|
119
120
|
const reconnect = config.reconnect ?? DEFAULT_RECONNECT;
|
|
121
|
+
const timers = config.timers ?? systemTimers;
|
|
120
122
|
|
|
121
123
|
// Resolve the two base addresses. With a proxy set, both route through it;
|
|
122
124
|
// without one, each module talks to its own service directly.
|
|
@@ -149,7 +151,7 @@ export class CloudClient {
|
|
|
149
151
|
// before any access token exists. It is deliberately Core-only: runtime-only
|
|
150
152
|
// clients never get a fallback that points Core/Auth calls at Runtime.
|
|
151
153
|
const authHttp = coreUrl
|
|
152
|
-
? createHttpClient({ baseUrl: coreUrl, logger, fetch: config.transports.http })
|
|
154
|
+
? createHttpClient({ baseUrl: coreUrl, logger, fetch: config.transports.http, timers })
|
|
153
155
|
: undefined;
|
|
154
156
|
const store = new TokenStore({ storage: config.transports.storage });
|
|
155
157
|
const auth = new Auth({
|
|
@@ -173,6 +175,7 @@ export class CloudClient {
|
|
|
173
175
|
getToken: getCoreToken,
|
|
174
176
|
logger,
|
|
175
177
|
fetch: config.transports.http,
|
|
178
|
+
timers,
|
|
176
179
|
})
|
|
177
180
|
: null;
|
|
178
181
|
const runtimeHttp = createHttpClient({
|
|
@@ -180,10 +183,11 @@ export class CloudClient {
|
|
|
180
183
|
getToken: getRuntimeToken,
|
|
181
184
|
logger,
|
|
182
185
|
fetch: config.transports.http,
|
|
186
|
+
timers,
|
|
183
187
|
});
|
|
184
188
|
|
|
185
189
|
const emitter = new RuntimeEmitter();
|
|
186
|
-
const subscriptions = new Subscriptions({ http: runtimeHttp });
|
|
190
|
+
const subscriptions = new Subscriptions({ http: runtimeHttp, timers });
|
|
187
191
|
|
|
188
192
|
// The handshake payload the connection sends on every (re)open. It is a
|
|
189
193
|
// factory (not a fixed value) so each reconnect re-reads the current defaults
|
|
@@ -221,12 +225,13 @@ export class CloudClient {
|
|
|
221
225
|
getToken: getRuntimeToken,
|
|
222
226
|
initPayload,
|
|
223
227
|
reconnect,
|
|
228
|
+
timers,
|
|
224
229
|
onAuthRejected: async () => {
|
|
225
230
|
await auth.getRuntimeToken({ forceRefresh: true });
|
|
226
231
|
},
|
|
227
232
|
logger,
|
|
228
233
|
});
|
|
229
|
-
const camera = new Camera({ http: runtimeHttp });
|
|
234
|
+
const camera = new Camera({ http: runtimeHttp, timers });
|
|
230
235
|
const tts = new Tts({ http: runtimeHttp });
|
|
231
236
|
const maps = new Maps({ http: runtimeHttp });
|
|
232
237
|
const audio = new UdpAudio({ udp: config.transports.udp });
|
|
@@ -239,6 +244,7 @@ export class CloudClient {
|
|
|
239
244
|
tts,
|
|
240
245
|
maps,
|
|
241
246
|
audio,
|
|
247
|
+
timers,
|
|
242
248
|
logger,
|
|
243
249
|
// On a fatal AUTH_EXPIRED at handshake, runtime forces auth to drop its
|
|
244
250
|
// cached access token and refresh; the connection then re-reads the fresh
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Logger } from "./logger";
|
|
11
11
|
import type { CloudClientTransports } from "./transports";
|
|
12
|
+
import type { CloudClientTimers } from "./timers";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The full shape passed to the root `CloudClient`.
|
|
@@ -25,6 +26,13 @@ export interface CloudClientConfig {
|
|
|
25
26
|
endpoints: { core?: string; runtime: string; proxy?: string };
|
|
26
27
|
auth: AuthConfig;
|
|
27
28
|
transports: CloudClientTransports;
|
|
29
|
+
/**
|
|
30
|
+
* Scheduler for all delayed client work, including connection/audio
|
|
31
|
+
* liveness, retries, and request timeouts. React Native hosts must provide
|
|
32
|
+
* native background timers because ordinary JS timers pause when Android
|
|
33
|
+
* backgrounds the app.
|
|
34
|
+
*/
|
|
35
|
+
timers?: CloudClientTimers;
|
|
28
36
|
logger?: Logger;
|
|
29
37
|
// backoff tuning for the live socket; one place so a host can match its fleet
|
|
30
38
|
reconnect?: { baseMs: number; maxMs: number; jitter: boolean };
|
package/src/http.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { HttpError } from "./errors";
|
|
16
16
|
import type { Logger } from "./logger";
|
|
17
|
+
import { systemTimers, type CloudClientTimers } from "./timers";
|
|
17
18
|
import type { HttpTransport } from "./transports";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -58,6 +59,7 @@ export interface CreateHttpClientDeps {
|
|
|
58
59
|
getToken?: () => Promise<string>;
|
|
59
60
|
logger: Logger;
|
|
60
61
|
fetch?: HttpTransport;
|
|
62
|
+
timers?: CloudClientTimers;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** How many times to retry a transient failure on an idempotent call. */
|
|
@@ -65,11 +67,6 @@ const MAX_RETRIES = 2;
|
|
|
65
67
|
/** Base backoff in milliseconds; doubles per attempt (250, 500, ...). */
|
|
66
68
|
const RETRY_BASE_MS = 250;
|
|
67
69
|
|
|
68
|
-
/** Resolve after `ms`, used for retry backoff. */
|
|
69
|
-
function delay(ms: number): Promise<void> {
|
|
70
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
70
|
/**
|
|
74
71
|
* Join a base URL and a path without producing a double slash or dropping one.
|
|
75
72
|
*
|
|
@@ -85,6 +82,7 @@ function joinUrl(baseUrl: string, path: string): string {
|
|
|
85
82
|
export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
86
83
|
const { baseUrl, getToken, logger } = deps;
|
|
87
84
|
const executeFetch = deps.fetch ?? globalThis.fetch;
|
|
85
|
+
const timers = deps.timers ?? systemTimers;
|
|
88
86
|
|
|
89
87
|
/**
|
|
90
88
|
* Resolve the Bearer to attach: a per-call override wins, otherwise the
|
|
@@ -122,7 +120,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
122
120
|
if (attempt > 0) {
|
|
123
121
|
const backoff = RETRY_BASE_MS * 2 ** (attempt - 1);
|
|
124
122
|
logger.debug("http retrying request", { method, path, attempt });
|
|
125
|
-
await
|
|
123
|
+
await new Promise<void>((resolve) => timers.setTimeout(resolve, backoff));
|
|
126
124
|
}
|
|
127
125
|
|
|
128
126
|
let res: Response;
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type {
|
|
|
32
32
|
UdpSocketLike,
|
|
33
33
|
KeyValueStore,
|
|
34
34
|
} from "./transports";
|
|
35
|
+
export type { CloudClientTimers } from "./timers";
|
|
35
36
|
|
|
36
37
|
// Local error types a host can branch on with `instanceof`.
|
|
37
38
|
export { CloudClientError, HttpError, AuthExpiredError } from "./errors";
|
|
@@ -67,4 +68,7 @@ export type {
|
|
|
67
68
|
ReportTrigger,
|
|
68
69
|
SubmitReportInput,
|
|
69
70
|
SubmitReportResult,
|
|
71
|
+
SupportConnectionState,
|
|
72
|
+
SupportProfileUpdateResult,
|
|
73
|
+
SupportStateInput,
|
|
70
74
|
} from "./modules/core/core";
|
package/src/modules/core/core.ts
CHANGED
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
type SubmitReportInput,
|
|
27
27
|
type SubmitReportResult,
|
|
28
28
|
} from "./reports";
|
|
29
|
+
import {
|
|
30
|
+
SupportProfiles,
|
|
31
|
+
type SupportProfileUpdateResult,
|
|
32
|
+
type SupportStateInput,
|
|
33
|
+
} from "./support-profile";
|
|
29
34
|
|
|
30
35
|
export type {
|
|
31
36
|
AddReportArtifactsResult,
|
|
@@ -40,6 +45,11 @@ export type {
|
|
|
40
45
|
SubmitReportInput,
|
|
41
46
|
SubmitReportResult,
|
|
42
47
|
} from "./reports";
|
|
48
|
+
export type {
|
|
49
|
+
SupportConnectionState,
|
|
50
|
+
SupportProfileUpdateResult,
|
|
51
|
+
SupportStateInput,
|
|
52
|
+
} from "./support-profile";
|
|
43
53
|
|
|
44
54
|
/**
|
|
45
55
|
* A single miniapp entry as returned by the listing.
|
|
@@ -145,10 +155,14 @@ export class Core {
|
|
|
145
155
|
): Promise<AddReportArtifactsResult>;
|
|
146
156
|
complete(reportId: string): Promise<{ status: ReportStatus }>;
|
|
147
157
|
};
|
|
158
|
+
readonly supportProfile: {
|
|
159
|
+
update(input: SupportStateInput): Promise<SupportProfileUpdateResult>;
|
|
160
|
+
};
|
|
148
161
|
|
|
149
162
|
constructor(deps: CoreDeps) {
|
|
150
163
|
const { http } = deps;
|
|
151
164
|
const reports = new Reports({ http });
|
|
165
|
+
const supportProfiles = new SupportProfiles(http);
|
|
152
166
|
|
|
153
167
|
this.miniapps = {
|
|
154
168
|
/**
|
|
@@ -198,5 +212,8 @@ export class Core {
|
|
|
198
212
|
addScreenshots: reports.addScreenshots.bind(reports),
|
|
199
213
|
complete: reports.complete.bind(reports),
|
|
200
214
|
};
|
|
215
|
+
this.supportProfile = {
|
|
216
|
+
update: supportProfiles.update.bind(supportProfiles),
|
|
217
|
+
};
|
|
201
218
|
}
|
|
202
219
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {HttpClient} from "../../http"
|
|
2
|
+
|
|
3
|
+
export type SupportConnectionState = "disconnected" | "scanning" | "connecting" | "bonding" | "connected"
|
|
4
|
+
|
|
5
|
+
export interface SupportStateInput {
|
|
6
|
+
observedAt: string
|
|
7
|
+
host: {
|
|
8
|
+
appVersion?: string
|
|
9
|
+
appBuild?: string
|
|
10
|
+
engineVersion?: string
|
|
11
|
+
bluetoothSdkVersion?: string
|
|
12
|
+
phonePlatform: "ios" | "android" | "unknown"
|
|
13
|
+
phoneModel?: string
|
|
14
|
+
phoneOsVersion?: string
|
|
15
|
+
connectionState: SupportConnectionState
|
|
16
|
+
failureCode?: string
|
|
17
|
+
failureStage?: string
|
|
18
|
+
}
|
|
19
|
+
device?: {
|
|
20
|
+
/** Sent over TLS for server-side HMAC derivation, then immediately discarded. */
|
|
21
|
+
hardwareId?: string
|
|
22
|
+
model?: string
|
|
23
|
+
androidVersion?: string
|
|
24
|
+
firmwareVersion?: string
|
|
25
|
+
mtkFirmwareVersion?: string
|
|
26
|
+
besFirmwareVersion?: string
|
|
27
|
+
appVersion?: string
|
|
28
|
+
buildNumber?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SupportProfileUpdateResult {
|
|
33
|
+
status: "accepted" | "deduplicated" | "stale"
|
|
34
|
+
observedAt: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SupportProfiles {
|
|
38
|
+
constructor(private readonly http: HttpClient) {}
|
|
39
|
+
|
|
40
|
+
update(input: SupportStateInput): Promise<SupportProfileUpdateResult> {
|
|
41
|
+
return this.http.put<SupportProfileUpdateResult>("/api/client/support-profile", input, {
|
|
42
|
+
idempotent: true,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* docs/issues/004-cloud-client/design.md ("Camera").
|
|
18
18
|
*/
|
|
19
19
|
import type { HttpClient } from "../../http";
|
|
20
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
20
21
|
import type {
|
|
21
22
|
CloudToClientMessage,
|
|
22
23
|
PhotoOptions,
|
|
@@ -48,6 +49,7 @@ export interface PhotoResult {
|
|
|
48
49
|
|
|
49
50
|
export interface CameraDeps {
|
|
50
51
|
http: HttpClient;
|
|
52
|
+
timers?: CloudClientTimers;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
@@ -60,17 +62,19 @@ export interface CameraDeps {
|
|
|
60
62
|
interface Pending<T> {
|
|
61
63
|
resolve: (value: T) => void;
|
|
62
64
|
reject: (err: Error) => void;
|
|
63
|
-
timer:
|
|
65
|
+
timer: unknown;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export class Camera {
|
|
67
69
|
private readonly http: HttpClient;
|
|
70
|
+
private readonly timers: CloudClientTimers;
|
|
68
71
|
|
|
69
72
|
/** In-flight photo requests, keyed by the `requestId` the cloud assigned. */
|
|
70
73
|
private readonly pendingPhotos = new Map<string, Pending<PhotoResult>>();
|
|
71
74
|
|
|
72
75
|
constructor(deps: CameraDeps) {
|
|
73
76
|
this.http = deps.http;
|
|
77
|
+
this.timers = deps.timers ?? systemTimers;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
@@ -111,7 +115,7 @@ export class Camera {
|
|
|
111
115
|
/** Step 2: resolve when the cloud pushes `photo.ready` for the request. */
|
|
112
116
|
awaitPhotoReady(requestId: string): Promise<PhotoResult> {
|
|
113
117
|
return new Promise<PhotoResult>((resolve, reject) => {
|
|
114
|
-
const timer = setTimeout(() => {
|
|
118
|
+
const timer = this.timers.setTimeout(() => {
|
|
115
119
|
// Drop the entry first so the rejection cannot race a late push.
|
|
116
120
|
this.pendingPhotos.delete(requestId);
|
|
117
121
|
reject(new Error(`Managed photo ${requestId} timed out`));
|
|
@@ -185,7 +189,7 @@ export class Camera {
|
|
|
185
189
|
private takePending(requestId: string): Pending<PhotoResult> | undefined {
|
|
186
190
|
const pending = this.pendingPhotos.get(requestId);
|
|
187
191
|
if (!pending) return undefined;
|
|
188
|
-
clearTimeout(pending.timer);
|
|
192
|
+
this.timers.clearTimeout(pending.timer);
|
|
189
193
|
this.pendingPhotos.delete(requestId);
|
|
190
194
|
return pending;
|
|
191
195
|
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from "@mentra/cloud-protocol";
|
|
51
51
|
import type { WebSocketLike } from "../../transports";
|
|
52
52
|
import type { Logger } from "../../logger";
|
|
53
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
53
54
|
|
|
54
55
|
/** Connection lifecycle, surfaced to the rest of runtime via `onState`. */
|
|
55
56
|
export type ConnectionState = "connecting" | "open" | "closed";
|
|
@@ -117,6 +118,7 @@ export interface ConnectionDeps {
|
|
|
117
118
|
// what rides in the handshake beyond the token it stamps in.
|
|
118
119
|
initPayload: () => ConnectionInit;
|
|
119
120
|
reconnect: { baseMs: number; maxMs: number; jitter: boolean };
|
|
121
|
+
timers?: CloudClientTimers;
|
|
120
122
|
// Called when the WebSocket upgrade itself is rejected as unauthorized. That
|
|
121
123
|
// happens before a protocol `error` frame can exist, so runtime's normal
|
|
122
124
|
// AUTH_EXPIRED handshake retry cannot see it. The host uses this to invalidate
|
|
@@ -164,8 +166,13 @@ function isUnauthorizedUpgrade(reason: string): boolean {
|
|
|
164
166
|
return /\b401\b|unauthorized/i.test(reason);
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
function isSupersededByNewerSession(reason: string): boolean {
|
|
170
|
+
return /superseded by newer session/i.test(reason);
|
|
171
|
+
}
|
|
172
|
+
|
|
167
173
|
export class Connection {
|
|
168
174
|
private readonly deps: ConnectionDeps;
|
|
175
|
+
private readonly timers: CloudClientTimers;
|
|
169
176
|
|
|
170
177
|
// The current socket. Null between connect attempts and after close, so every
|
|
171
178
|
// access guards on it rather than assuming a live socket.
|
|
@@ -187,6 +194,11 @@ export class Connection {
|
|
|
187
194
|
// stop" from "the network dropped".
|
|
188
195
|
private closedByHost = false;
|
|
189
196
|
|
|
197
|
+
// True when the cloud closed us with 1012 "superseded by newer session".
|
|
198
|
+
// Another live socket for this user won; reconnecting would just kill it
|
|
199
|
+
// and start a two-client fight. Stays down until the host calls open().
|
|
200
|
+
private replacedByNewerSession = false;
|
|
201
|
+
|
|
190
202
|
// How many consecutive failed (re)connect attempts, used to grow the backoff.
|
|
191
203
|
// Reset to zero on a successful handshake.
|
|
192
204
|
private reconnectAttempt = 0;
|
|
@@ -196,12 +208,12 @@ export class Connection {
|
|
|
196
208
|
// already armed is a no-op, so the (intentional) double call from `onClose`
|
|
197
209
|
// and from a failed attempt's catch-reschedule can never stack two timers and
|
|
198
210
|
// double the reconnect rate.
|
|
199
|
-
private reconnectTimer:
|
|
211
|
+
private reconnectTimer: unknown | null = null;
|
|
200
212
|
|
|
201
213
|
// The watchdog interval (started in open(), cleared in close()). It is the
|
|
202
214
|
// backstop that revives the reconnect loop if it ever stalls: see
|
|
203
215
|
// `tickWatchdog` and the file header for why a stall is possible at all.
|
|
204
|
-
private watchdogTimer:
|
|
216
|
+
private watchdogTimer: unknown | null = null;
|
|
205
217
|
|
|
206
218
|
// Mirror of the last state pushed through `setState`, so the watchdog can ask
|
|
207
219
|
// "are we currently closed?" without a separate subscription. The state
|
|
@@ -211,8 +223,8 @@ export class Connection {
|
|
|
211
223
|
|
|
212
224
|
// Liveness timers. The interval drives the periodic ping; the pong-wait timer
|
|
213
225
|
// is armed when a ping goes out and disarmed when its pong arrives.
|
|
214
|
-
private pingTimer:
|
|
215
|
-
private pongTimer:
|
|
226
|
+
private pingTimer: unknown | null = null;
|
|
227
|
+
private pongTimer: unknown | null = null;
|
|
216
228
|
|
|
217
229
|
// While `open()` is awaiting `connection.ack`, these settle that promise. They
|
|
218
230
|
// are cleared the moment the handshake resolves, rejects, or times out, so a
|
|
@@ -220,11 +232,12 @@ export class Connection {
|
|
|
220
232
|
private pendingAck: {
|
|
221
233
|
resolve: (ack: ConnectionAck) => void;
|
|
222
234
|
reject: (err: Error) => void;
|
|
223
|
-
timer:
|
|
235
|
+
timer: unknown;
|
|
224
236
|
} | null = null;
|
|
225
237
|
|
|
226
238
|
constructor(deps: ConnectionDeps) {
|
|
227
239
|
this.deps = deps;
|
|
240
|
+
this.timers = deps.timers ?? systemTimers;
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
/**
|
|
@@ -237,6 +250,11 @@ export class Connection {
|
|
|
237
250
|
return this.currentAck;
|
|
238
251
|
}
|
|
239
252
|
|
|
253
|
+
/** True after the cloud closed us as the losing socket for this user. */
|
|
254
|
+
get isReplacedByNewerSession(): boolean {
|
|
255
|
+
return this.replacedByNewerSession;
|
|
256
|
+
}
|
|
257
|
+
|
|
240
258
|
/**
|
|
241
259
|
* Open the socket, run the handshake, and resolve with `connection.ack`.
|
|
242
260
|
*
|
|
@@ -248,8 +266,9 @@ export class Connection {
|
|
|
248
266
|
*/
|
|
249
267
|
async open(): Promise<ConnectionAck> {
|
|
250
268
|
// A fresh open() means the host wants the socket up; clear any prior
|
|
251
|
-
// host-close intent so a later drop reconnects normally.
|
|
269
|
+
// host-close / superseded intent so a later drop reconnects normally.
|
|
252
270
|
this.closedByHost = false;
|
|
271
|
+
this.replacedByNewerSession = false;
|
|
253
272
|
|
|
254
273
|
// A fresh open() is a clean slate: cancel any stale reconnect left over from
|
|
255
274
|
// a previous session, and reset the backoff so we do not start this connect
|
|
@@ -277,6 +296,7 @@ export class Connection {
|
|
|
277
296
|
*/
|
|
278
297
|
close(): void {
|
|
279
298
|
this.closedByHost = true;
|
|
299
|
+
this.replacedByNewerSession = false;
|
|
280
300
|
this.stopLiveness();
|
|
281
301
|
// Cancel any queued reconnect and stop the watchdog: the host wants us down,
|
|
282
302
|
// so neither the loop nor its backstop should bring the socket back up.
|
|
@@ -370,7 +390,7 @@ export class Connection {
|
|
|
370
390
|
// The promise the handshake settles. Wired before the socket can fire any
|
|
371
391
|
// callback so an immediate open/message cannot race ahead of the listener.
|
|
372
392
|
const acked = new Promise<ConnectionAck>((resolve, reject) => {
|
|
373
|
-
const timer = setTimeout(() => {
|
|
393
|
+
const timer = this.timers.setTimeout(() => {
|
|
374
394
|
this.pendingAck = null;
|
|
375
395
|
// Close the half-done socket too: a handshake that times out client-
|
|
376
396
|
// side may still COMPLETE server-side moments later, leaving a ghost
|
|
@@ -501,7 +521,7 @@ export class Connection {
|
|
|
501
521
|
this.reconnectAttempt = 0;
|
|
502
522
|
|
|
503
523
|
if (this.pendingAck) {
|
|
504
|
-
clearTimeout(this.pendingAck.timer);
|
|
524
|
+
this.timers.clearTimeout(this.pendingAck.timer);
|
|
505
525
|
const { resolve } = this.pendingAck;
|
|
506
526
|
this.pendingAck = null;
|
|
507
527
|
resolve(ack);
|
|
@@ -509,6 +529,9 @@ export class Connection {
|
|
|
509
529
|
|
|
510
530
|
this.setState("open");
|
|
511
531
|
this.startLiveness();
|
|
532
|
+
this.deps.logger.debug("ws-session-debug handshake ok", {
|
|
533
|
+
sessionId: ack.sessionId,
|
|
534
|
+
});
|
|
512
535
|
}
|
|
513
536
|
|
|
514
537
|
/**
|
|
@@ -539,6 +562,20 @@ export class Connection {
|
|
|
539
562
|
this.setState("closed");
|
|
540
563
|
|
|
541
564
|
const reason = info.reason || `code ${info.code}`;
|
|
565
|
+
if (isSupersededByNewerSession(reason)) {
|
|
566
|
+
// Newest-wins on the server. Coming back up here would supersede the
|
|
567
|
+
// winner, which then reconnects, forever. Stay down until open().
|
|
568
|
+
this.replacedByNewerSession = true;
|
|
569
|
+
this.clearReconnectTimer();
|
|
570
|
+
this.stopWatchdog();
|
|
571
|
+
this.deps.logger.info("ws-session-debug staying down after supersede", {
|
|
572
|
+
code: info.code,
|
|
573
|
+
reason,
|
|
574
|
+
sessionId: this.currentAck?.sessionId ?? null,
|
|
575
|
+
reconnectAttempt: this.reconnectAttempt,
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
542
579
|
if (isUnauthorizedUpgrade(reason)) {
|
|
543
580
|
void Promise.resolve(this.deps.onAuthRejected?.())
|
|
544
581
|
.catch((err) => {
|
|
@@ -572,6 +609,12 @@ export class Connection {
|
|
|
572
609
|
// Already a retry queued -> nothing to do. This is the guard that makes the
|
|
573
610
|
// double call from onClose + catch-reschedule (and the watchdog) harmless.
|
|
574
611
|
if (this.reconnectTimer !== null) return;
|
|
612
|
+
if (this.replacedByNewerSession) {
|
|
613
|
+
this.deps.logger.debug("ws-session-debug skip reconnect; replaced by newer session", {
|
|
614
|
+
reason,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
575
618
|
|
|
576
619
|
const delay = backoffDelay(this.reconnectAttempt, this.deps.reconnect);
|
|
577
620
|
this.reconnectAttempt += 1;
|
|
@@ -581,7 +624,7 @@ export class Connection {
|
|
|
581
624
|
reason,
|
|
582
625
|
});
|
|
583
626
|
|
|
584
|
-
this.reconnectTimer = setTimeout(() => {
|
|
627
|
+
this.reconnectTimer = this.timers.setTimeout(() => {
|
|
585
628
|
// Null the timer first so this slot is free again: a connect that fails
|
|
586
629
|
// below must be able to schedule the next retry, and the watchdog must see
|
|
587
630
|
// "no reconnect pending" while the attempt is in flight.
|
|
@@ -608,7 +651,7 @@ export class Connection {
|
|
|
608
651
|
/** Cancel a queued reconnect, if any. Safe to call when none is pending. */
|
|
609
652
|
private clearReconnectTimer(): void {
|
|
610
653
|
if (this.reconnectTimer !== null) {
|
|
611
|
-
clearTimeout(this.reconnectTimer);
|
|
654
|
+
this.timers.clearTimeout(this.reconnectTimer);
|
|
612
655
|
this.reconnectTimer = null;
|
|
613
656
|
}
|
|
614
657
|
}
|
|
@@ -620,7 +663,7 @@ export class Connection {
|
|
|
620
663
|
*/
|
|
621
664
|
private startWatchdog(): void {
|
|
622
665
|
this.stopWatchdog();
|
|
623
|
-
this.watchdogTimer = setInterval(
|
|
666
|
+
this.watchdogTimer = this.timers.setInterval(
|
|
624
667
|
() => this.tickWatchdog(),
|
|
625
668
|
RECONNECT_WATCHDOG_MS,
|
|
626
669
|
);
|
|
@@ -629,7 +672,7 @@ export class Connection {
|
|
|
629
672
|
/** Stop the reconnect watchdog (on host close). */
|
|
630
673
|
private stopWatchdog(): void {
|
|
631
674
|
if (this.watchdogTimer !== null) {
|
|
632
|
-
clearInterval(this.watchdogTimer);
|
|
675
|
+
this.timers.clearInterval(this.watchdogTimer);
|
|
633
676
|
this.watchdogTimer = null;
|
|
634
677
|
}
|
|
635
678
|
}
|
|
@@ -649,6 +692,7 @@ export class Connection {
|
|
|
649
692
|
if (
|
|
650
693
|
this.currentState === "closed" &&
|
|
651
694
|
!this.closedByHost &&
|
|
695
|
+
!this.replacedByNewerSession &&
|
|
652
696
|
this.reconnectTimer === null
|
|
653
697
|
) {
|
|
654
698
|
this.scheduleReconnect("watchdog: no reconnect pending");
|
|
@@ -665,7 +709,7 @@ export class Connection {
|
|
|
665
709
|
*/
|
|
666
710
|
private startLiveness(): void {
|
|
667
711
|
this.stopLiveness();
|
|
668
|
-
this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
712
|
+
this.pingTimer = this.timers.setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
669
713
|
}
|
|
670
714
|
|
|
671
715
|
/** Send one liveness ping and arm the pong-wait timer. */
|
|
@@ -676,8 +720,8 @@ export class Connection {
|
|
|
676
720
|
// If a pong-wait timer is already armed, leave it: a single outstanding
|
|
677
721
|
// timeout is enough to catch a dead socket, and re-arming would push the
|
|
678
722
|
// deadline back on every interval.
|
|
679
|
-
if (this.pongTimer) return;
|
|
680
|
-
this.pongTimer = setTimeout(() => {
|
|
723
|
+
if (this.pongTimer !== null) return;
|
|
724
|
+
this.pongTimer = this.timers.setTimeout(() => {
|
|
681
725
|
// No pong in time. The socket is dead; close it so the close handler runs
|
|
682
726
|
// the reconnect path, rather than reconnecting from here and racing the
|
|
683
727
|
// existing socket's eventual close.
|
|
@@ -703,8 +747,8 @@ export class Connection {
|
|
|
703
747
|
|
|
704
748
|
/** Stop both liveness timers (on close, reconnect, or teardown). */
|
|
705
749
|
private stopLiveness(): void {
|
|
706
|
-
if (this.pingTimer) {
|
|
707
|
-
clearInterval(this.pingTimer);
|
|
750
|
+
if (this.pingTimer !== null) {
|
|
751
|
+
this.timers.clearInterval(this.pingTimer);
|
|
708
752
|
this.pingTimer = null;
|
|
709
753
|
}
|
|
710
754
|
this.clearPongTimer();
|
|
@@ -712,8 +756,8 @@ export class Connection {
|
|
|
712
756
|
|
|
713
757
|
/** Disarm the pong-wait timer (a pong arrived, or liveness is stopping). */
|
|
714
758
|
private clearPongTimer(): void {
|
|
715
|
-
if (this.pongTimer) {
|
|
716
|
-
clearTimeout(this.pongTimer);
|
|
759
|
+
if (this.pongTimer !== null) {
|
|
760
|
+
this.timers.clearTimeout(this.pongTimer);
|
|
717
761
|
this.pongTimer = null;
|
|
718
762
|
}
|
|
719
763
|
}
|
|
@@ -724,7 +768,7 @@ export class Connection {
|
|
|
724
768
|
*/
|
|
725
769
|
private failPendingAck(err: Error): void {
|
|
726
770
|
if (!this.pendingAck) return;
|
|
727
|
-
clearTimeout(this.pendingAck.timer);
|
|
771
|
+
this.timers.clearTimeout(this.pendingAck.timer);
|
|
728
772
|
const { reject } = this.pendingAck;
|
|
729
773
|
this.pendingAck = null;
|
|
730
774
|
reject(err);
|
|
@@ -34,6 +34,7 @@ import type { Maps, DirectionsRequest, DirectionsResult, LatLng, ReverseGeocodeR
|
|
|
34
34
|
import type { Tts, RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
|
|
35
35
|
import type { UdpAudio } from "./audio-udp";
|
|
36
36
|
import type { RuntimeSnapshot } from "./status";
|
|
37
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
37
38
|
|
|
38
39
|
const UDP_PROBE_INTERVAL_MS = 1_000;
|
|
39
40
|
const UDP_LIVENESS_TIMEOUT_MS = 3_000;
|
|
@@ -117,6 +118,7 @@ export interface RuntimeDeps {
|
|
|
117
118
|
tts: Tts;
|
|
118
119
|
maps: Maps;
|
|
119
120
|
audio: UdpAudio;
|
|
121
|
+
timers?: CloudClientTimers;
|
|
120
122
|
logger: Logger;
|
|
121
123
|
/**
|
|
122
124
|
* Force `cloud.auth` to drop its cached access token and refresh now.
|
|
@@ -142,13 +144,14 @@ export class Runtime implements RuntimeModule {
|
|
|
142
144
|
public readonly maps: Maps;
|
|
143
145
|
private readonly audio: UdpAudio;
|
|
144
146
|
private readonly logger: Logger;
|
|
147
|
+
private readonly timers: CloudClientTimers;
|
|
145
148
|
private readonly forceRefreshToken: () => Promise<string>;
|
|
146
149
|
private status: RuntimeSnapshot = {
|
|
147
150
|
status: "disconnected",
|
|
148
151
|
audioTransport: "none",
|
|
149
152
|
};
|
|
150
153
|
private hostClosed = true;
|
|
151
|
-
private udpProbeTimer:
|
|
154
|
+
private udpProbeTimer: unknown | null = null;
|
|
152
155
|
private udpProbeStartedAt = 0;
|
|
153
156
|
private lastUdpAckAt = 0;
|
|
154
157
|
|
|
@@ -179,6 +182,7 @@ export class Runtime implements RuntimeModule {
|
|
|
179
182
|
this.tts = deps.tts;
|
|
180
183
|
this.maps = deps.maps;
|
|
181
184
|
this.audio = deps.audio;
|
|
185
|
+
this.timers = deps.timers ?? systemTimers;
|
|
182
186
|
this.logger = deps.logger;
|
|
183
187
|
this.forceRefreshToken = deps.forceRefreshToken;
|
|
184
188
|
}
|
|
@@ -302,16 +306,25 @@ export class Runtime implements RuntimeModule {
|
|
|
302
306
|
// current set (at the current version) to restore live transcription.
|
|
303
307
|
void this.handleReopen();
|
|
304
308
|
} else if (state === "closed") {
|
|
309
|
+
const superseded = this.connection.isReplacedByNewerSession;
|
|
310
|
+
const reason = superseded
|
|
311
|
+
? "superseded by newer session"
|
|
312
|
+
: "socket closed";
|
|
313
|
+
this.logger.debug("ws-session-debug runtime closed", {
|
|
314
|
+
superseded,
|
|
315
|
+
hostClosed: this.hostClosed,
|
|
316
|
+
reason,
|
|
317
|
+
});
|
|
305
318
|
this.stopUdpLiveness();
|
|
306
319
|
this.updateStatus({
|
|
307
|
-
status: this.hostClosed
|
|
320
|
+
status: this.hostClosed || superseded
|
|
308
321
|
? "disconnected"
|
|
309
322
|
: this.opened
|
|
310
323
|
? "reconnecting"
|
|
311
324
|
: "connecting",
|
|
312
325
|
audioTransport: "none",
|
|
313
326
|
});
|
|
314
|
-
this.emitter.emit("disconnected", { reason
|
|
327
|
+
this.emitter.emit("disconnected", { reason });
|
|
315
328
|
}
|
|
316
329
|
});
|
|
317
330
|
}
|
|
@@ -513,15 +526,15 @@ export class Runtime implements RuntimeModule {
|
|
|
513
526
|
this.udpProbeStartedAt = Date.now();
|
|
514
527
|
this.lastUdpAckAt = 0;
|
|
515
528
|
this.sendUdpProbe();
|
|
516
|
-
this.udpProbeTimer = setInterval(() => {
|
|
529
|
+
this.udpProbeTimer = this.timers.setInterval(() => {
|
|
517
530
|
this.sendUdpProbe();
|
|
518
531
|
this.checkUdpLiveness();
|
|
519
532
|
}, UDP_PROBE_INTERVAL_MS);
|
|
520
533
|
}
|
|
521
534
|
|
|
522
535
|
private stopUdpLiveness(): void {
|
|
523
|
-
if (this.udpProbeTimer) {
|
|
524
|
-
clearInterval(this.udpProbeTimer);
|
|
536
|
+
if (this.udpProbeTimer !== null) {
|
|
537
|
+
this.timers.clearInterval(this.udpProbeTimer);
|
|
525
538
|
this.udpProbeTimer = null;
|
|
526
539
|
}
|
|
527
540
|
this.udpProbeStartedAt = 0;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* and docs/issues/004-cloud-client/design.md ("Subscriptions").
|
|
18
18
|
*/
|
|
19
19
|
import type { HttpClient } from "../../http";
|
|
20
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
20
21
|
import type { AudioSubscription } from "@mentra/cloud-protocol";
|
|
21
22
|
|
|
22
23
|
/** The REST path the cloud exposes for the full-replace subscription write. */
|
|
@@ -24,10 +25,12 @@ const SUBSCRIPTIONS_PATH = "/api/audio/subscriptions";
|
|
|
24
25
|
|
|
25
26
|
export interface SubscriptionsDeps {
|
|
26
27
|
http: HttpClient;
|
|
28
|
+
timers?: CloudClientTimers;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export class Subscriptions {
|
|
30
32
|
private readonly http: HttpClient;
|
|
33
|
+
private readonly timers: CloudClientTimers;
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* The last set we sent. Kept so a reconnect can re-send the exact same set at
|
|
@@ -43,6 +46,7 @@ export class Subscriptions {
|
|
|
43
46
|
|
|
44
47
|
constructor(deps: SubscriptionsDeps) {
|
|
45
48
|
this.http = deps.http;
|
|
49
|
+
this.timers = deps.timers ?? systemTimers;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -130,7 +134,7 @@ export class Subscriptions {
|
|
|
130
134
|
|
|
131
135
|
const delay = RETRY_DELAYS_MS[attempt];
|
|
132
136
|
if (res.reason !== "stale-session" || delay === undefined) return;
|
|
133
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
137
|
+
await new Promise<void>((resolve) => this.timers.setTimeout(resolve, delay));
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
}
|
package/src/timers.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Timer surface used by every delayed operation in the cloud client. */
|
|
2
|
+
export interface CloudClientTimers {
|
|
3
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
4
|
+
clearTimeout(handle: unknown): void;
|
|
5
|
+
setInterval(callback: () => void, intervalMs: number): unknown;
|
|
6
|
+
clearInterval(handle: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Default scheduler for Node, Bun, and foreground browser hosts. */
|
|
10
|
+
export const systemTimers: CloudClientTimers = {
|
|
11
|
+
setTimeout(callback, delayMs) {
|
|
12
|
+
return setTimeout(callback, delayMs);
|
|
13
|
+
},
|
|
14
|
+
clearTimeout(handle) {
|
|
15
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
16
|
+
},
|
|
17
|
+
setInterval(callback, intervalMs) {
|
|
18
|
+
return setInterval(callback, intervalMs);
|
|
19
|
+
},
|
|
20
|
+
clearInterval(handle) {
|
|
21
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
22
|
+
},
|
|
23
|
+
};
|