@goodandready/dsh-tts 0.4.31 → 0.4.33

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,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.33 (2026-10-11)
4
+
5
+ - **SSE Stream Watchdog**: Added idle timeout watchdog (30s) in Web player (`lib/client-src/20-player.js`) to automatically trigger reconnect on stale/frozen EventSource connection (#206).
6
+ - **Engine Manager Cleanup**: Added explicit `removeEventListener('abort', ...)` in `execute` finally block for model downloads in `lib/engines/manager.js` (#206).
7
+ - **Updater Testing**: Added behavioral test capturing `child_process.spawn` arguments during `/api/dsh-tts/update` execution verifying no bypass of minimum release age (#206).
8
+ - **Client Bundle**: Rebuilt `lib/client.js` from canonical client-src fragments with idle watchdog.
9
+
10
+ ## 0.4.32
11
+
12
+ ### Fixed
13
+ - **Transactional Rollback on Credential Failure** (#151):
14
+ - Added transactional rollback to previous config in `/dsh-tts/config` PUT handler if credential persistence throws an error after settings replacement, ensuring consistent state on aborted saves.
15
+ - **Messenger Gateway / Speak Guard Strictness** (#165):
16
+ - Enforced 403 rejection for unclassified / external gateway requests lacking channel metadata when `messengerTtsEnabled` is disabled, while preserving internal web UI and voice duplex traffic.
17
+ - **Strict Multi-Format Model Integrity Validation** (#179):
18
+ - Enhanced `validateFileIntegrity` to reject uniform repeating byte sequences of any value, strictly validate JSON parsing for voice mappings, enforce ONNX protobuf header magic (`0x08`), and validate safetensors header length bounds.
19
+ - **Non-Tautological Test Suite Enforcement** (#181):
20
+ - Replaced tautological and assertion-free test fixtures in deduplication, client prebuffering, and SSE player suites with strict assertions verifying exact execution counts (`synthCalls === 1`), factory invocation, and queue limits.
21
+ - **Immediate Pre-Write SSE Chunk Cap** (#186):
22
+ - Added immediate buffer cap rejection before `res.write` in `safeWrite`, preventing oversized payloads (>512 KB) from entering the socket buffer even on the initial chunk.
23
+ - **Cache L2 Clear Concurrency Guard** (#202):
24
+ - Captured generation token at the immediate start of `get()`, preventing post-clear metadata recreation when disk reads are interleaved with cache clearing.
25
+
3
26
  ## 0.4.31
4
27
 
5
28
  ### Fixed
package/lib/cache.js CHANGED
@@ -137,6 +137,7 @@ export function createSpeechCache({ root, maxBytes }) {
137
137
  },
138
138
 
139
139
  async get(key) {
140
+ const currentGen = generation
140
141
  // 1. L1 RAM Hit
141
142
  if (l1Map.has(key)) {
142
143
  const hit = l1Map.get(key)
@@ -145,15 +146,16 @@ export function createSpeechCache({ root, maxBytes }) {
145
146
  l1Map.set(key, { ...hit, at: now })
146
147
  const d = diskEntries.get(key)
147
148
  if (d) d.at = now
148
- const currentGen = generation
149
- const writePromise = (async () => {
150
- if (currentGen !== generation) return
151
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
152
- if (currentGen !== generation) {
153
- await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
154
- }
155
- })().catch(() => {})
156
- trackWrite(writePromise)
149
+ if (currentGen === generation) {
150
+ const writePromise = (async () => {
151
+ if (currentGen !== generation) return
152
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
153
+ if (currentGen !== generation) {
154
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
155
+ }
156
+ })().catch(() => {})
157
+ trackWrite(writePromise)
158
+ }
157
159
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
158
160
  }
159
161
 
@@ -162,19 +164,20 @@ export function createSpeechCache({ root, maxBytes }) {
162
164
  try {
163
165
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
164
166
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
165
- putL1(key, meta.mime, audio)
166
- const now = Date.now()
167
- const d = diskEntries.get(key)
168
- if (d) d.at = now
169
- const currentGen = generation
170
- const writePromise = (async () => {
171
- if (currentGen !== generation) return
172
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
173
- if (currentGen !== generation) {
174
- await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
175
- }
176
- })().catch(() => {})
177
- trackWrite(writePromise)
167
+ if (currentGen === generation) {
168
+ putL1(key, meta.mime, audio)
169
+ const now = Date.now()
170
+ const d = diskEntries.get(key)
171
+ if (d) d.at = now
172
+ const writePromise = (async () => {
173
+ if (currentGen !== generation) return
174
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
175
+ if (currentGen !== generation) {
176
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
177
+ }
178
+ })().catch(() => {})
179
+ trackWrite(writePromise)
180
+ }
178
181
  return { mime: meta.mime || 'audio/mpeg', audio }
179
182
  } catch (miss) {
180
183
  return null
package/lib/client.js CHANGED
@@ -527,17 +527,36 @@ window.__ModuleLoader__.load({
527
527
  return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
528
528
  }
529
529
 
530
- // Unified SSE Realtime Stream Connection (#158)
530
+ // Unified SSE Realtime Stream Connection (#158, #206)
531
531
  let activeSse = null
532
532
  let activeSseAttempt = 0
533
533
  let activeSseTimer = null
534
+ let activeSseWatchdog = null
534
535
  let sseDisposed = false
536
+ const SSE_IDLE_TIMEOUT_MS = 30000
535
537
 
536
538
  function startSseStream() {
537
539
  if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
538
540
  sseDisposed = false
539
541
 
542
+ const resetWatchdog = () => {
543
+ if (activeSseWatchdog) clearTimeout(activeSseWatchdog)
544
+ if (sseDisposed) return
545
+ activeSseWatchdog = setTimeout(() => {
546
+ if (activeSse && !sseDisposed) {
547
+ try {
548
+ if (typeof activeSse.onerror === 'function') {
549
+ activeSse.onerror(new Event('error'))
550
+ } else {
551
+ activeSse.close()
552
+ }
553
+ } catch (_err) { /* error trigger ignore */ }
554
+ }
555
+ }, SSE_IDLE_TIMEOUT_MS)
556
+ }
557
+
540
558
  const onItem = (e) => {
559
+ resetWatchdog()
541
560
  try {
542
561
  const item = JSON.parse(e.data)
543
562
  if (!item || !item.id) return
@@ -556,6 +575,7 @@ window.__ModuleLoader__.load({
556
575
  }
557
576
 
558
577
  const onChime = (e) => {
578
+ resetWatchdog()
559
579
  try {
560
580
  const item = JSON.parse(e.data)
561
581
  if (!item) return
@@ -567,6 +587,7 @@ window.__ModuleLoader__.load({
567
587
 
568
588
  const connect = () => {
569
589
  if (sseDisposed) return
590
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
570
591
  if (activeSse) {
571
592
  try { activeSse.close() } catch (_err) { /* active sse already closed */ }
572
593
  activeSse = null
@@ -574,10 +595,15 @@ window.__ModuleLoader__.load({
574
595
  try {
575
596
  const es = new EventSource('/dsh-tts/stream')
576
597
  activeSse = es
577
- es.onopen = () => { activeSseAttempt = 0 }
598
+ es.onopen = () => {
599
+ activeSseAttempt = 0
600
+ resetWatchdog()
601
+ }
602
+ es.onmessage = () => { resetWatchdog() }
578
603
  es.addEventListener('utterance', onItem)
579
604
  es.addEventListener('chime', onChime)
580
605
  es.onerror = () => {
606
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
581
607
  if (activeSse === es) {
582
608
  try { es.close() } catch (_err) { /* eventsource already closed */ }
583
609
  activeSse = null
@@ -588,6 +614,7 @@ window.__ModuleLoader__.load({
588
614
  if (activeSseTimer) clearTimeout(activeSseTimer)
589
615
  activeSseTimer = setTimeout(connect, delay)
590
616
  }
617
+ resetWatchdog()
591
618
  } catch { /* EventSource failed */ }
592
619
  }
593
620
 
@@ -595,7 +622,8 @@ window.__ModuleLoader__.load({
595
622
 
596
623
  return () => {
597
624
  sseDisposed = true
598
- if (activeSseTimer) clearTimeout(activeSseTimer)
625
+ if (activeSseTimer) { clearTimeout(activeSseTimer); activeSseTimer = null }
626
+ if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
599
627
  if (activeSse) {
600
628
  try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
601
629
  activeSse = null
@@ -674,7 +702,7 @@ window.__ModuleLoader__.load({
674
702
  clientFetch('/dsh-tts/speak', {
675
703
  method: 'POST',
676
704
  headers: { 'Content-Type': 'application/json' },
677
- body: JSON.stringify({ text }),
705
+ body: JSON.stringify({ text, channel: 'web' }),
678
706
  timeoutMs: 15000,
679
707
  })
680
708
  .then((r) => r.json())
@@ -727,7 +755,7 @@ window.__ModuleLoader__.load({
727
755
  clientFetch('/dsh-tts/speak', {
728
756
  method: 'POST',
729
757
  headers: { 'Content-Type': 'application/json' },
730
- body: JSON.stringify({ text: phrase }),
758
+ body: JSON.stringify({ text: phrase, channel: 'web' }),
731
759
  timeoutMs: 15000,
732
760
  })
733
761
  .then((r) => r.json())
@@ -63,8 +63,8 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
63
63
  return false
64
64
  }
65
65
 
66
- // Reject repetitive dummy bytes (e.g. 2048 bytes of 0x2a)
67
- if (buf.length >= 2048 && (buf[0] === 0x2a || buf[0] === 0x00)) {
66
+ // Reject repetitive dummy bytes (e.g. 2048 bytes of 0x41, 0x2a, etc.)
67
+ if (buf.length >= 2048) {
68
68
  let allSame = true
69
69
  for (let i = 1; i < buf.length; i++) {
70
70
  if (buf[i] !== buf[0]) {
@@ -75,16 +75,24 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
75
75
  if (allSame) return false
76
76
  }
77
77
 
78
- // Validate JSON files
78
+ // Format-specific verification
79
79
  if (filePath.endsWith('.json')) {
80
- const rawStr = buf.toString('utf8').trim()
81
- if (rawStr.startsWith('{') || rawStr.startsWith('[') || rawStr.includes('{')) {
82
- try {
83
- const fullContent = await fs.promises.readFile(filePath, 'utf8')
84
- JSON.parse(fullContent)
85
- } catch {
86
- return false
87
- }
80
+ try {
81
+ const content = await fs.promises.readFile(filePath, 'utf8')
82
+ const parsed = JSON.parse(content)
83
+ if (!parsed || typeof parsed !== 'object') return false
84
+ } catch {
85
+ return false
86
+ }
87
+ } else if (filePath.endsWith('.safetensors')) {
88
+ if (st.size < 16) return false
89
+ const headerLen = buf.readBigUInt64LE(0)
90
+ if (headerLen <= 0n || headerLen > 100n * 1024n * 1024n || BigInt(st.size) < 8n + headerLen) {
91
+ return false
92
+ }
93
+ } else if (filePath.endsWith('.onnx')) {
94
+ if (buf[0] !== 0x08) {
95
+ return false
88
96
  }
89
97
  }
90
98
 
@@ -189,9 +197,13 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
189
197
  const dir = getModelPath(engineId)
190
198
 
191
199
  const controller = new AbortController()
200
+ let onAbortListener = null
192
201
  if (options.signal) {
193
202
  if (options.signal.aborted) controller.abort()
194
- else options.signal.addEventListener('abort', () => controller.abort(), { once: true })
203
+ else {
204
+ onAbortListener = () => controller.abort()
205
+ options.signal.addEventListener('abort', onAbortListener, { once: true })
206
+ }
195
207
  }
196
208
 
197
209
  const downloadState = {
@@ -303,6 +315,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
303
315
  lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
304
316
  throw err
305
317
  } finally {
318
+ if (options.signal && onAbortListener) {
319
+ try { options.signal.removeEventListener('abort', onAbortListener) } catch (_err) { /* listener already removed */ }
320
+ }
306
321
  if (activeDownloads.get(engineId) === downloadState) {
307
322
  activeDownloads.delete(engineId)
308
323
  }
package/lib/routes.js CHANGED
@@ -388,13 +388,23 @@ export function registerHttpRoutes(ctx, api) {
388
388
  if (parsed && typeof parsed === 'object') {
389
389
  delete parsed.revision
390
390
  }
391
+ let replaceSucceeded = false
392
+ const prevConfig = typeof settingsApi.get === 'function' ? settingsApi.get() : null
391
393
  try {
392
394
  await settingsApi.replace(parsed, revision)
395
+ replaceSucceeded = true
393
396
  for (const item of keys) {
394
397
  await storeProviderKey(item.provider, item.value)
395
398
  }
396
399
  writeJson(res, 200, await configResponse())
397
400
  } catch (e) {
401
+ if (replaceSucceeded && prevConfig) {
402
+ try {
403
+ await settingsApi.replace(prevConfig)
404
+ } catch (_rollbackErr) {
405
+ // best-effort rollback on credential storage failure
406
+ }
407
+ }
398
408
  const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
399
409
  /conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
400
410
  )
@@ -486,7 +496,7 @@ export function registerHttpRoutes(ctx, api) {
486
496
  return
487
497
  }
488
498
  const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
489
- const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
499
+ const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger' || (!isVoice && !isWeb)
490
500
  if (isMessenger && !cfg.messengerTtsEnabled) {
491
501
  writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
492
502
  return
package/lib/stream-hub.js CHANGED
@@ -25,7 +25,7 @@ function safeWrite(res, payload) {
25
25
  ? res.writableLength
26
26
  : (res.__dtsBufferedBytes || 0)
27
27
 
28
- if (res.__dtsBackpressured && (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER)) {
28
+ if (payloadBytes > MAX_SUBSCRIBER_BUFFER || (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) || currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER * 2) {
29
29
  subscribers.delete(res)
30
30
  detach(res)
31
31
  try { res.end() } catch (_err) { /* already ended */ }
package/lib/updater.js CHANGED
@@ -125,7 +125,7 @@ function runtime() {
125
125
  : validProfileName(basename(profileDir))
126
126
  ? basename(profileDir)
127
127
  : 'web'
128
- const cliEntry = findDshCliEntry()
128
+ const cliEntry = process.env.DSH_CLI_ENTRY ?? findDshCliEntry()
129
129
  return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
130
130
  }
131
131
 
@@ -170,7 +170,10 @@ export function isNewerVersion(currentValue, candidateValue) {
170
170
  return comparePrerelease(candidate.prerelease, current.prerelease) > 0
171
171
  }
172
172
 
173
- async function latestVersion(packageName, registry) {
173
+ async function latestVersion(packageName, registry, options) {
174
+ if (typeof options?.latestVersionImpl === 'function') {
175
+ return await options.latestVersionImpl(packageName, registry)
176
+ }
174
177
  if (
175
178
  latestCache?.packageName === packageName &&
176
179
  latestCache.registry === registry &&
@@ -202,7 +205,7 @@ async function currentVersion(manifestUrl) {
202
205
 
203
206
  export async function status(options, target) {
204
207
  const current = await currentVersion(options.manifestUrl)
205
- const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
208
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org', options)
206
209
  return {
207
210
  packageName: options.packageName,
208
211
  currentVersion: current,
@@ -217,7 +220,25 @@ export async function status(options, target) {
217
220
  async function installExact(target, packageSpec, options) {
218
221
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
219
222
  await new Promise((resolvePromise, reject) => {
220
- const child = spawn(
223
+ const spawnCall = options?.spawnImpl || spawn
224
+ const child = spawnCall === spawn ? spawn(
225
+ process.execPath,
226
+ [
227
+ target.cliEntry,
228
+ 'plugin',
229
+ '--profile',
230
+ target.profileName,
231
+ 'add',
232
+ packageSpec,
233
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
234
+ ],
235
+ {
236
+ cwd: target.profileDir,
237
+ windowsHide: true,
238
+ stdio: ['ignore', 'pipe', 'pipe'],
239
+ env: { ...process.env, NO_COLOR: '1' },
240
+ }
241
+ ) : spawnCall(
221
242
  process.execPath,
222
243
  [
223
244
  target.cliEntry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",