@gotcos/glasses-server 6.36.26 → 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,98 @@
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
+
58
+ ## 6.36.27
59
+
60
+ The sidecar renders one request at a time. The server finally acts like it.
61
+
62
+ WHAT BROKE. Chunking (6.36.25) split a reply into 9 segments and /prepare
63
+ pre-warmed all 9 at once. The synthesis timeout was armed when a request was
64
+ ISSUED, so it ran while that request sat in the sidecar's queue. Measured on a
65
+ 6,781-character reply: renders took ~2.6s each, but segment 5 spent 11.5s of its
66
+ 12,000ms budget waiting for a turn. On device it tipped over, the pre-warm
67
+ returned 502, and iOS surfaced it as NotSupportedError. Five of nine segments
68
+ played.
69
+
70
+ The 12,000ms constant was not wrong when it was written -- its own comment says
71
+ it exists to "bound hung sidecar so local_first can fall back before session TTL
72
+ (~60s)", from an era when a reply was ONE render. Chunking changed the input and
73
+ nothing re-derived the limit.
74
+
75
+ - New render gate in tts-local.ts. One render reaches the sidecar at a time, so
76
+ the synthesis timeout now bounds RENDER, which is what it always claimed to
77
+ bound. Queue wait is governed separately.
78
+ - Queue wait has its own DERIVED ceiling: one synthesis budget per render ahead,
79
+ plus one budget of headroom. The headroom is not slack -- without it the first
80
+ waiter's ceiling expires in a dead heat with the holder's own timeout, and a
81
+ request that was about to be served is rejected in the same tick. Found by the
82
+ test, not by reasoning.
83
+ - /play outranks /prepare pre-warm. A user is waiting on the first and nobody is
84
+ waiting on the second; without priority, segment 5's playback request queues
85
+ behind pre-warms for 6, 7 and 8 -- work not needed for minutes. Priority never
86
+ reorders playback against itself.
87
+ - /api/health tts_local now reports renderQueueDepth. This was not observable on
88
+ 2026-08-23 and that cost an evening.
89
+
90
+ Every guard mutation-verified, including one that only counting could catch:
91
+ dropping the waiter's detach left all ten behaviour tests green while leaking an
92
+ abort listener per queued render (45 of them on a 46-segment reply).
93
+
94
+ 217 files, 3042 tests.
95
+
1
96
  ## 6.36.26
2
97
 
3
98
  Deadline hardening for the segmented TTS path shipped in 6.36.25. Three constants
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.36.26",
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
 
@@ -269,6 +269,11 @@ export function getLocalTtsHealth(): {
269
269
  starting: boolean
270
270
  error: string | null
271
271
  lastFallbackToOpenAI: { at: string; reason: string } | null
272
+ /** Renders in flight plus queued. A reply is chunked into up to MAX_CHUNKS
273
+ * segments that all pre-warm at once, and the sidecar renders one at a time,
274
+ * so this is the number that explains a slow or failing segment. It was not
275
+ * observable on 2026-08-23 and that cost an evening. */
276
+ renderQueueDepth: number
272
277
  } {
273
278
  return {
274
279
  ready: serverAvailable,
@@ -282,6 +287,7 @@ export function getLocalTtsHealth(): {
282
287
  starting: serverStarting,
283
288
  error: lastError,
284
289
  lastFallbackToOpenAI,
290
+ renderQueueDepth: localRenderQueueDepth(),
285
291
  }
286
292
  }
287
293
 
@@ -390,15 +396,170 @@ export function stopLocalTtsServer(): void {
390
396
  }
391
397
 
392
398
  /** Synthesize via local OpenAI-shaped speech endpoint. Returns full audio Buffer. */
399
+
400
+ // ── render gate ──────────────────────────────────────────────────────────────
401
+ //
402
+ // The Kokoro sidecar renders ONE request at a time behind its own lock. Issuing
403
+ // N requests concurrently therefore does not make them finish any sooner; it
404
+ // only makes each one's timeout run while it waits its turn.
405
+ //
406
+ // That is what broke playback on 2026-08-23. Chunking turned a reply into 9
407
+ // renders, all fired at once by /prepare's pre-warm. Measured on a 6,781-char
408
+ // reply: each render took ~2.6s, but segment 5's request spent 11.5s of its
409
+ // 12,000ms budget QUEUED. On a slightly busier machine it tipped over, returned
410
+ // 502, and iOS surfaced it as NotSupportedError. Five of nine segments played.
411
+ //
412
+ // The gate makes that queue explicit on our side, which buys two things:
413
+ //
414
+ // 1. The synthesis timeout starts when a request ACQUIRES the gate, so it
415
+ // bounds RENDER time -- what it was always described as bounding. Waiting
416
+ // for a turn is governed separately, by a derived ceiling (below).
417
+ // 2. Playback jumps ahead of pre-warm. A /play request has a user waiting on
418
+ // it; a /prepare pre-warm does not. Without priority, segment 5's playback
419
+ // request queues behind the pre-warms for 6, 7 and 8 -- work that is not
420
+ // needed until minutes later.
421
+ //
422
+ // Priority never reorders playback against itself: a priority waiter is placed
423
+ // after other priority waiters and before every background one.
424
+
425
+ type RenderWaiter = {
426
+ priority: boolean
427
+ resolve: () => void
428
+ reject: (err: Error) => void
429
+ signal?: AbortSignal
430
+ detach?: () => void
431
+ }
432
+
433
+ let gateBusy = false
434
+ const gateWaiters: RenderWaiter[] = []
435
+
436
+ /** In-flight plus queued renders. Exposed for /api/health and tests. */
437
+ export function localRenderQueueDepth(): number {
438
+ return gateWaiters.length + (gateBusy ? 1 : 0)
439
+ }
440
+
441
+ /** Test-only: drop all queued waiters and free the gate. */
442
+ export function __resetRenderGate(): void {
443
+ for (const w of gateWaiters.splice(0)) {
444
+ w.detach?.()
445
+ w.reject(makeAbortError('render gate reset'))
446
+ }
447
+ gateBusy = false
448
+ }
449
+
450
+ function makeAbortError(message: string): Error {
451
+ const err = new Error(message)
452
+ err.name = 'AbortError'
453
+ return err
454
+ }
455
+
456
+ function releaseRenderGate(): void {
457
+ const next = gateWaiters.shift()
458
+ if (!next) {
459
+ gateBusy = false
460
+ return
461
+ }
462
+ // The gate stays held; ownership passes straight to `next`. Setting
463
+ // gateBusy = false here would let a later arrival barge in ahead of a
464
+ // waiter that has already been promised the slot.
465
+ next.detach?.()
466
+ next.resolve()
467
+ }
468
+
469
+ function acquireRenderGate(priority: boolean, signal?: AbortSignal): Promise<void> {
470
+ if (signal?.aborted) return Promise.reject(makeAbortError('local TTS request aborted'))
471
+ if (!gateBusy) {
472
+ gateBusy = true
473
+ return Promise.resolve()
474
+ }
475
+ return new Promise<void>((resolve, reject) => {
476
+ const waiter: RenderWaiter = { priority, resolve, reject, signal }
477
+
478
+ // DERIVED ceiling on queue wait, so a wedged sidecar cannot hang callers
479
+ // forever. Everyone ahead of us is individually bounded by the synthesis
480
+ // timeout, so the longest legitimate wait is that timeout once per render
481
+ // ahead of us -- the one in flight plus everyone already queued.
482
+ //
483
+ // The +1 is not slack, it is a race fix found by the test below. Without it
484
+ // the first waiter's ceiling is exactly one render budget, which expires in
485
+ // a dead heat with the holder's OWN timeout: the holder times out, releases
486
+ // the gate, and the waiter that was about to be served is rejected in the
487
+ // same tick. One extra budget of headroom means the queue always outlives
488
+ // the thing it is waiting for.
489
+ const ahead = gateWaiters.length + 1
490
+ const waitCeilingMs = (ahead + 1) * effectiveSynthTimeoutMs()
491
+ const timer = setTimeout(() => {
492
+ const at = gateWaiters.indexOf(waiter)
493
+ if (at >= 0) gateWaiters.splice(at, 1)
494
+ waiter.detach?.()
495
+ const err = new Error(
496
+ `local TTS queue wait exceeded ${waitCeilingMs}ms behind ${ahead} render(s)`,
497
+ )
498
+ err.name = 'TimeoutError'
499
+ reject(err)
500
+ }, waitCeilingMs)
501
+ if (typeof timer === 'object' && timer && 'unref' in timer) {
502
+ ;(timer as { unref: () => void }).unref()
503
+ }
504
+
505
+ const onAbort = () => {
506
+ const at = gateWaiters.indexOf(waiter)
507
+ if (at >= 0) gateWaiters.splice(at, 1)
508
+ waiter.detach?.()
509
+ reject(makeAbortError('local TTS request aborted'))
510
+ }
511
+ waiter.detach = () => {
512
+ clearTimeout(timer)
513
+ signal?.removeEventListener('abort', onAbort)
514
+ }
515
+ signal?.addEventListener('abort', onAbort, { once: true })
516
+
517
+ if (priority) {
518
+ const firstBackground = gateWaiters.findIndex((w) => !w.priority)
519
+ if (firstBackground < 0) gateWaiters.push(waiter)
520
+ else gateWaiters.splice(firstBackground, 0, waiter)
521
+ } else {
522
+ gateWaiters.push(waiter)
523
+ }
524
+ })
525
+ }
526
+
527
+ function effectiveSynthTimeoutMs(): number {
528
+ return Number.isFinite(LOCAL_TTS_SYNTH_TIMEOUT_MS) && LOCAL_TTS_SYNTH_TIMEOUT_MS > 0
529
+ ? LOCAL_TTS_SYNTH_TIMEOUT_MS
530
+ : 12_000
531
+ }
532
+
393
533
  export async function synthesizeLocalTts(opts: {
394
534
  text: string
395
535
  voice: string
396
536
  format: string
397
537
  signal?: AbortSignal
538
+ /** True for a request a user is waiting on (/play). False/undefined for
539
+ * background pre-warm, which yields the sidecar to playback. */
540
+ priority?: boolean
398
541
  }): Promise<Buffer> {
399
- const timeoutMs = Number.isFinite(LOCAL_TTS_SYNTH_TIMEOUT_MS) && LOCAL_TTS_SYNTH_TIMEOUT_MS > 0
400
- ? LOCAL_TTS_SYNTH_TIMEOUT_MS
401
- : 12_000
542
+ // Wait for the sidecar BEFORE starting the synthesis clock. The timeout
543
+ // below is a render budget; it was previously charged for queue time too,
544
+ // which is the whole reason segment 5 of 9 died at 12s while rendering in
545
+ // 2.6s. See the render gate above.
546
+ await acquireRenderGate(opts.priority === true, opts.signal)
547
+ try {
548
+ return await synthesizeLocalTtsRender(opts)
549
+ } finally {
550
+ releaseRenderGate()
551
+ }
552
+ }
553
+
554
+ /** The actual sidecar call. Never invoke directly -- it assumes the caller
555
+ * holds the render gate, and the timeout it starts is a RENDER budget. */
556
+ async function synthesizeLocalTtsRender(opts: {
557
+ text: string
558
+ voice: string
559
+ format: string
560
+ signal?: AbortSignal
561
+ }): Promise<Buffer> {
562
+ const timeoutMs = effectiveSynthTimeoutMs()
402
563
  const timeoutSignal = AbortSignal.timeout(timeoutMs)
403
564
  const signal =
404
565
  opts.signal && typeof AbortSignal.any === 'function'
@@ -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,
@@ -454,6 +455,9 @@ async function generateLocalIntoCache(
454
455
  voice: string,
455
456
  format: string,
456
457
  signal?: AbortSignal,
458
+ // A user is waiting on a /play render; nobody is waiting on a pre-warm.
459
+ // Defaults to background so a new caller cannot accidentally starve playback.
460
+ priority = false,
457
461
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
458
462
  // A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
459
463
  // the old shared cap truncated local speech at ~3-4 pages for no reason that
@@ -471,7 +475,7 @@ async function generateLocalIntoCache(
471
475
  }
472
476
  try {
473
477
  // Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
474
- const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
478
+ const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal, priority })
475
479
  if (!bytes.length) {
476
480
  abortEntry(hash)
477
481
  return { ok: false, status: 502, message: 'local TTS returned empty body' }
@@ -496,10 +500,11 @@ async function generateIntoCache(
496
500
  format: string,
497
501
  instructions: string,
498
502
  signal?: AbortSignal,
503
+ priority = false,
499
504
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
500
505
  if (getCached(hash)) return { ok: true }
501
506
  if (decision.backend === 'local') {
502
- return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
507
+ return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal, priority)
503
508
  }
504
509
  return generateOpenAIIntoCache(
505
510
  hash,
@@ -520,6 +525,9 @@ async function generateWithFallback(opts: {
520
525
  enginePreference?: TtsEnginePreference | null
521
526
  signal?: AbortSignal
522
527
  sessionId?: string
528
+ /** True when a user is waiting (a /play cold miss). Background pre-warm
529
+ * leaves it false so playback can take the sidecar ahead of it. */
530
+ priority?: boolean
523
531
  }): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
524
532
  const enginePreference = opts.enginePreference ?? null
525
533
  const preferOpenAI = enginePreference === 'openai'
@@ -553,6 +561,7 @@ async function generateWithFallback(opts: {
553
561
  opts.format,
554
562
  opts.instructions,
555
563
  opts.signal,
564
+ opts.priority === true,
556
565
  )
557
566
  if (primary.ok) {
558
567
  if (softEscapedToOpenAI) {
@@ -586,6 +595,7 @@ async function generateWithFallback(opts: {
586
595
  opts.format,
587
596
  opts.instructions,
588
597
  opts.signal,
598
+ opts.priority === true,
589
599
  )
590
600
  if (localResult.ok) {
591
601
  if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
@@ -616,6 +626,7 @@ async function generateWithFallback(opts: {
616
626
  opts.format,
617
627
  opts.instructions,
618
628
  opts.signal,
629
+ opts.priority === true,
619
630
  )
620
631
  if (openaiResult.ok) {
621
632
  announceKokoroFallbackToOpenAI(failReason)
@@ -803,6 +814,7 @@ ttsRouter.post('/tts/stream', async (req, res) => {
803
814
  voice: decision.backendVoice,
804
815
  format: requestedFormat,
805
816
  signal: upstreamController.signal,
817
+ priority: true,
806
818
  })
807
819
  if (upstreamController.signal.aborted) return
808
820
  res.writeHead(200, {
@@ -857,12 +869,12 @@ ttsRouter.post('/tts/stream', async (req, res) => {
857
869
  // hash the (text, voice, format) tuple, and return a session URL.
858
870
  // 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
859
871
  // 3. The browser GETs /api/tts/play/:session using the session as a bearer
860
- // 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);
861
873
  // the route serves cached bytes or starts live generation on a cold miss.
862
874
  //
863
875
  // The two-step pattern is required because authentication on the play route
864
876
  // would force XHR (no Range support, no progressive decoding). The session
865
- // 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
866
878
  // to one prepared audio item. It is re-readable only for native Range refills.
867
879
  ttsRouter.post('/tts/prepare', async (req, res) => {
868
880
  try {
@@ -906,6 +918,13 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
906
918
  const preferOpenAI = enginePreference === 'openai'
907
919
  const forceLocal = enginePreference === 'local'
908
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
+
909
928
  const mintAndWarm = (chunk: string) => {
910
929
  const hash = hashForDecision(decision, requestedFormat, chunk, requestedInstructions)
911
930
  const uuid = createSession({
@@ -915,7 +934,7 @@ ttsRouter.post('/tts/prepare', async (req, res) => {
915
934
  format: requestedFormat,
916
935
  preferOpenAI,
917
936
  forceLocal,
918
- })
937
+ }, { graceMs })
919
938
  // Detached preparation is deliberately local-only. It must never retain
920
939
  // authority to spend cloud budget after the client cancels or closes.
921
940
  // OpenAI generation (including Kokoro fallback) begins only from the
@@ -1065,9 +1084,14 @@ function serveCachedBody(
1065
1084
  ttsRouter.get('/tts/play/:session', async (req, res) => {
1066
1085
  // peekSession (v5.9.4) — non-destructive lookup so iOS WKWebView can issue
1067
1086
  // its routine HTTP Range requests for audio buffer refill without 404ing
1068
- // 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.
1069
1088
  const session = peekSession(req.params.session)
1070
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)
1071
1095
  return res.status(404).json({ error: 'session expired or unknown' })
1072
1096
  }
1073
1097
 
@@ -1112,6 +1136,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
1112
1136
  enginePreference,
1113
1137
  signal: upstreamController.signal,
1114
1138
  sessionId: req.params.session,
1139
+ // /play cold miss: the listener is waiting on this render right now.
1140
+ priority: true,
1115
1141
  })
1116
1142
 
1117
1143
  if (!result.ok) {