@mentra/cloud-client 0.1.0-beta.0 → 3.1.0-dev.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/cloud-client",
3
- "version": "0.1.0-beta.0",
3
+ "version": "3.1.0-dev.2",
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": "^0.1.0-beta.0",
15
+ "@mentra/cloud-protocol": "3.1.0-dev.2",
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 delay(backoff);
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";
@@ -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: ReturnType<typeof setTimeout>;
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: ReturnType<typeof setTimeout> | null = null;
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: ReturnType<typeof setInterval> | null = null;
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: ReturnType<typeof setInterval> | null = null;
215
- private pongTimer: ReturnType<typeof setTimeout> | null = null;
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: ReturnType<typeof setTimeout>;
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: ReturnType<typeof setInterval> | null = null;
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: "socket closed" });
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
+ };