@mentra/engine 3.2.0-dev.200 → 3.2.0-dev.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  4. package/build/services/DeviceEventRouter.js +3 -7
  5. package/build/services/DeviceEventRouter.js.map +1 -1
  6. package/build/services/PhoneStreamCoordinator.d.ts +7 -20
  7. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  8. package/build/services/PhoneStreamCoordinator.js +16 -99
  9. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  10. package/build/services/asg/galleryNotices.d.ts +1 -1
  11. package/build/services/asg/galleryNotices.d.ts.map +1 -1
  12. package/build/services/asg/galleryNotices.js.map +1 -1
  13. package/build/services/asg/gallerySyncService.d.ts +14 -0
  14. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  15. package/build/services/asg/gallerySyncService.js +232 -96
  16. package/build/services/asg/gallerySyncService.js.map +1 -1
  17. package/build/services/slimStreamStatus.d.ts.map +1 -1
  18. package/build/services/slimStreamStatus.js +16 -0
  19. package/build/services/slimStreamStatus.js.map +1 -1
  20. package/package.json +7 -7
  21. package/src/generated/releaseMetadata.ts +5 -5
  22. package/src/services/DeviceEventRouter.ts +5 -9
  23. package/src/services/PhoneStreamCoordinator.ts +23 -128
  24. package/src/services/asg/galleryNotices.ts +1 -0
  25. package/src/services/asg/gallerySyncService.ts +260 -112
  26. package/src/services/slimStreamStatus.ts +10 -1
  27. package/build/services/StreamLifecycleController.d.ts +0 -85
  28. package/build/services/StreamLifecycleController.d.ts.map +0 -1
  29. package/build/services/StreamLifecycleController.js +0 -173
  30. package/build/services/StreamLifecycleController.js.map +0 -1
  31. package/src/services/StreamLifecycleController.ts +0 -243
@@ -1,173 +0,0 @@
1
- /**
2
- * Drives stream liveliness checks via a keep-alive heartbeat:
3
- * `setActive(true)` starts the timer; each tick sends a keep-alive with a
4
- * fresh ackId; `maxMissedAcks` consecutive timeouts fire `onTimeout`.
5
- *
6
- * Phone-owned successor to the retired cloud stream lifecycle controller.
7
- * It uses the local `LifecycleLogger` interface because pino is not a phone
8
- * dependency.
9
- *
10
- * Timers MUST go through BgTimer. React Native pauses plain `setInterval`
11
- * while MentraOS is backgrounded; glasses WHIP then auto-stops after 60s
12
- * without `keep_stream_alive`, and Mentra Call recovers by restarting ingest.
13
- */
14
- function productionTimers() {
15
- // Lazy so unit tests that inject timers never parse react-native.
16
- const { BgTimer } = require("../utils/timers");
17
- return BgTimer;
18
- }
19
- /**
20
- * Keep-alive heartbeat. Activate to start ticks; each tick sends keep-alive
21
- * with a fresh ackId and arms a timeout. `maxMissedAcks` consecutive misses
22
- * fire `onTimeout`, and the caller is expected to tear the stream down.
23
- */
24
- export class StreamLifecycleController {
25
- callbacks;
26
- keepAliveTimer;
27
- pendingAcks = new Map();
28
- missedAcks = 0;
29
- lastActivityMs;
30
- active = false;
31
- disposed = false;
32
- logger;
33
- streamId;
34
- keepAliveIntervalMs;
35
- ackTimeoutMs;
36
- maxMissedAcks;
37
- shouldSendKeepAlive;
38
- now;
39
- timers;
40
- constructor(options, callbacks) {
41
- this.callbacks = callbacks;
42
- this.logger = options.logger.child({ component: "StreamLifecycle" });
43
- this.streamId = options.streamId;
44
- this.keepAliveIntervalMs = options.keepAliveIntervalMs;
45
- this.ackTimeoutMs = options.ackTimeoutMs;
46
- this.maxMissedAcks = options.maxMissedAcks;
47
- this.shouldSendKeepAlive = options.shouldSendKeepAlive;
48
- this.now = options.now ?? (() => Date.now());
49
- this.timers = options.timers ?? productionTimers();
50
- this.lastActivityMs = this.now();
51
- }
52
- setActive(active) {
53
- if (this.disposed || this.active === active)
54
- return;
55
- this.logger.debug({ streamId: this.streamId, active }, "Updating lifecycle active state");
56
- this.active = active;
57
- if (active) {
58
- this.startTimer();
59
- }
60
- else {
61
- this.stopTimer();
62
- this.clearPendingAcks();
63
- this.missedAcks = 0;
64
- }
65
- }
66
- recordActivity() {
67
- this.lastActivityMs = this.now();
68
- this.missedAcks = 0;
69
- }
70
- /**
71
- * Send one keep-alive immediately instead of waiting for the next interval.
72
- * Used when the BLE link comes back after a suspension: the glasses
73
- * publisher's 60s watchdog has been running the whole time, so the first
74
- * heartbeat after resume must not wait up to another full interval.
75
- */
76
- tickNow() {
77
- if (this.disposed || !this.active)
78
- return;
79
- void this.tick();
80
- }
81
- handleAck(ackId) {
82
- if (this.disposed)
83
- return;
84
- const ackInfo = this.pendingAcks.get(ackId);
85
- if (!ackInfo) {
86
- this.logger.warn({ streamId: this.streamId, ackId }, "Received unknown keep-alive ACK");
87
- return;
88
- }
89
- this.timers.clearTimeout(ackInfo.timeout);
90
- this.pendingAcks.delete(ackId);
91
- this.recordActivity();
92
- this.callbacks.onKeepAliveAcked?.(ackId, this.now() - ackInfo.sentAt);
93
- }
94
- dispose() {
95
- if (this.disposed)
96
- return;
97
- this.disposed = true;
98
- this.stopTimer();
99
- this.clearPendingAcks();
100
- this.logger.debug({ streamId: this.streamId }, "Lifecycle disposed");
101
- }
102
- getLastActivityMs() {
103
- return this.lastActivityMs;
104
- }
105
- startTimer() {
106
- if (this.keepAliveTimer)
107
- return;
108
- this.keepAliveTimer = this.timers.setInterval(() => {
109
- void this.tick();
110
- }, this.keepAliveIntervalMs);
111
- this.logger.debug({ streamId: this.streamId }, "Keep-alive timer started");
112
- }
113
- stopTimer() {
114
- if (this.keepAliveTimer) {
115
- this.timers.clearInterval(this.keepAliveTimer);
116
- this.keepAliveTimer = undefined;
117
- this.logger.debug({ streamId: this.streamId }, "Keep-alive timer stopped");
118
- }
119
- }
120
- async tick() {
121
- if (this.disposed || !this.active)
122
- return;
123
- if (this.shouldSendKeepAlive && !this.shouldSendKeepAlive()) {
124
- this.logger.warn({ streamId: this.streamId }, "Skipping keep-alive send because transport is unavailable");
125
- return;
126
- }
127
- const ackId = this.createAckId();
128
- const sentAt = this.now();
129
- const timeout = this.timers.setTimeout(() => {
130
- this.onAckTimeout(ackId, sentAt);
131
- }, this.ackTimeoutMs);
132
- this.pendingAcks.set(ackId, { sentAt, timeout });
133
- this.callbacks.onKeepAliveSent?.(ackId);
134
- try {
135
- await this.callbacks.sendKeepAlive(ackId);
136
- }
137
- catch (error) {
138
- this.logger.error({ streamId: this.streamId, ackId, error }, "Error sending keep-alive");
139
- }
140
- }
141
- onAckTimeout(ackId, sentAt) {
142
- if (this.disposed)
143
- return;
144
- // Race guard: handleAck may have processed the ACK between the timeout
145
- // being scheduled and this callback running. In that case the ackId is
146
- // no longer pending — counting it as missed would falsely escalate.
147
- if (!this.pendingAcks.has(ackId))
148
- return;
149
- this.pendingAcks.delete(ackId);
150
- this.missedAcks += 1;
151
- const ageMs = this.now() - sentAt;
152
- this.logger.warn({ streamId: this.streamId, ackId, missedAcks: this.missedAcks, ageMs }, "Keep-alive ACK timeout");
153
- this.callbacks.onKeepAliveMissed?.(ackId, ageMs, this.missedAcks);
154
- if (this.missedAcks >= this.maxMissedAcks) {
155
- this.logger.error({
156
- streamId: this.streamId,
157
- missedAcks: this.missedAcks,
158
- maxMissedAcks: this.maxMissedAcks,
159
- }, "Maximum missed ACKs reached; triggering timeout");
160
- void this.callbacks.onTimeout();
161
- }
162
- }
163
- clearPendingAcks() {
164
- for (const { timeout } of this.pendingAcks.values()) {
165
- this.timers.clearTimeout(timeout);
166
- }
167
- this.pendingAcks.clear();
168
- }
169
- createAckId() {
170
- return `a${this.now().toString(36).slice(-5)}`;
171
- }
172
- }
173
- //# sourceMappingURL=StreamLifecycleController.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"StreamLifecycleController.js","sourceRoot":"","sources":["../../src/services/StreamLifecycleController.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Drives stream liveliness checks via a keep-alive heartbeat:\n * `setActive(true)` starts the timer; each tick sends a keep-alive with a\n * fresh ackId; `maxMissedAcks` consecutive timeouts fire `onTimeout`.\n *\n * Phone-owned successor to the retired cloud stream lifecycle controller.\n * It uses the local `LifecycleLogger` interface because pino is not a phone\n * dependency.\n *\n * Timers MUST go through BgTimer. React Native pauses plain `setInterval`\n * while MentraOS is backgrounded; glasses WHIP then auto-stops after 60s\n * without `keep_stream_alive`, and Mentra Call recovers by restarting ingest.\n */\n\nexport interface LifecycleLogger {\n child(bindings: Record<string, unknown>): LifecycleLogger\n debug(...args: unknown[]): void\n warn(...args: unknown[]): void\n error(...args: unknown[]): void\n}\n\ninterface StreamLifecycleCallbacks {\n sendKeepAlive: (ackId: string) => Promise<void> | void\n onTimeout: () => Promise<void> | void\n onKeepAliveSent?: (ackId: string) => void\n onKeepAliveAcked?: (ackId: string, ageMs: number) => void\n onKeepAliveMissed?: (ackId: string, ageMs: number, missedCount: number) => void\n}\n\nexport interface StreamLifecycleTimerApi {\n setInterval: (callback: () => void, delay: number) => number\n clearInterval: (intervalId: number) => void\n setTimeout: (callback: () => void, delay: number) => number\n clearTimeout: (timeoutId: number) => void\n}\n\nexport interface StreamLifecycleOptions {\n logger: LifecycleLogger\n streamId: string\n keepAliveIntervalMs: number\n ackTimeoutMs: number\n maxMissedAcks: number\n shouldSendKeepAlive?: () => boolean\n now?: () => number\n timers?: StreamLifecycleTimerApi\n}\n\ninterface PendingAckInfo {\n sentAt: number\n timeout: number\n}\n\nfunction productionTimers(): StreamLifecycleTimerApi {\n // Lazy so unit tests that inject timers never parse react-native.\n const {BgTimer} = require(\"../utils/timers\") as {BgTimer: StreamLifecycleTimerApi}\n return BgTimer\n}\n\n/**\n * Keep-alive heartbeat. Activate to start ticks; each tick sends keep-alive\n * with a fresh ackId and arms a timeout. `maxMissedAcks` consecutive misses\n * fire `onTimeout`, and the caller is expected to tear the stream down.\n */\nexport class StreamLifecycleController {\n private keepAliveTimer?: number\n private pendingAcks: Map<string, PendingAckInfo> = new Map()\n private missedAcks = 0\n private lastActivityMs: number\n private active = false\n private disposed = false\n\n private readonly logger: LifecycleLogger\n private readonly streamId: string\n private readonly keepAliveIntervalMs: number\n private readonly ackTimeoutMs: number\n private readonly maxMissedAcks: number\n private readonly shouldSendKeepAlive?: () => boolean\n private readonly now: () => number\n private readonly timers: StreamLifecycleTimerApi\n\n constructor(\n options: StreamLifecycleOptions,\n private readonly callbacks: StreamLifecycleCallbacks,\n ) {\n this.logger = options.logger.child({component: \"StreamLifecycle\"})\n this.streamId = options.streamId\n this.keepAliveIntervalMs = options.keepAliveIntervalMs\n this.ackTimeoutMs = options.ackTimeoutMs\n this.maxMissedAcks = options.maxMissedAcks\n this.shouldSendKeepAlive = options.shouldSendKeepAlive\n this.now = options.now ?? (() => Date.now())\n this.timers = options.timers ?? productionTimers()\n this.lastActivityMs = this.now()\n }\n\n setActive(active: boolean): void {\n if (this.disposed || this.active === active) return\n\n this.logger.debug({streamId: this.streamId, active}, \"Updating lifecycle active state\")\n\n this.active = active\n if (active) {\n this.startTimer()\n } else {\n this.stopTimer()\n this.clearPendingAcks()\n this.missedAcks = 0\n }\n }\n\n recordActivity(): void {\n this.lastActivityMs = this.now()\n this.missedAcks = 0\n }\n\n /**\n * Send one keep-alive immediately instead of waiting for the next interval.\n * Used when the BLE link comes back after a suspension: the glasses\n * publisher's 60s watchdog has been running the whole time, so the first\n * heartbeat after resume must not wait up to another full interval.\n */\n tickNow(): void {\n if (this.disposed || !this.active) return\n void this.tick()\n }\n\n handleAck(ackId: string): void {\n if (this.disposed) return\n\n const ackInfo = this.pendingAcks.get(ackId)\n if (!ackInfo) {\n this.logger.warn({streamId: this.streamId, ackId}, \"Received unknown keep-alive ACK\")\n return\n }\n\n this.timers.clearTimeout(ackInfo.timeout)\n this.pendingAcks.delete(ackId)\n this.recordActivity()\n\n this.callbacks.onKeepAliveAcked?.(ackId, this.now() - ackInfo.sentAt)\n }\n\n dispose(): void {\n if (this.disposed) return\n\n this.disposed = true\n this.stopTimer()\n this.clearPendingAcks()\n this.logger.debug({streamId: this.streamId}, \"Lifecycle disposed\")\n }\n\n getLastActivityMs(): number {\n return this.lastActivityMs\n }\n\n private startTimer(): void {\n if (this.keepAliveTimer) return\n this.keepAliveTimer = this.timers.setInterval(() => {\n void this.tick()\n }, this.keepAliveIntervalMs)\n this.logger.debug({streamId: this.streamId}, \"Keep-alive timer started\")\n }\n\n private stopTimer(): void {\n if (this.keepAliveTimer) {\n this.timers.clearInterval(this.keepAliveTimer)\n this.keepAliveTimer = undefined\n this.logger.debug({streamId: this.streamId}, \"Keep-alive timer stopped\")\n }\n }\n\n private async tick(): Promise<void> {\n if (this.disposed || !this.active) return\n\n if (this.shouldSendKeepAlive && !this.shouldSendKeepAlive()) {\n this.logger.warn(\n {streamId: this.streamId},\n \"Skipping keep-alive send because transport is unavailable\",\n )\n return\n }\n\n const ackId = this.createAckId()\n const sentAt = this.now()\n\n const timeout = this.timers.setTimeout(() => {\n this.onAckTimeout(ackId, sentAt)\n }, this.ackTimeoutMs)\n\n this.pendingAcks.set(ackId, {sentAt, timeout})\n this.callbacks.onKeepAliveSent?.(ackId)\n\n try {\n await this.callbacks.sendKeepAlive(ackId)\n } catch (error) {\n this.logger.error({streamId: this.streamId, ackId, error}, \"Error sending keep-alive\")\n }\n }\n\n private onAckTimeout(ackId: string, sentAt: number): void {\n if (this.disposed) return\n\n // Race guard: handleAck may have processed the ACK between the timeout\n // being scheduled and this callback running. In that case the ackId is\n // no longer pending — counting it as missed would falsely escalate.\n if (!this.pendingAcks.has(ackId)) return\n\n this.pendingAcks.delete(ackId)\n this.missedAcks += 1\n const ageMs = this.now() - sentAt\n\n this.logger.warn(\n {streamId: this.streamId, ackId, missedAcks: this.missedAcks, ageMs},\n \"Keep-alive ACK timeout\",\n )\n\n this.callbacks.onKeepAliveMissed?.(ackId, ageMs, this.missedAcks)\n\n if (this.missedAcks >= this.maxMissedAcks) {\n this.logger.error(\n {\n streamId: this.streamId,\n missedAcks: this.missedAcks,\n maxMissedAcks: this.maxMissedAcks,\n },\n \"Maximum missed ACKs reached; triggering timeout\",\n )\n\n void this.callbacks.onTimeout()\n }\n }\n\n private clearPendingAcks(): void {\n for (const {timeout} of this.pendingAcks.values()) {\n this.timers.clearTimeout(timeout)\n }\n this.pendingAcks.clear()\n }\n\n private createAckId(): string {\n return `a${this.now().toString(36).slice(-5)}`\n }\n}\n"]}
@@ -1,243 +0,0 @@
1
- /**
2
- * Drives stream liveliness checks via a keep-alive heartbeat:
3
- * `setActive(true)` starts the timer; each tick sends a keep-alive with a
4
- * fresh ackId; `maxMissedAcks` consecutive timeouts fire `onTimeout`.
5
- *
6
- * Phone-owned successor to the retired cloud stream lifecycle controller.
7
- * It uses the local `LifecycleLogger` interface because pino is not a phone
8
- * dependency.
9
- *
10
- * Timers MUST go through BgTimer. React Native pauses plain `setInterval`
11
- * while MentraOS is backgrounded; glasses WHIP then auto-stops after 60s
12
- * without `keep_stream_alive`, and Mentra Call recovers by restarting ingest.
13
- */
14
-
15
- export interface LifecycleLogger {
16
- child(bindings: Record<string, unknown>): LifecycleLogger
17
- debug(...args: unknown[]): void
18
- warn(...args: unknown[]): void
19
- error(...args: unknown[]): void
20
- }
21
-
22
- interface StreamLifecycleCallbacks {
23
- sendKeepAlive: (ackId: string) => Promise<void> | void
24
- onTimeout: () => Promise<void> | void
25
- onKeepAliveSent?: (ackId: string) => void
26
- onKeepAliveAcked?: (ackId: string, ageMs: number) => void
27
- onKeepAliveMissed?: (ackId: string, ageMs: number, missedCount: number) => void
28
- }
29
-
30
- export interface StreamLifecycleTimerApi {
31
- setInterval: (callback: () => void, delay: number) => number
32
- clearInterval: (intervalId: number) => void
33
- setTimeout: (callback: () => void, delay: number) => number
34
- clearTimeout: (timeoutId: number) => void
35
- }
36
-
37
- export interface StreamLifecycleOptions {
38
- logger: LifecycleLogger
39
- streamId: string
40
- keepAliveIntervalMs: number
41
- ackTimeoutMs: number
42
- maxMissedAcks: number
43
- shouldSendKeepAlive?: () => boolean
44
- now?: () => number
45
- timers?: StreamLifecycleTimerApi
46
- }
47
-
48
- interface PendingAckInfo {
49
- sentAt: number
50
- timeout: number
51
- }
52
-
53
- function productionTimers(): StreamLifecycleTimerApi {
54
- // Lazy so unit tests that inject timers never parse react-native.
55
- const {BgTimer} = require("../utils/timers") as {BgTimer: StreamLifecycleTimerApi}
56
- return BgTimer
57
- }
58
-
59
- /**
60
- * Keep-alive heartbeat. Activate to start ticks; each tick sends keep-alive
61
- * with a fresh ackId and arms a timeout. `maxMissedAcks` consecutive misses
62
- * fire `onTimeout`, and the caller is expected to tear the stream down.
63
- */
64
- export class StreamLifecycleController {
65
- private keepAliveTimer?: number
66
- private pendingAcks: Map<string, PendingAckInfo> = new Map()
67
- private missedAcks = 0
68
- private lastActivityMs: number
69
- private active = false
70
- private disposed = false
71
-
72
- private readonly logger: LifecycleLogger
73
- private readonly streamId: string
74
- private readonly keepAliveIntervalMs: number
75
- private readonly ackTimeoutMs: number
76
- private readonly maxMissedAcks: number
77
- private readonly shouldSendKeepAlive?: () => boolean
78
- private readonly now: () => number
79
- private readonly timers: StreamLifecycleTimerApi
80
-
81
- constructor(
82
- options: StreamLifecycleOptions,
83
- private readonly callbacks: StreamLifecycleCallbacks,
84
- ) {
85
- this.logger = options.logger.child({component: "StreamLifecycle"})
86
- this.streamId = options.streamId
87
- this.keepAliveIntervalMs = options.keepAliveIntervalMs
88
- this.ackTimeoutMs = options.ackTimeoutMs
89
- this.maxMissedAcks = options.maxMissedAcks
90
- this.shouldSendKeepAlive = options.shouldSendKeepAlive
91
- this.now = options.now ?? (() => Date.now())
92
- this.timers = options.timers ?? productionTimers()
93
- this.lastActivityMs = this.now()
94
- }
95
-
96
- setActive(active: boolean): void {
97
- if (this.disposed || this.active === active) return
98
-
99
- this.logger.debug({streamId: this.streamId, active}, "Updating lifecycle active state")
100
-
101
- this.active = active
102
- if (active) {
103
- this.startTimer()
104
- } else {
105
- this.stopTimer()
106
- this.clearPendingAcks()
107
- this.missedAcks = 0
108
- }
109
- }
110
-
111
- recordActivity(): void {
112
- this.lastActivityMs = this.now()
113
- this.missedAcks = 0
114
- }
115
-
116
- /**
117
- * Send one keep-alive immediately instead of waiting for the next interval.
118
- * Used when the BLE link comes back after a suspension: the glasses
119
- * publisher's 60s watchdog has been running the whole time, so the first
120
- * heartbeat after resume must not wait up to another full interval.
121
- */
122
- tickNow(): void {
123
- if (this.disposed || !this.active) return
124
- void this.tick()
125
- }
126
-
127
- handleAck(ackId: string): void {
128
- if (this.disposed) return
129
-
130
- const ackInfo = this.pendingAcks.get(ackId)
131
- if (!ackInfo) {
132
- this.logger.warn({streamId: this.streamId, ackId}, "Received unknown keep-alive ACK")
133
- return
134
- }
135
-
136
- this.timers.clearTimeout(ackInfo.timeout)
137
- this.pendingAcks.delete(ackId)
138
- this.recordActivity()
139
-
140
- this.callbacks.onKeepAliveAcked?.(ackId, this.now() - ackInfo.sentAt)
141
- }
142
-
143
- dispose(): void {
144
- if (this.disposed) return
145
-
146
- this.disposed = true
147
- this.stopTimer()
148
- this.clearPendingAcks()
149
- this.logger.debug({streamId: this.streamId}, "Lifecycle disposed")
150
- }
151
-
152
- getLastActivityMs(): number {
153
- return this.lastActivityMs
154
- }
155
-
156
- private startTimer(): void {
157
- if (this.keepAliveTimer) return
158
- this.keepAliveTimer = this.timers.setInterval(() => {
159
- void this.tick()
160
- }, this.keepAliveIntervalMs)
161
- this.logger.debug({streamId: this.streamId}, "Keep-alive timer started")
162
- }
163
-
164
- private stopTimer(): void {
165
- if (this.keepAliveTimer) {
166
- this.timers.clearInterval(this.keepAliveTimer)
167
- this.keepAliveTimer = undefined
168
- this.logger.debug({streamId: this.streamId}, "Keep-alive timer stopped")
169
- }
170
- }
171
-
172
- private async tick(): Promise<void> {
173
- if (this.disposed || !this.active) return
174
-
175
- if (this.shouldSendKeepAlive && !this.shouldSendKeepAlive()) {
176
- this.logger.warn(
177
- {streamId: this.streamId},
178
- "Skipping keep-alive send because transport is unavailable",
179
- )
180
- return
181
- }
182
-
183
- const ackId = this.createAckId()
184
- const sentAt = this.now()
185
-
186
- const timeout = this.timers.setTimeout(() => {
187
- this.onAckTimeout(ackId, sentAt)
188
- }, this.ackTimeoutMs)
189
-
190
- this.pendingAcks.set(ackId, {sentAt, timeout})
191
- this.callbacks.onKeepAliveSent?.(ackId)
192
-
193
- try {
194
- await this.callbacks.sendKeepAlive(ackId)
195
- } catch (error) {
196
- this.logger.error({streamId: this.streamId, ackId, error}, "Error sending keep-alive")
197
- }
198
- }
199
-
200
- private onAckTimeout(ackId: string, sentAt: number): void {
201
- if (this.disposed) return
202
-
203
- // Race guard: handleAck may have processed the ACK between the timeout
204
- // being scheduled and this callback running. In that case the ackId is
205
- // no longer pending — counting it as missed would falsely escalate.
206
- if (!this.pendingAcks.has(ackId)) return
207
-
208
- this.pendingAcks.delete(ackId)
209
- this.missedAcks += 1
210
- const ageMs = this.now() - sentAt
211
-
212
- this.logger.warn(
213
- {streamId: this.streamId, ackId, missedAcks: this.missedAcks, ageMs},
214
- "Keep-alive ACK timeout",
215
- )
216
-
217
- this.callbacks.onKeepAliveMissed?.(ackId, ageMs, this.missedAcks)
218
-
219
- if (this.missedAcks >= this.maxMissedAcks) {
220
- this.logger.error(
221
- {
222
- streamId: this.streamId,
223
- missedAcks: this.missedAcks,
224
- maxMissedAcks: this.maxMissedAcks,
225
- },
226
- "Maximum missed ACKs reached; triggering timeout",
227
- )
228
-
229
- void this.callbacks.onTimeout()
230
- }
231
- }
232
-
233
- private clearPendingAcks(): void {
234
- for (const {timeout} of this.pendingAcks.values()) {
235
- this.timers.clearTimeout(timeout)
236
- }
237
- this.pendingAcks.clear()
238
- }
239
-
240
- private createAckId(): string {
241
- return `a${this.now().toString(36).slice(-5)}`
242
- }
243
- }