@gotcos/glasses-server 6.36.23 → 6.36.24
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 +34 -0
- package/package.json +1 -1
- package/server/lib/tts-cache.ts +44 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 6.36.24
|
|
2
|
+
|
|
3
|
+
**Playback stopped after about a minute, whatever the reply length.**
|
|
4
|
+
|
|
5
|
+
The TTS play session held a deadline fixed 60 seconds from creation. iOS WKWebView
|
|
6
|
+
re-requests `audio.src` every few seconds to refill its decode buffer, so once the
|
|
7
|
+
session expired those refills 404'd and the audio simply stopped mid-sentence.
|
|
8
|
+
|
|
9
|
+
Measured on this machine: 250 characters is 14 seconds of speech, 4,000 characters
|
|
10
|
+
is 211. So any reply over roughly 1,100 characters outlived its own session. The
|
|
11
|
+
symptom was "the first ten seconds play and nothing else comes" -- the fast-path
|
|
12
|
+
prefix is 250 characters, which is that 14 seconds exactly.
|
|
13
|
+
|
|
14
|
+
6.36.23 removed a character cap that was also real, but the cap truncated the
|
|
15
|
+
TEXT; this truncated the PLAYBACK. The ceiling was time, not length, which is what
|
|
16
|
+
"caps out at a max duration" meant literally.
|
|
17
|
+
|
|
18
|
+
`SESSION_TTL_MS` becomes `SESSION_IDLE_MS`, refreshed on every read, so a session
|
|
19
|
+
stays alive while audio is actively playing and dies a minute after it stops.
|
|
20
|
+
|
|
21
|
+
Because the session UUID IS the auth for an unauthenticated play route, a purely
|
|
22
|
+
sliding window could be held open indefinitely by polling. `SESSION_MAX_LIFETIME_MS`
|
|
23
|
+
(30 minutes) is an absolute ceiling that reading never extends -- far longer than
|
|
24
|
+
any plausible reply, and still a bounded exposure window for a leaked URL. The
|
|
25
|
+
periodic reaper honours it too.
|
|
26
|
+
|
|
27
|
+
v5.9.4 made these reads non-destructive for exactly this symptom and stopped one
|
|
28
|
+
step short, noting "sessions still expire on the existing 60s TTL, so the practical
|
|
29
|
+
exposure window is unchanged". True, and also what left the ceiling in place.
|
|
30
|
+
|
|
31
|
+
Suite 3013 / 214, tsc 0. Both halves mutation-verified: removing the refresh fails
|
|
32
|
+
the playback tests, and letting a read extend past the ceiling fails the security
|
|
33
|
+
tests.
|
|
34
|
+
|
|
1
35
|
## 6.36.23
|
|
2
36
|
|
|
3
37
|
**Long replies stopped speaking at about three or four pages.**
|
package/package.json
CHANGED
package/server/lib/tts-cache.ts
CHANGED
|
@@ -71,7 +71,10 @@ interface SessionEntry {
|
|
|
71
71
|
preferOpenAI?: boolean
|
|
72
72
|
/** Settings forced Local/Kokoro — do not auto-escape to OpenAI on play miss. */
|
|
73
73
|
forceLocal?: boolean
|
|
74
|
+
/** Idle deadline. Pushed out on every read; never past `hardExpiresAt`. */
|
|
74
75
|
expiresAt: number
|
|
76
|
+
/** Absolute deadline, fixed at creation. Reading never extends it. */
|
|
77
|
+
hardExpiresAt: number
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
interface DiskSidecar {
|
|
@@ -83,7 +86,32 @@ interface DiskSidecar {
|
|
|
83
86
|
|
|
84
87
|
const MAX_ENTRIES = 50
|
|
85
88
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024 // 100 MB — in-memory cap
|
|
86
|
-
|
|
89
|
+
/**
|
|
90
|
+
* IDLE timeout, not a lifetime. Refreshed on every read.
|
|
91
|
+
*
|
|
92
|
+
* It was a fixed 60s from creation, which silently capped PLAYBACK at 60
|
|
93
|
+
* seconds: iOS WKWebView re-requests `audio.src` every few seconds to refill
|
|
94
|
+
* its decode buffer, and once the session expired those refills 404'd and the
|
|
95
|
+
* audio simply stopped. Measured on this machine, 250 characters is 14 seconds
|
|
96
|
+
* of speech and 4,000 characters is 211 -- so any reply over roughly 1,100
|
|
97
|
+
* characters outlived its own session and cut off mid-sentence.
|
|
98
|
+
*
|
|
99
|
+
* v5.9.4 made reads non-destructive for exactly this reason and stopped one
|
|
100
|
+
* step short, noting "sessions still expire on the existing 60s TTL, so the
|
|
101
|
+
* practical exposure window is unchanged". That was true, and it is also what
|
|
102
|
+
* left the ceiling in place.
|
|
103
|
+
*/
|
|
104
|
+
const SESSION_IDLE_MS = 60_000
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Absolute ceiling, never refreshed.
|
|
108
|
+
*
|
|
109
|
+
* The session UUID IS the auth for an unauthenticated play route, so a purely
|
|
110
|
+
* sliding window could be kept alive indefinitely by polling. Thirty minutes is
|
|
111
|
+
* far longer than any plausible single reply (211 seconds for 4,000 characters)
|
|
112
|
+
* and still bounds the exposure of a leaked URL.
|
|
113
|
+
*/
|
|
114
|
+
const SESSION_MAX_LIFETIME_MS = 30 * 60_000
|
|
87
115
|
|
|
88
116
|
/** Disk cache configuration (env-overridable). Defaults sized for "I run this
|
|
89
117
|
* on my laptop and forget about it for months" rather than a service tier.
|
|
@@ -499,9 +527,14 @@ function sweepStaleByAge(): void {
|
|
|
499
527
|
* (hash, text, voice, format) bundle. The play route may reread it for native
|
|
500
528
|
* Range refills during the 60-second TTL; expired sessions are rejected and
|
|
501
529
|
* reaped by the periodic sweeper below. */
|
|
502
|
-
export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
|
|
530
|
+
export function createSession(s: Omit<SessionEntry, 'expiresAt' | 'hardExpiresAt'>): string {
|
|
503
531
|
const uuid = randomUUID()
|
|
504
|
-
|
|
532
|
+
const now = Date.now()
|
|
533
|
+
sessions.set(uuid, {
|
|
534
|
+
...s,
|
|
535
|
+
expiresAt: now + SESSION_IDLE_MS,
|
|
536
|
+
hardExpiresAt: now + SESSION_MAX_LIFETIME_MS,
|
|
537
|
+
})
|
|
505
538
|
return uuid
|
|
506
539
|
}
|
|
507
540
|
|
|
@@ -520,10 +553,16 @@ export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
|
|
|
520
553
|
export function peekSession(uuid: string): SessionEntry | null {
|
|
521
554
|
const s = sessions.get(uuid)
|
|
522
555
|
if (!s) return null
|
|
523
|
-
|
|
556
|
+
const now = Date.now()
|
|
557
|
+
// Absolute ceiling first: a hard expiry must not be extendable by reading.
|
|
558
|
+
if (s.hardExpiresAt <= now || s.expiresAt < now) {
|
|
524
559
|
sessions.delete(uuid)
|
|
525
560
|
return null
|
|
526
561
|
}
|
|
562
|
+
// SLIDING. Every Range refill during playback pushes the idle deadline out, so
|
|
563
|
+
// a session lives as long as audio is actively being played and dies a minute
|
|
564
|
+
// after it stops -- never past the hard ceiling.
|
|
565
|
+
s.expiresAt = Math.min(now + SESSION_IDLE_MS, s.hardExpiresAt)
|
|
527
566
|
return s
|
|
528
567
|
}
|
|
529
568
|
|
|
@@ -552,7 +591,7 @@ export function consumeSession(uuid: string): SessionEntry | null {
|
|
|
552
591
|
export function reapExpiredSessions(): void {
|
|
553
592
|
const now = Date.now()
|
|
554
593
|
for (const [uuid, s] of sessions) {
|
|
555
|
-
if (s.expiresAt < now) sessions.delete(uuid)
|
|
594
|
+
if (s.hardExpiresAt <= now || s.expiresAt < now) sessions.delete(uuid)
|
|
556
595
|
}
|
|
557
596
|
}
|
|
558
597
|
|