@gotcos/glasses-server 6.36.27 → 6.36.28

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/CHANGELOG.md CHANGED
@@ -1,3 +1,60 @@
1
+ ## 6.36.28
2
+
3
+ THE bug. Three earlier fixes tonight were each real and none was this one.
4
+
5
+ Every segment of a reply is minted at /prepare with a 120s idle deadline that
6
+ starts AT MINT. But the client only touches segment k when segment k-1 starts
7
+ playing. Measured on device, 6,781 chars at 1.25x:
8
+
9
+ seg 4 first touched t+101s played
10
+ seg 5 first touched t+147s FAILED
11
+ seg 6 first touched t+215s FAILED
12
+ seg 7 never reached FAILED
13
+
14
+ Segment 5 was deleted 27s before the client first asked for it. Reproduced with
15
+ curl against the running server, replaying the exact timeline: HTTP 404 at
16
+ segment 5, t+216s. The 404 reaches the audio element as NotSupportedError at
17
+ readyState 0 -- identical to every other media failure, which is why a char cap,
18
+ a wider idle window and a render gate all left playback stopping at segment 5.
19
+
20
+ WHAT CHANGED
21
+
22
+ - An unread session gets a grace window DERIVED from how long the whole reply
23
+ takes to speak, plus one segment of margin (the last segment is first touched
24
+ when the second-to-last starts playing).
25
+ - A read may only ever EXTEND a deadline, never shorten one. This is not
26
+ cosmetic: the client warms segment k one whole segment before playing it, so
27
+ collapsing the grace on that first read SPENT it. At 0.5x a 900-char segment
28
+ is 170s of wall time against a 120s window, and the reply lost segment 1 while
29
+ holding a 1,476s grace. Found by QA, after the first version of this fix.
30
+ - SESSION_IDLE_MS and SESSION_MAX_LIFETIME_MS are now COMPUTED from shared
31
+ constants rather than written down. Both were derived from ~19 chars/sec, the
32
+ FAST voice, and this release is the one that measured the slow voice at 10.
33
+ Leaving them meant the file asserted two contradictory worst cases:
34
+ idle 900 / 10 / 0.5 = 180s against a 120s window
35
+ ceiling 40,000 / 10 / 0.5 = 133 min against a 90 min ceiling
36
+ The ceiling had also started silently truncating the grace for any reply over
37
+ ~26,400 chars, relocating the same failure to roughly segment 31 of 46.
38
+ - MAX_LOCAL_TTS_CHARS, LATER_CHUNK_CHARS, the speech rate and the minimum
39
+ playback rate now live in one place and are imported by the tests that used to
40
+ restate them. That duplication is why the contradiction above was invisible.
41
+ - /play now logs the 404 it produces. The server held this fact all evening and
42
+ recorded nothing, so the only reporter was a fire-and-forget client call.
43
+ - CORS exposes Content-Range and Accept-Ranges, without which the client's
44
+ failure probe reads them as null from the null-origin companion WebView.
45
+
46
+ SECURITY, stated plainly rather than buried
47
+
48
+ An unread capability now lives far longer than 120 seconds -- up to the ceiling,
49
+ which is 136 minutes. That is a real widening and it is the price of playing a
50
+ 40,000-character reply, which genuinely takes over two hours at 0.5x. What is
51
+ unchanged: the ceiling is absolute, reading cannot extend a capability past the
52
+ grace it was minted with, and an unread capability still expires on its own.
53
+ Both properties are pinned and both mutate red.
54
+
55
+ 216 files, 3047 tests in scope (218 / 3053 including a parallel session's two
56
+ files, which are not part of this release).
57
+
1
58
  ## 6.36.27
2
59
 
3
60
  The sidecar renders one request at a time. The server finally acts like it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.27",
3
+ "version": "6.36.28",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -172,6 +172,12 @@ app.use(cors({
172
172
  if (isAllowedNetworkOrigin(origin)) return cb(null, true)
173
173
  cb(new Error('CORS blocked'))
174
174
  },
175
+ // Without this, a cross-origin reader sees only the CORS-safelisted response
176
+ // headers. The companion's origin is `null` (a file:// WebView), so it is
177
+ // cross-origin by definition, and the TTS failure probe would log
178
+ // contentRange/acceptRanges as null on every single call -- leading the next
179
+ // reader to conclude the server had stopped sending Range headers.
180
+ exposedHeaders: ['Content-Range', 'Accept-Ranges'],
175
181
  }))
176
182
  // Auth middleware — always active (token is auto-generated if not set).
177
183
  // Mounted before body parsers so rejected uploads cannot consume parse memory.
@@ -101,25 +101,68 @@ const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
101
101
  * practical exposure window is unchanged". That was true, and it is also what
102
102
  * left the ceiling in place.
103
103
  */
104
+ /**
105
+ * SHARED PHYSICAL CONSTANTS for every TTS deadline in this file.
106
+ *
107
+ * These sit at the top because three separate deadlines are derived from them,
108
+ * and on 2026-08-23 two of those deadlines were derived from a DIFFERENT
109
+ * speech rate than the third. Both cannot be right, and the tests could not see
110
+ * the contradiction because each restated its own copy of the rate.
111
+ */
112
+
113
+ /** The longest a single chunk can be. Mirrors the chunker in routes/tts.ts. */
114
+ export const LATER_CHUNK_CHARS = 900
115
+
116
+ /** The most text one reply can be asked to speak locally. */
117
+ export const MAX_LOCAL_TTS_CHARS = 40_000
118
+
119
+ /**
120
+ * Characters spoken per second by the SLOWEST voice, not the average.
121
+ *
122
+ * Measured on device 2026-08-23: am_echo ~19 chars/sec, bm_george as low as
123
+ * 10.6 on one segment. Every deadline below uses 10, because a window sized on
124
+ * the fast voice does not cover the slow one -- which is the entire class of
125
+ * bug these constants exist to end.
126
+ *
127
+ * CAVEAT, stated because it is load-bearing: this is one segment of one voice
128
+ * out of 28 shipped Kokoro voices. It errs safe for a WINDOW (too slow means
129
+ * too generous) but it has not been censused, and a voice slower than 10 would
130
+ * undersize every deadline here at once.
131
+ */
132
+ export const SLOWEST_SPEECH_CHARS_PER_SEC = 10
133
+
134
+ /**
135
+ * The slowest rate playback can actually run at.
136
+ *
137
+ * NOT the slowest option the picker offers -- that is 0.75x. This is the clamp
138
+ * floor in the client's getPreferredSpeed(), which exists so a poisoned
139
+ * localStorage value cannot produce an absurd rate. Deadlines must survive the
140
+ * clamp, not just the menu.
141
+ */
142
+ export const MIN_PLAYBACK_RATE = 0.5
143
+
144
+ /** Wall-clock ms to speak `chars` at the slowest voice and slowest rate. */
145
+ export function worstCaseSpeechMs(chars: number): number {
146
+ return (chars / SLOWEST_SPEECH_CHARS_PER_SEC / MIN_PLAYBACK_RATE) * 1000
147
+ }
148
+
104
149
  export const SESSION_IDLE_MS = (() => {
105
- // DERIVED, like the ceiling below, and for the same reason: a window that does
106
- // not cover its own inputs is the bug this replaced.
150
+ // COMPUTED from the shared constants, not written down.
151
+ //
152
+ // A session that is being read must outlast the gap between two reads. The
153
+ // client warms segment i+1 at the START of segment i and does not touch it
154
+ // again until segment i FINISHES, so that gap is one full segment:
107
155
  //
108
- // Every segment's session is minted at /prepare. The client warms segment i+1
109
- // at the START of segment i, so the gap between that touch and the real request
110
- // is ONE FULL SEGMENT of playback. The window has to outlast that gap at the
111
- // slowest speed the client offers:
156
+ // worstCaseSpeechMs(LATER_CHUNK_CHARS) = 900 / 10 / 0.5 = 180s
112
157
  //
113
- // LATER_CHUNK_CHARS 900 chars
114
- // speech rate ~19 chars/sec (measured)
115
- // MIN_SPEED 0.5x (voice-output.ts clamps here)
116
- // => 900 / 19 / 0.5 = 94.7s
158
+ // The previous value, 120s, was derived from ~19 chars/sec -- the FAST voice.
159
+ // Once bm_george was measured at 10.6 the derivation was stale, and 900 chars
160
+ // at 0.5x is 180s against a 120s window. That is the same defect this file
161
+ // has now hit three times, so the value is computed here rather than chosen.
117
162
  //
118
- // 60s covered 1x (47.4s) and 1.25x (37.9s) but NOT 0.75x (63.2s) -- a shipped
119
- // option in the Settings picker. At 0.75x every other segment would 404, and
120
- // because onError resolves rather than rejects, playback would skip on and
121
- // sound complete while dropping half the reply. 120s covers 0.5x with margin.
122
- return 120_000
163
+ // x1.5 of margin absorbs a stalled segment or a slow refill without letting an
164
+ // abandoned capability linger: 4.5 minutes, not 90.
165
+ return Math.ceil(worstCaseSpeechMs(LATER_CHUNK_CHARS) * 1.5)
123
166
  })()
124
167
 
125
168
  /**
@@ -129,24 +172,32 @@ export const SESSION_IDLE_MS = (() => {
129
172
  * sliding window could be kept alive indefinitely by polling. This bounds the
130
173
  * exposure of a leaked URL.
131
174
  *
132
- * DERIVED, not picked. Every segment of a reply is minted at prepare time, so
133
- * the ceiling has to outlast the WHOLE reply played at the SLOWEST speed the
134
- * client offers -- otherwise the last segments expire before playback reaches
135
- * them, which is the same class of bug as the 60s deadline this replaced:
175
+ * COMPUTED from the largest reply the system can be asked to speak, at the
176
+ * slowest voice and slowest rate, plus one segment of margin -- which is exactly
177
+ * what initialGraceMs computes, so the ceiling is defined as "the largest grace
178
+ * that can legally be issued":
136
179
  *
137
- * MAX_LOCAL_TTS_CHARS 40,000 chars
138
- * speech rate ~19 chars per second (measured)
139
- * MIN_SPEED 0.5x (voice-output.ts clamps here)
140
- * => 40000 / 19 / 0.5 = 70.2 minutes of audio
180
+ * worstCaseSpeechMs(40,000) + worstCaseSpeechMs(900)
181
+ * = 40000/10/0.5 + 900/10/0.5 = 8000s + 180s = 136.3 minutes
141
182
  *
142
- * 30 minutes was shorter than both that and the 1x case (35.1 min), so a maximal
143
- * reply would have cut off near the end. 90 minutes covers the worst case with
144
- * margin and is still a bounded window.
183
+ * The previous 90 minutes was derived from ~19 chars/sec. After the slow voice
184
+ * was measured at 10.6 that stopped covering its own input: initialGraceMs
185
+ * exceeded the ceiling for any reply over ~26,400 characters, so the ceiling
186
+ * silently truncated the grace and a maximal reply's last segments died before
187
+ * playback reached them -- the segment-5 failure relocated to segment 31 of 46.
145
188
  *
146
- * If MAX_LOCAL_TTS_CHARS or MIN_SPEED changes, re-derive this. A ceiling that
147
- * silently stops covering its own inputs is exactly what went wrong before.
189
+ * This is a long-lived bearer capability and that is a real trade, stated rather
190
+ * than buried: a 40,000-character reply genuinely takes over two hours to speak
191
+ * at 0.5x, and the capability must outlive the audio it serves. The bound is
192
+ * still absolute and still unextendable by reading.
193
+ *
194
+ * There is no separate arithmetic to keep in sync. Change a shared constant and
195
+ * both this and the grace move together, and the test below asserts the ceiling
196
+ * covers the largest grace.
148
197
  */
149
- export const SESSION_MAX_LIFETIME_MS = 90 * 60_000
198
+ export const SESSION_MAX_LIFETIME_MS = Math.ceil(
199
+ worstCaseSpeechMs(MAX_LOCAL_TTS_CHARS) + worstCaseSpeechMs(LATER_CHUNK_CHARS),
200
+ )
150
201
 
151
202
  /** Disk cache configuration (env-overridable). Defaults sized for "I run this
152
203
  * on my laptop and forget about it for months" rather than a service tier.
@@ -562,12 +613,60 @@ function sweepStaleByAge(): void {
562
613
  * (hash, text, voice, format) bundle. The play route may reread it for native
563
614
  * Range refills during the 60-second TTL; expired sessions are rejected and
564
615
  * reaped by the periodic sweeper below. */
565
- export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
616
+ /**
617
+ * How long a session that has NEVER been read stays alive.
618
+ *
619
+ * WHY THIS IS NOT SESSION_IDLE_MS. Every segment of a reply is minted at
620
+ * /prepare, but the client only touches segment k when segment k-1 STARTS
621
+ * playing. For a 9-segment reply that first touch can be minutes away:
622
+ *
623
+ * measured on device, 6,781 chars at 1.25x
624
+ * seg 4 first touched at t+101s played
625
+ * seg 5 first touched at t+147s FAILED
626
+ * seg 6 first touched at t+215s FAILED
627
+ * seg 7 never reached FAILED
628
+ *
629
+ * With a flat 120s idle deadline running from MINT, segment 5 was already dead
630
+ * when the client first asked for it, and the 404 arrived as
631
+ * NotSupportedError. Playback stopped at exactly segment 5 on every run.
632
+ *
633
+ * So the idle clock must not start before anyone could reasonably read it. An
634
+ * unread session gets a grace window derived from how long the WHOLE reply takes
635
+ * to speak at the slowest voice and slowest rate; the 120s idle window applies
636
+ * from the first read onward, when it means what it says.
637
+ */
638
+ export function initialGraceMs(totalChars: number): number {
639
+ // The margin is ONE SEGMENT, not one idle window. The last segment is first
640
+ // touched when the second-to-last STARTS playing, so the window has to reach
641
+ // one segment past the end of the reply. An earlier version added
642
+ // SESSION_IDLE_MS here and described it as covering that gap -- which it does
643
+ // not, since a segment can be 180s at this file's own slowest rate.
644
+ const lastSegmentMs = worstCaseSpeechMs(LATER_CHUNK_CHARS)
645
+ return Math.max(SESSION_IDLE_MS, worstCaseSpeechMs(totalChars) + lastSegmentMs)
646
+ }
647
+
648
+ export function createSession(
649
+ s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>,
650
+ opts: { graceMs?: number } = {},
651
+ ): string {
566
652
  const uuid = randomUUID()
567
653
  const now = Date.now()
654
+ // Number.isFinite, not just ??. Math.max(120000, NaN) is NaN, and NaN fails
655
+ // every comparison in peekSession, so a NaN grace would leave the session
656
+ // governed only by the ceiling. Cheap to close, silent if left open.
657
+ const requested = opts.graceMs
658
+ const grace = Number.isFinite(requested)
659
+ ? Math.max(SESSION_IDLE_MS, requested as number)
660
+ : SESSION_IDLE_MS
568
661
  sessions.set(uuid, {
569
662
  ...s,
570
- expiresAt: now + SESSION_IDLE_MS,
663
+ // Belt and braces, NOT the thing that enforces the ceiling. Mutation shows
664
+ // removing this Math.min changes nothing observable: peekSession and
665
+ // reapExpiredSessions both test hardExpiresAt independently, so a grace
666
+ // beyond the ceiling is already unreachable. Kept because a stored deadline
667
+ // that lies about its own limit invites a future reader to trust it -- but
668
+ // do not mistake it for the guard.
669
+ expiresAt: Math.min(now + grace, now + SESSION_MAX_LIFETIME_MS),
571
670
  hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
572
671
  })
573
672
  return uuid
@@ -594,10 +693,27 @@ export function peekSession(uuid: string): SessionEntry | null {
594
693
  sessions.delete(uuid)
595
694
  return null
596
695
  }
597
- // SLIDING. Every Range refill during playback pushes the idle deadline out, so
598
- // a session lives as long as audio is actively being played and dies a minute
599
- // after it stops -- never past the hard ceiling.
600
- s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
696
+ // SLIDING, and it may only ever EXTEND a deadline -- never shorten one.
697
+ //
698
+ // The Math.max is the whole point, and its absence was a defect that survived
699
+ // into review. `warmNext(i)` reads segment i+1 at the START of segment i, and
700
+ // then nothing touches it again until segment i FINISHES, one full segment
701
+ // later. If that first read collapsed the derived grace to SESSION_IDLE_MS,
702
+ // the warm would SPEND the grace instead of using it, and any segment whose
703
+ // playback exceeds 120s would expire before its turn:
704
+ //
705
+ // 900 chars at 10.6 chars/sec = 85s of audio
706
+ // at 0.5x = 170s of wall time > 120s
707
+ //
708
+ // Verified by replaying the real warm pattern: with a plain assignment the
709
+ // 6,781-char reply lost segment 1 at t+170s while holding a 1,476s grace.
710
+ //
711
+ // Every security property is unchanged. The ceiling still binds independently
712
+ // below. Reading still buys nothing back -- for a session with a long grace,
713
+ // max() returns the grace deadline it already had, so a read cannot extend a
714
+ // capability's life by even a millisecond. Once now + SESSION_IDLE_MS passes
715
+ // the original grace, this becomes an ordinary sliding window again.
716
+ s.expiresAt = Math.min(Math.max(s.expiresAt, now + SESSION_IDLE_MS), s.hardExpiresAt)
601
717
  return s
602
718
  }
603
719
 
@@ -28,6 +28,7 @@ import {
28
28
  completeEntry,
29
29
  abortEntry,
30
30
  createSession,
31
+ initialGraceMs,
31
32
  peekSession,
32
33
  rebindSessionHash,
33
34
  reapExpiredSessions,
@@ -868,12 +869,12 @@ ttsRouter.post('/tts/stream', async (req, res) => {
868
869
  // hash the (text, voice, format) tuple, and return a session URL.
869
870
  // 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
870
871
  // 3. The browser GETs /api/tts/play/:session using the session as a bearer
871
- // capability. Range refills may reuse it during its 60-second lifetime;
872
+ // capability. Range refills may reuse it throughout its lifetime (see SESSION_IDLE_MS / initialGraceMs);
872
873
  // the route serves cached bytes or starts live generation on a cold miss.
873
874
  //
874
875
  // The two-step pattern is required because authentication on the play route
875
876
  // would force XHR (no Range support, no progressive decoding). The session
876
- // UUID IS the auth — cryptographically random, short-lived (60s), and scoped
877
+ // UUID IS the auth — cryptographically random, bounded by SESSION_MAX_LIFETIME_MS, and scoped
877
878
  // to one prepared audio item. It is re-readable only for native Range refills.
878
879
  ttsRouter.post('/tts/prepare', async (req, res) => {
879
880
  try {
@@ -917,6 +918,13 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
917
918
  const preferOpenAI = enginePreference === 'openai'
918
919
  const forceLocal = enginePreference === 'local'
919
920
 
921
+ // Every segment is minted here, but the client only touches segment k when
922
+ // segment k-1 starts playing -- minutes later for a long reply. So each
923
+ // session's first deadline is derived from the WHOLE reply's speaking time,
924
+ // not from a flat idle window that starts before anyone could read it. See
925
+ // initialGraceMs; this is the bug that stopped playback at segment 5 of 9.
926
+ const graceMs = initialGraceMs(capped.length)
927
+
920
928
  const mintAndWarm = (chunk: string) => {
921
929
  const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
922
930
  const uuid = createSession({
@@ -926,7 +934,7 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
926
934
  format: requestedFormat,
927
935
  preferOpenAI,
928
936
  forceLocal,
929
- })
937
+ }, { graceMs })
930
938
  // Detached preparation is deliberately local-only. It must never retain
931
939
  // authority to spend cloud budget after the client cancels or closes.
932
940
  // OpenAI generation (including Kokoro fallback) begins only from the
@@ -1076,9 +1084,14 @@ function serveCachedBody(
1076
1084
  ttsRouter.get('/tts/play/:session', async (req, res) => {
1077
1085
  // peekSession (v5.9.4) — non-destructive lookup so iOS WKWebView can issue
1078
1086
  // its routine HTTP Range requests for audio buffer refill without 404ing
1079
- // halfway through a long playback. Sessions still TTL out at 60s.
1087
+ // halfway through a long playback. Sessions TTL out on the idle window, or on their derived grace if never read.
1080
1088
  const session = peekSession(req.params.session)
1081
1089
  if (!session) {
1090
+ // LOG IT. The server produced this 404 and recorded nothing, so the only
1091
+ // reporter was the client -- whose report is fire-and-forget, 3s-aborted and
1092
+ // error-deduped. Four builds on 2026-08-23 were spent inferring a fact the
1093
+ // server held the whole time.
1094
+ console.warn('[tts/play] 404 session expired or unknown:', req.params.session)
1082
1095
  return res.status(404).json({ error: 'session expired or unknown' })
1083
1096
  }
1084
1097