@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 +57 -0
- package/package.json +1 -1
- package/server/index.ts +6 -0
- package/server/lib/tts-cache.ts +151 -35
- package/server/routes/tts.ts +17 -4
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
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.
|
package/server/lib/tts-cache.ts
CHANGED
|
@@ -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
|
-
//
|
|
106
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
*
|
|
147
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
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
|
|
package/server/routes/tts.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|