@gotcos/glasses-server 6.21.19 → 6.21.21
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 +45 -0
- package/package.json +1 -1
- package/server/lib/send-audio.ts +42 -0
- package/server/lib/whisper-local.ts +105 -4
- package/server/routes/meeting.ts +2 -2
- package/server/routes/voice.ts +3 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
## 6.21.21
|
|
2
|
+
|
|
3
|
+
- One transient process probe no longer disables local Whisper for the whole
|
|
4
|
+
server lifetime. On 2026-08-06 the `/bin/ps` probe inside the startup
|
|
5
|
+
preflight failed exactly once, during a generation changeover while the
|
|
6
|
+
previous generation's Python child was shutting down. Startup caught it, set
|
|
7
|
+
state `failed`, and stopped — whisper never launched, port 8178 refused for
|
|
8
|
+
~30 minutes across a live recording, and only a manual restart recovered it.
|
|
9
|
+
Boot calls `startWhisperServer()` once, and the only other recovery path is a
|
|
10
|
+
circuit breaker that first needs three failed transcriptions, so nothing ever
|
|
11
|
+
retried the thing that had actually failed.
|
|
12
|
+
|
|
13
|
+
Both preflight probes now retry three times with backoff. `lsof` exit 1 means
|
|
14
|
+
"no matches" and is still passed straight through, so the common path costs
|
|
15
|
+
exactly one probe. Preflight still fails CLOSED after the retries: it exists
|
|
16
|
+
to prove no orphaned whisper owns port 8178, and spawning without that proof
|
|
17
|
+
risks two owners.
|
|
18
|
+
|
|
19
|
+
- A surviving probe failure now names its own cause. The field failure logged
|
|
20
|
+
only `Command failed: /bin/ps …` with empty stderr — identical output for a
|
|
21
|
+
timeout kill, a non-zero exit, and a failed fork — which cost an
|
|
22
|
+
investigation and still did not settle the mechanism (measured, this probe
|
|
23
|
+
runs in ~45ms against a 2s timeout, so "it timed out" was never established).
|
|
24
|
+
Errors now carry per-attempt elapsed time, `killed`, `signal`, `code`, and
|
|
25
|
+
stderr, deduplicated so all three attempts survive the 240-character bound.
|
|
26
|
+
|
|
27
|
+
## 6.21.20
|
|
28
|
+
|
|
29
|
+
- Audio playback works at all. Every play button in the Control speaker review
|
|
30
|
+
returned a 404 and made no sound, on every default install, since playback
|
|
31
|
+
shipped. `res.sendFile` delegates to `send`, which defaults to
|
|
32
|
+
`dotfiles: 'ignore'` and — with no `root` set — applies that policy to the
|
|
33
|
+
WHOLE absolute path rather than to anything the request supplied. The default
|
|
34
|
+
data home is `~/.cos-glasses/data`, and `.cos-glasses` is a dot component, so
|
|
35
|
+
the file was found, confirmed to exist, then refused on the way out the door.
|
|
36
|
+
All three audio routes were affected: meeting chunks, speaker-profile samples,
|
|
37
|
+
and ext-audio samples.
|
|
38
|
+
|
|
39
|
+
The tests could not have caught this. They point `COS_DATA_DIR` at
|
|
40
|
+
`mktemp -d` — `/var/folders/...` — which cannot contain a dot component, so
|
|
41
|
+
the suite was structurally incapable of reproducing a default install and
|
|
42
|
+
stayed green while the feature was dead. The three routes now share
|
|
43
|
+
`sendAudioFile`, and `send-audio.test.ts` serves from a dot-directory on
|
|
44
|
+
purpose, over a real listener, asserting on the returned bytes.
|
|
45
|
+
|
|
1
46
|
## 6.21.19
|
|
2
47
|
|
|
3
48
|
- Review playback falls back to ext-audio. The 7-day archive introduced in
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Serving a retained WAV back to a reviewer.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS AT ALL, because `res.sendFile(path)` looks like it would do.
|
|
4
|
+
//
|
|
5
|
+
// `send` (what Express delegates to) defaults to `dotfiles: 'ignore'`, and when
|
|
6
|
+
// no `root` option is given it applies that policy to the ENTIRE absolute path,
|
|
7
|
+
// not to the part a request supplied:
|
|
8
|
+
//
|
|
9
|
+
// parts = normalize(path).split(sep) // send/index.js — the whole path
|
|
10
|
+
// if (containsDotFile(parts)) ... error(404)
|
|
11
|
+
//
|
|
12
|
+
// The COS data home is `~/.cos-glasses/data` by default. `.cos-glasses` is a
|
|
13
|
+
// dot component, so every audio route 404'd on every default install — the play
|
|
14
|
+
// button rendered, the fetch failed, and nothing was heard. Verified directly
|
|
15
|
+
// against the installed module: `{}` -> 404, `{dotfiles:'allow'}` -> resolves.
|
|
16
|
+
//
|
|
17
|
+
// The tests never caught it because they point `COS_DATA_DIR` at
|
|
18
|
+
// `mktemp -d` (`/var/folders/...`), which structurally CANNOT contain a dot
|
|
19
|
+
// component. The suite was green and the feature was dead. `send-audio.test.ts`
|
|
20
|
+
// serves from a dot-directory on purpose.
|
|
21
|
+
//
|
|
22
|
+
// 'allow' is safe here rather than merely convenient: the dot component is ours
|
|
23
|
+
// (the data home), and nothing a caller supplies can introduce one. Every route
|
|
24
|
+
// resolves its path through a validator first — `sessionId` and speaker names
|
|
25
|
+
// match `[A-Za-z0-9:_-]`, chunk indices are integers, and the archive helpers
|
|
26
|
+
// re-check containment before returning. The path handed to this function is
|
|
27
|
+
// already known to exist and to live under the data dir.
|
|
28
|
+
|
|
29
|
+
import type { Response } from 'express'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a WAV that has already been resolved and containment-checked.
|
|
33
|
+
*
|
|
34
|
+
* Callers must have verified the file exists; a missing file here surfaces as
|
|
35
|
+
* send's own 404 HTML rather than the route's JSON, which is exactly the
|
|
36
|
+
* confusing failure this module documents.
|
|
37
|
+
*/
|
|
38
|
+
export function sendAudioFile(res: Response, path: string): void {
|
|
39
|
+
res.type('audio/wav')
|
|
40
|
+
// See the file header: without this, any data home under a dot-directory 404s.
|
|
41
|
+
res.sendFile(path, { dotfiles: 'allow' })
|
|
42
|
+
}
|
|
@@ -283,6 +283,23 @@ const WHISPER_SERVER_PORT = 8178
|
|
|
283
283
|
const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
|
|
284
284
|
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|
|
285
285
|
const PROCESS_PROBE_MAX_BUFFER = 1024 * 1024
|
|
286
|
+
// A process probe failing ONCE must not disable local Whisper for the whole
|
|
287
|
+
// server lifetime. On 2026-08-06 the `/bin/ps` probe in the startup preflight
|
|
288
|
+
// failed exactly once, during the 6.21.20 generation changeover while the
|
|
289
|
+
// previous generation's Python child was still shutting down.
|
|
290
|
+
// `startWhisperServer` caught it, set state 'failed', and never tried again:
|
|
291
|
+
// whisper-server never launched, port 8178 refused for ~30 minutes across a
|
|
292
|
+
// live recording, and only a manual restart recovered it. Boot calls
|
|
293
|
+
// `startWhisperServer()` exactly once, and the only other recovery path is a
|
|
294
|
+
// circuit breaker that first needs three failed transcriptions — so nothing
|
|
295
|
+
// retried the thing that had actually failed.
|
|
296
|
+
//
|
|
297
|
+
// Three attempts at 150/300ms covers a transient blip while still failing in
|
|
298
|
+
// well under a second when the probe is genuinely broken. Preflight must keep
|
|
299
|
+
// failing CLOSED after that: it exists to prove no orphaned whisper owns port
|
|
300
|
+
// 8178, and spawning without that proof risks two owners.
|
|
301
|
+
const PROCESS_PROBE_ATTEMPTS = 3
|
|
302
|
+
const PROCESS_PROBE_RETRY_BASE_MS = 150
|
|
286
303
|
const PS_BIN = '/bin/ps'
|
|
287
304
|
const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
|
|
288
305
|
|
|
@@ -338,14 +355,90 @@ function runProcessProbe(file: string, args: string[]): Promise<string> {
|
|
|
338
355
|
})
|
|
339
356
|
}
|
|
340
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Describe a probe failure precisely enough that the NEXT one diagnoses itself.
|
|
360
|
+
*
|
|
361
|
+
* The 2026-08-06 failure logged only `Command failed: /bin/ps ...` with empty
|
|
362
|
+
* stderr, which is what execFile emits for a non-zero exit, a timeout kill, and
|
|
363
|
+
* a fork failure alike. That single ambiguous string cost an investigation and
|
|
364
|
+
* still did not settle the mechanism: measured, this probe runs in ~45ms against
|
|
365
|
+
* a 2s timeout, so "it timed out" was never established. Record the fields that
|
|
366
|
+
* discriminate — signal and `killed` mean the timeout fired, a numeric `code`
|
|
367
|
+
* means ps itself exited non-zero, `EAGAIN`/`ENOMEM` means the fork failed —
|
|
368
|
+
* plus how long it actually took.
|
|
369
|
+
*/
|
|
370
|
+
function describeProbeFailure(error: any, elapsedMs: number, attempt: number): string {
|
|
371
|
+
const parts = [`attempt ${attempt}`, `${elapsedMs}ms`]
|
|
372
|
+
if (error?.killed) parts.push('killed=true')
|
|
373
|
+
if (error?.signal) parts.push(`signal=${error.signal}`)
|
|
374
|
+
if (error?.code !== undefined && error?.code !== null) parts.push(`code=${error.code}`)
|
|
375
|
+
const stderr = String(error?.stderr ?? '').trim()
|
|
376
|
+
if (stderr) parts.push(`stderr=${stderr.slice(0, 80)}`)
|
|
377
|
+
return parts.join(' ')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Fold per-attempt details into one line that survives `boundedError`'s 240
|
|
382
|
+
* characters.
|
|
383
|
+
*
|
|
384
|
+
* Repeating an identical 48-character "Command failed: /bin/ps …" once per
|
|
385
|
+
* attempt spent most of the budget and truncated attempt 3 — the most recent
|
|
386
|
+
* and most diagnostic one. A test caught that. So the message is emitted ONCE
|
|
387
|
+
* when every attempt failed the same way, and per-attempt only when they
|
|
388
|
+
* genuinely differ, which is itself a signal worth seeing.
|
|
389
|
+
*/
|
|
390
|
+
function summarizeProbeFailures(attempts: Array<{ message: string; detail: string }>): string {
|
|
391
|
+
const distinct = [...new Set(attempts.map(a => a.message))]
|
|
392
|
+
if (distinct.length === 1) {
|
|
393
|
+
return `${distinct[0]} [${attempts.map(a => a.detail).join('; ')}]`
|
|
394
|
+
}
|
|
395
|
+
return attempts.map(a => `${a.message} (${a.detail})`).join(' | ')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Run a process probe, retrying a transient failure.
|
|
400
|
+
*
|
|
401
|
+
* `isExpectedFailure` short-circuits retries for a failure that is a normal
|
|
402
|
+
* result rather than a fault — `lsof` exits 1 to mean "no matches", and
|
|
403
|
+
* retrying that would triple the cost of the common case.
|
|
404
|
+
*/
|
|
405
|
+
async function runProcessProbeRetrying(
|
|
406
|
+
bin: string,
|
|
407
|
+
args: string[],
|
|
408
|
+
isExpectedFailure: (error: any) => boolean = () => false,
|
|
409
|
+
): Promise<string> {
|
|
410
|
+
const failures: Array<{ message: string; detail: string }> = []
|
|
411
|
+
for (let attempt = 1; attempt <= PROCESS_PROBE_ATTEMPTS; attempt++) {
|
|
412
|
+
const startedAt = Date.now()
|
|
413
|
+
try {
|
|
414
|
+
return await runProcessProbe(bin, args)
|
|
415
|
+
} catch (error: any) {
|
|
416
|
+
if (isExpectedFailure(error)) throw error
|
|
417
|
+
const detail = describeProbeFailure(error, Date.now() - startedAt, attempt)
|
|
418
|
+
failures.push({ message: boundedError(error), detail })
|
|
419
|
+
if (attempt < PROCESS_PROBE_ATTEMPTS) {
|
|
420
|
+
console.warn(
|
|
421
|
+
`[whisper-local] ${bin} probe failed, retrying (${attempt}/${PROCESS_PROBE_ATTEMPTS}): ${detail}`,
|
|
422
|
+
)
|
|
423
|
+
await sleep(PROCESS_PROBE_RETRY_BASE_MS * attempt)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Every attempt is reported, not just the last: a probe that fails three
|
|
428
|
+
// different ways is a different problem from one that fails identically.
|
|
429
|
+
throw new Error(summarizeProbeFailures(failures))
|
|
430
|
+
}
|
|
431
|
+
|
|
341
432
|
async function listProcesses(): Promise<ProcessEntry[]> {
|
|
342
433
|
// `command=` includes arguments, which lets us distinguish this COS-owned
|
|
343
434
|
// port/model signature from unrelated whisper-server instances.
|
|
344
435
|
let output: string
|
|
345
436
|
try {
|
|
346
|
-
output = await
|
|
437
|
+
output = await runProcessProbeRetrying(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
|
|
347
438
|
} catch (error) {
|
|
348
|
-
throw new Error(
|
|
439
|
+
throw new Error(
|
|
440
|
+
`unable to inspect process table after ${PROCESS_PROBE_ATTEMPTS} attempts: ${boundedError(error)}`,
|
|
441
|
+
)
|
|
349
442
|
}
|
|
350
443
|
return output.split('\n').flatMap(line => {
|
|
351
444
|
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
|
|
@@ -354,17 +447,25 @@ async function listProcesses(): Promise<ProcessEntry[]> {
|
|
|
354
447
|
})
|
|
355
448
|
}
|
|
356
449
|
|
|
450
|
+
/** lsof exits 1 for "no matches" — a normal answer, not a fault. */
|
|
451
|
+
const isLsofNoMatches = (err: any): boolean => err?.code === 1 || err?.status === 1
|
|
452
|
+
|
|
357
453
|
async function listeningPids(): Promise<number[]> {
|
|
358
454
|
try {
|
|
359
|
-
|
|
455
|
+
// Retries for the same reason as the process-table probe: this also fails
|
|
456
|
+
// preflight CLOSED, so one transient blip here would equally disable
|
|
457
|
+
// whisper for the server's whole lifetime. "No matches" is passed through
|
|
458
|
+
// untouched so the common case still costs exactly one probe.
|
|
459
|
+
const output = await runProcessProbeRetrying(
|
|
360
460
|
LSOF_BIN,
|
|
361
461
|
['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
|
|
462
|
+
isLsofNoMatches,
|
|
362
463
|
)
|
|
363
464
|
return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
364
465
|
} catch (err: any) {
|
|
365
466
|
// lsof uses exit 1 for "no matches". Anything else means we could not
|
|
366
467
|
// prove the port state, so startup must fail closed.
|
|
367
|
-
if (err
|
|
468
|
+
if (isLsofNoMatches(err)) return []
|
|
368
469
|
throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${boundedError(err)}`)
|
|
369
470
|
}
|
|
370
471
|
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
|
10
10
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
11
11
|
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
|
+
import { sendAudioFile } from '../lib/send-audio.js'
|
|
13
14
|
import {
|
|
14
15
|
extAudioChunkPath,
|
|
15
16
|
listExtAudioChunks,
|
|
@@ -1178,8 +1179,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1178
1179
|
})
|
|
1179
1180
|
return
|
|
1180
1181
|
}
|
|
1181
|
-
res
|
|
1182
|
-
res.sendFile(path)
|
|
1182
|
+
sendAudioFile(res, path)
|
|
1183
1183
|
})
|
|
1184
1184
|
|
|
1185
1185
|
/** What audio a meeting still has, so the panel can show play buttons only
|
package/server/routes/voice.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
|
11
11
|
import { dataPath } from '../lib/data-dir.js'
|
|
12
12
|
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
13
13
|
import { trainingSourceFor } from '../lib/training-audio-provenance.js'
|
|
14
|
+
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
15
|
|
|
15
16
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
16
17
|
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
@@ -471,8 +472,7 @@ voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
|
|
|
471
472
|
})
|
|
472
473
|
return
|
|
473
474
|
}
|
|
474
|
-
res
|
|
475
|
-
res.sendFile(wav)
|
|
475
|
+
sendAudioFile(res, wav)
|
|
476
476
|
})
|
|
477
477
|
|
|
478
478
|
// GET /api/voice/ext-audio/:sessionId/sample — hear an unidentified voice.
|
|
@@ -492,8 +492,7 @@ voiceRouter.get('/voice/ext-audio/:sessionId/sample', (req, res) => {
|
|
|
492
492
|
})
|
|
493
493
|
return
|
|
494
494
|
}
|
|
495
|
-
res
|
|
496
|
-
res.sendFile(wav)
|
|
495
|
+
sendAudioFile(res, wav)
|
|
497
496
|
})
|
|
498
497
|
|
|
499
498
|
// GET /api/voice/profiles — enrolled people with sample counts and provenance.
|