@gotcos/glasses-server 6.21.20 → 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 CHANGED
@@ -1,3 +1,29 @@
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
+
1
27
  ## 6.21.20
2
28
 
3
29
  - Audio playback works at all. Every play button in the Control speaker review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.20",
3
+ "version": "6.21.21",
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": {
@@ -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 runProcessProbe(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
437
+ output = await runProcessProbeRetrying(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
347
438
  } catch (error) {
348
- throw new Error(`unable to inspect process table: ${boundedError(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
- const output = await runProcessProbe(
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?.code === 1 || err?.status === 1) return []
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
  }