@gotcos/glasses-server 6.36.26 → 6.36.27

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,41 @@
1
+ ## 6.36.27
2
+
3
+ The sidecar renders one request at a time. The server finally acts like it.
4
+
5
+ WHAT BROKE. Chunking (6.36.25) split a reply into 9 segments and /prepare
6
+ pre-warmed all 9 at once. The synthesis timeout was armed when a request was
7
+ ISSUED, so it ran while that request sat in the sidecar's queue. Measured on a
8
+ 6,781-character reply: renders took ~2.6s each, but segment 5 spent 11.5s of its
9
+ 12,000ms budget waiting for a turn. On device it tipped over, the pre-warm
10
+ returned 502, and iOS surfaced it as NotSupportedError. Five of nine segments
11
+ played.
12
+
13
+ The 12,000ms constant was not wrong when it was written -- its own comment says
14
+ it exists to "bound hung sidecar so local_first can fall back before session TTL
15
+ (~60s)", from an era when a reply was ONE render. Chunking changed the input and
16
+ nothing re-derived the limit.
17
+
18
+ - New render gate in tts-local.ts. One render reaches the sidecar at a time, so
19
+ the synthesis timeout now bounds RENDER, which is what it always claimed to
20
+ bound. Queue wait is governed separately.
21
+ - Queue wait has its own DERIVED ceiling: one synthesis budget per render ahead,
22
+ plus one budget of headroom. The headroom is not slack -- without it the first
23
+ waiter's ceiling expires in a dead heat with the holder's own timeout, and a
24
+ request that was about to be served is rejected in the same tick. Found by the
25
+ test, not by reasoning.
26
+ - /play outranks /prepare pre-warm. A user is waiting on the first and nobody is
27
+ waiting on the second; without priority, segment 5's playback request queues
28
+ behind pre-warms for 6, 7 and 8 -- work not needed for minutes. Priority never
29
+ reorders playback against itself.
30
+ - /api/health tts_local now reports renderQueueDepth. This was not observable on
31
+ 2026-08-23 and that cost an evening.
32
+
33
+ Every guard mutation-verified, including one that only counting could catch:
34
+ dropping the waiter's detach left all ten behaviour tests green while leaking an
35
+ abort listener per queued render (45 of them on a 46-segment reply).
36
+
37
+ 217 files, 3042 tests.
38
+
1
39
  ## 6.36.26
2
40
 
3
41
  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.27",
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": {
@@ -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'
@@ -454,6 +454,9 @@ async function generateLocalIntoCache(
454
454
  voice: string,
455
455
  format: string,
456
456
  signal?: AbortSignal,
457
+ // A user is waiting on a /play render; nobody is waiting on a pre-warm.
458
+ // Defaults to background so a new caller cannot accidentally starve playback.
459
+ priority = false,
457
460
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
458
461
  // A memory/latency bound, NOT OpenAI's 4096. Kokoro reads what it is handed;
459
462
  // the old shared cap truncated local speech at ~3-4 pages for no reason that
@@ -471,7 +474,7 @@ async function generateLocalIntoCache(
471
474
  }
472
475
  try {
473
476
  // Local path ignores COS_VOICE_INSTRUCTIONS / per-request instructions.
474
- const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal })
477
+ const bytes = await synthesizeLocalTts({ text: spoken, voice, format, signal, priority })
475
478
  if (!bytes.length) {
476
479
  abortEntry(hash)
477
480
  return { ok: false, status: 502, message: 'local TTS returned empty body' }
@@ -496,10 +499,11 @@ async function generateIntoCache(
496
499
  format: string,
497
500
  instructions: string,
498
501
  signal?: AbortSignal,
502
+ priority = false,
499
503
  ): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
500
504
  if (getCached(hash)) return { ok: true }
501
505
  if (decision.backend === 'local') {
502
- return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal)
506
+ return generateLocalIntoCache(hash, text, decision.backendVoice, format, signal, priority)
503
507
  }
504
508
  return generateOpenAIIntoCache(
505
509
  hash,
@@ -520,6 +524,9 @@ async function generateWithFallback(opts: {
520
524
  enginePreference?: TtsEnginePreference | null
521
525
  signal?: AbortSignal
522
526
  sessionId?: string
527
+ /** True when a user is waiting (a /play cold miss). Background pre-warm
528
+ * leaves it false so playback can take the sidecar ahead of it. */
529
+ priority?: boolean
523
530
  }): Promise<{ ok: true; hash: string } | { ok: false; status: number; message: string }> {
524
531
  const enginePreference = opts.enginePreference ?? null
525
532
  const preferOpenAI = enginePreference === 'openai'
@@ -553,6 +560,7 @@ async function generateWithFallback(opts: {
553
560
  opts.format,
554
561
  opts.instructions,
555
562
  opts.signal,
563
+ opts.priority === true,
556
564
  )
557
565
  if (primary.ok) {
558
566
  if (softEscapedToOpenAI) {
@@ -586,6 +594,7 @@ async function generateWithFallback(opts: {
586
594
  opts.format,
587
595
  opts.instructions,
588
596
  opts.signal,
597
+ opts.priority === true,
589
598
  )
590
599
  if (localResult.ok) {
591
600
  if (opts.sessionId) rebindSessionHash(opts.sessionId, localHash)
@@ -616,6 +625,7 @@ async function generateWithFallback(opts: {
616
625
  opts.format,
617
626
  opts.instructions,
618
627
  opts.signal,
628
+ opts.priority === true,
619
629
  )
620
630
  if (openaiResult.ok) {
621
631
  announceKokoroFallbackToOpenAI(failReason)
@@ -803,6 +813,7 @@ ttsRouter.post('/tts/stream', async (req, res) => {
803
813
  voice: decision.backendVoice,
804
814
  format: requestedFormat,
805
815
  signal: upstreamController.signal,
816
+ priority: true,
806
817
  })
807
818
  if (upstreamController.signal.aborted) return
808
819
  res.writeHead(200, {
@@ -1112,6 +1123,8 @@ ttsRouter.get('/tts/play/:session', async (req, res) => {
1112
1123
  enginePreference,
1113
1124
  signal: upstreamController.signal,
1114
1125
  sessionId: req.params.session,
1126
+ // /play cold miss: the listener is waiting on this render right now.
1127
+ priority: true,
1115
1128
  })
1116
1129
 
1117
1130
  if (!result.ok) {