@drakulavich/kesha-voice-kit 1.30.0 → 2.0.0

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.
Files changed (55) hide show
  1. package/README.md +5 -5
  2. package/SKILL.md +1 -1
  3. package/completions/kesha.bash +1 -1
  4. package/completions/kesha.fish +0 -1
  5. package/completions/kesha.zsh +3 -1
  6. package/docs/diagnostic-logs.md +36 -3
  7. package/docs/product-positioning.md +3 -3
  8. package/model-plan.json +130 -0
  9. package/package.json +21 -7
  10. package/src/cache-layout.ts +78 -8
  11. package/src/cli/action-result.ts +2 -1
  12. package/src/cli/command-session.ts +14 -3
  13. package/src/cli/completions.ts +4 -2
  14. package/src/cli/dispatch.ts +37 -5
  15. package/src/cli/init.ts +4 -4
  16. package/src/cli/install.ts +9 -6
  17. package/src/cli/main.ts +61 -46
  18. package/src/cli/mcp.ts +2 -0
  19. package/src/cli/options.ts +79 -0
  20. package/src/cli/record.ts +2 -1
  21. package/src/cli/say.ts +92 -11
  22. package/src/cli/stats.ts +9 -4
  23. package/src/cli/support-bundle.ts +3 -3
  24. package/src/cli.ts +1 -1
  25. package/src/diagnostic-log.ts +2 -9
  26. package/src/doctor.ts +62 -11
  27. package/src/engine/events.ts +20 -1
  28. package/src/engine/spawn.ts +114 -0
  29. package/src/engine-health.ts +25 -56
  30. package/src/engine-install.ts +230 -109
  31. package/src/engine-targets.ts +53 -5
  32. package/src/engine.ts +87 -102
  33. package/src/fluid-roots.ts +5 -0
  34. package/src/format.ts +11 -5
  35. package/src/install-lock.ts +45 -11
  36. package/src/install-plan.ts +96 -48
  37. package/src/kokoro-ane.ts +67 -4
  38. package/src/language-routing.ts +51 -0
  39. package/src/lib.ts +96 -61
  40. package/src/log.ts +2 -0
  41. package/src/mcp/audio-output.ts +2 -2
  42. package/src/mcp/tools.ts +10 -14
  43. package/src/package-info.ts +28 -2
  44. package/src/paths.ts +2 -2
  45. package/src/process-tree.ts +70 -18
  46. package/src/semver.mjs +7 -10
  47. package/src/shell-artifacts.ts +4 -3
  48. package/src/state-paths.ts +84 -0
  49. package/src/stats.ts +2 -10
  50. package/src/status.ts +39 -42
  51. package/src/synth.ts +58 -79
  52. package/src/transcribe.ts +18 -3
  53. package/src/voice-inventory.ts +51 -0
  54. package/src/voice-routing.ts +97 -6
  55. package/tsconfig.json +1 -1
package/src/engine.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { existsSync, statSync } from "fs";
1
+ import { existsSync, statSync, type Stats } from "fs";
2
2
  import { errorMessage } from "./error-utils";
3
3
  import { join } from "path";
4
4
  import { installHint } from "./install-hint";
5
5
  import { log } from "./log";
6
6
  import { createLiveStatus } from "./progress";
7
7
  import { defaultEngineBinPath, keshaCacheDir } from "./paths";
8
- import { engineAbortError, registerProcessTree } from "./process-tree";
9
- import { engineFailure, KeshaError, readEvents, type ErrorEvent } from "./engine/events";
8
+ import { engineAbortError, interruptedRun } from "./process-tree";
9
+ import { runEngineProcess, spawnHint } from "./engine/spawn";
10
+ import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
10
11
  import {
11
12
  describeToCapabilities,
12
13
  parseDescribe,
@@ -15,7 +16,6 @@ import {
15
16
  type DescribeDocument,
16
17
  type EngineCapabilities,
17
18
  type TtsLanguageCapability,
18
- PROTOCOL_VERSION,
19
19
  } from "./engine/describe";
20
20
 
21
21
  export type { EngineCapabilities, TtsLanguageCapability };
@@ -74,39 +74,10 @@ export function isEngineInstalled(): boolean {
74
74
  return existsSync(getEngineBinPath());
75
75
  }
76
76
 
77
- type SpawnStdioEntry = "inherit" | "pipe" | "ignore";
78
- export type SpawnStdio = [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry];
79
-
80
- /** The env for a spawn whose stderr is parsed as protocol 4 events. */
81
- export function protocolEnv(): Record<string, string | undefined> {
82
- return { ...process.env, KESHA_PROTOCOL: String(PROTOCOL_VERSION) };
83
- }
84
-
85
- function spawnHint(): string {
86
- return process.env.KESHA_ENGINE_BIN
87
- ? "KESHA_ENGINE_BIN points at it; fix the path or unset it and run `kesha install`"
88
- : "run `kesha install`";
89
- }
90
-
91
- /** `Bun.spawn` throws synchronously on ENOENT/EACCES; every launch failure becomes `E_ENGINE_SPAWN`. */
92
- export function spawnEngineProcess(
93
- binPath: string,
94
- args: string[],
95
- stdio: SpawnStdio,
96
- env: Record<string, string | undefined> = process.env,
97
- ): ReturnType<typeof Bun.spawn> {
98
- try {
99
- // `env` is passed explicitly: Bun snapshots process.env at startup otherwise (#874).
100
- return Bun.spawn([binPath, ...args], { detached: true, stdio, env });
101
- } catch (err) {
102
- throw new KeshaError("E_ENGINE_SPAWN", `failed to launch kesha-engine at ${binPath}: ${errorMessage(err)}`, {
103
- hint: spawnHint(),
104
- });
105
- }
106
- }
107
-
108
77
  export interface RunEngineOptions {
109
78
  signal?: AbortSignal;
79
+ /** Text for the subprocess's stdin. Nothing a user typed may become an argv element: a NUL byte or a megabyte of it makes the spawn itself fail (#T1-1). */
80
+ stdin?: string;
110
81
  /** Receives each progress line as the engine writes it, rather than once the run is
111
82
  * over (#1002). Progress the caller takes delivery of this way is dropped from the
112
83
  * returned `stderr`, so it is never shown a second time in the failure report. */
@@ -122,44 +93,25 @@ interface EngineRun {
122
93
  }
123
94
 
124
95
  async function runEngine(args: string[], opts: RunEngineOptions = {}): Promise<EngineRun> {
125
- if (opts.signal?.aborted) throw engineAbortError();
126
- const binPath = getEngineBinPath();
127
- const startedAt = performance.now();
128
- log.debug(`spawn ${binPath} ${args.join(" ")}`);
129
- const proc = spawnEngineProcess(binPath, args, ["ignore", "pipe", "pipe"], protocolEnv());
130
- const tree = registerProcessTree(proc);
131
- let aborted = false;
132
- let forceKillTimer: Timer | undefined;
133
- const abort = () => {
134
- aborted = true;
135
- tree.terminate("SIGTERM");
136
- forceKillTimer ??= tree.forceKillAfterGrace();
137
- };
138
- opts.signal?.addEventListener("abort", abort, { once: true });
139
- let stdout: string;
140
- let events: Awaited<ReturnType<typeof readEvents>>;
141
- let exitCode: number;
142
- try {
143
- [stdout, events, exitCode] = await Promise.all([
144
- new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
145
- readEvents(proc.stderr as ReadableStream<Uint8Array>, { onProgress: opts.onProgressLine }),
146
- proc.exited,
147
- ]);
148
- } finally {
149
- opts.signal?.removeEventListener("abort", abort);
150
- tree.dispose();
151
- if (!aborted && forceKillTimer) clearTimeout(forceKillTimer);
152
- }
153
- log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
154
- if (aborted) {
96
+ const run = await runEngineProcess(getEngineBinPath(), args, {
97
+ stdio: [opts.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
98
+ stdin: opts.stdin,
99
+ readStdout: (stream) => new Response(stream).text(),
100
+ sinks: { onProgress: opts.onProgressLine },
101
+ signal: opts.signal,
102
+ });
103
+ if (run.aborted) {
155
104
  log.debug(`aborted args=${JSON.stringify(args)}`);
156
105
  throw engineAbortError();
157
106
  }
107
+ const interrupted = interruptedRun(run.exitCode);
108
+ if (interrupted) throw interrupted;
109
+ const { events } = run;
158
110
  const stderr = events.stderr.trim();
159
111
  // #275 D4: warnings reach the user on success; on failure they travel inside the KeshaError.
160
- if (exitCode === 0 && events.invalid.length === 0 && events.error === null && stderr.length > 0)
112
+ if (run.exitCode === 0 && events.invalid.length === 0 && events.error === null && stderr.length > 0)
161
113
  process.stderr.write(`${stderr}\n`);
162
- return { stdout: stdout.trim(), stderr, exitCode, error: events.error, invalid: events.invalid };
114
+ return { stdout: run.stdout.trim(), stderr, exitCode: run.exitCode, error: events.error, invalid: events.invalid };
163
115
  }
164
116
 
165
117
  /** The run failed and said so: a non-zero status, or an error event whatever the status. */
@@ -172,20 +124,25 @@ function failed(run: EngineRun): boolean {
172
124
  return reportedFailure(run) || run.invalid.length > 0;
173
125
  }
174
126
 
175
- let cachedDescribe: { binPath: string; mtime: number; doc: DescribeDocument } | null = null;
127
+ let cachedDescribe: { identity: string; doc: DescribeDocument } | null = null;
128
+
129
+ /** ctime is part of it because utimes cannot set it back after a replacement that restores mtime (#1256, #1258). */
130
+ function fileIdentity(binPath: string, st: Stats): string {
131
+ return `${binPath}:${st.ino}:${st.size}:${st.ctimeMs}`;
132
+ }
176
133
 
177
134
  /** The describe document of the installed engine, cached until the binary changes (#248). */
178
135
  export async function getDescribe(opts: RunEngineOptions = {}): Promise<DescribeDocument> {
179
136
  const binPath = getEngineBinPath();
180
- let mtime: number;
137
+ let identity: string;
181
138
  try {
182
- mtime = statSync(binPath).mtimeMs;
139
+ identity = fileIdentity(binPath, statSync(binPath));
183
140
  } catch (err) {
184
141
  throw new KeshaError("E_ENGINE_SPAWN", `kesha-engine not found at ${binPath}: ${errorMessage(err)}`, {
185
142
  hint: spawnHint(),
186
143
  });
187
144
  }
188
- if (cachedDescribe?.binPath === binPath && cachedDescribe.mtime === mtime) return cachedDescribe.doc;
145
+ if (cachedDescribe?.identity === identity) return cachedDescribe.doc;
189
146
  const run = await runEngine(["describe"], opts);
190
147
  let doc: DescribeDocument | null = null;
191
148
  if (run.exitCode === 0) {
@@ -205,17 +162,42 @@ export async function getDescribe(opts: RunEngineOptions = {}): Promise<Describe
205
162
  const mismatch = protocolMismatch(doc, binPath);
206
163
  if (mismatch) throw mismatch;
207
164
  if (run.invalid.length > 0) throw engineFailure("describe", run, undefined);
208
- cachedDescribe = { binPath, mtime, doc };
165
+ cachedDescribe = { identity, doc };
209
166
  return doc;
210
167
  }
211
168
 
212
- /** Capabilities view for the screens that predate `describe`; null when the engine cannot be read. */
213
- export async function getEngineCapabilities(opts: RunEngineOptions = {}): Promise<EngineCapabilities | null> {
169
+ /** Generous because macOS Gatekeeper can take several seconds to scan a freshly written binary on its first run. */
170
+ export const ENGINE_PROBE_TIMEOUT_MS = 15_000;
171
+
172
+ let unansweredDescribe: string | null = null;
173
+
174
+ function binaryIdentity(binPath: string): string {
175
+ const st = statSync(binPath, { throwIfNoEntry: false });
176
+ return st ? fileIdentity(binPath, st) : `${binPath}:missing`;
177
+ }
178
+
179
+ /** Capabilities view for the screens that predate `describe`; null when the engine cannot be read within the deadline. */
180
+ export async function getEngineCapabilities(timeoutMs = ENGINE_PROBE_TIMEOUT_MS): Promise<EngineCapabilities | null> {
181
+ const binPath = getEngineBinPath();
182
+ const identity = binaryIdentity(binPath);
183
+ // `install --plan` probes twice; a binary that already let one deadline pass is not waited on again.
184
+ if (unansweredDescribe === identity) return null;
185
+ const controller = new AbortController();
186
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
214
187
  try {
215
- return describeToCapabilities(await getDescribe(opts));
188
+ return describeToCapabilities(await getDescribe({ signal: controller.signal }));
216
189
  } catch (err) {
217
- if (err instanceof KeshaError) return null;
218
- throw err;
190
+ if (!(err instanceof KeshaError)) throw err;
191
+ if (controller.signal.aborted) {
192
+ unansweredDescribe = identity;
193
+ log.warn(
194
+ `kesha-engine at ${binPath} did not answer \`describe\` within ${timeoutMs / 1000}s; ` +
195
+ "continuing without its capabilities — re-run `kesha install` to replace it",
196
+ );
197
+ }
198
+ return null;
199
+ } finally {
200
+ clearTimeout(timer);
219
201
  }
220
202
  }
221
203
 
@@ -454,47 +436,43 @@ async function forwardStdout(stream: ReadableStream<Uint8Array>, status: { clear
454
436
  * reworded ticker degrades to one line per second rather than breaking (`recordTicksInPlace`).
455
437
  */
456
438
  const RECORD_TICK = /^Listening\.\.\. \d+s$/;
439
+ /** The recording's outcome arrives as progress too; it is the result, so `--quiet` must not drop it (Exploratory S3-F2). */
440
+ const RECORD_OUTCOME = /^(Recorded .+ \(\d+ Hz, \d+ channels?, \d+ frames\)|No speech detected\.)$/;
457
441
 
458
442
  export async function recordEngine(target: RecordTarget, maxSeconds: number): Promise<void> {
459
- const binPath = getEngineBinPath();
460
- const args = buildRecordArgs(target, maxSeconds);
461
- const startedAt = performance.now();
462
- log.debug(`spawn ${binPath} ${args.join(" ")}`);
463
- // stdout is piped rather than inherited so the row can be closed before the engine's transcript
464
- // lands: the engine owned both streams and closed its own row until #1181 deleted the painter.
465
- const proc = spawnEngineProcess(binPath, args, ["inherit", "pipe", "pipe"], protocolEnv());
466
- const tree = registerProcessTree(proc);
467
443
  const status = createLiveStatus();
468
- let events: Awaited<ReturnType<typeof readEvents>>;
469
- let exitCode: number;
444
+ let run: Awaited<ReturnType<typeof runEngineProcess>>;
470
445
  try {
471
- [, events, exitCode] = await Promise.all([
472
- forwardStdout(proc.stdout as ReadableStream<Uint8Array>, status, () => {
473
- // Nobody is reading the transcript any more, so the microphone has no reason to stay open (#1187).
474
- log.debug("stdout closed by the reader; stopping the recording");
475
- proc.kill("SIGTERM");
476
- }),
477
- readEvents(proc.stderr as ReadableStream<Uint8Array>, {
446
+ // stdout is piped rather than inherited so the row can be closed before the engine's transcript
447
+ // lands: the engine owned both streams and closed its own row until #1181 deleted the painter.
448
+ run = await runEngineProcess(getEngineBinPath(), buildRecordArgs(target, maxSeconds), {
449
+ stdio: ["inherit", "pipe", "pipe"],
450
+ readStdout: (stream, proc) =>
451
+ forwardStdout(stream, status, () => {
452
+ // Nobody is reading the transcript any more, so the microphone has no reason to stay open (#1187).
453
+ log.debug("stdout closed by the reader; stopping the recording");
454
+ proc.kill("SIGTERM");
455
+ }),
456
+ sinks: {
478
457
  onProgress: (line) => {
479
458
  if (RECORD_TICK.test(line)) {
480
459
  status.update(line);
481
460
  return;
482
461
  }
483
462
  status.clear();
484
- log.progress(line);
463
+ if (RECORD_OUTCOME.test(line)) log.notice(line);
464
+ else log.progress(line);
485
465
  },
486
466
  onWarn: (line) => {
487
467
  status.clear();
488
468
  log.warn(line);
489
469
  },
490
- }),
491
- proc.exited,
492
- ]);
470
+ },
471
+ });
493
472
  } finally {
494
473
  status.clear();
495
- tree.dispose();
496
474
  }
497
- log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
475
+ const { exitCode, events } = run;
498
476
  // An interrupt is how a live recording normally ends, so its status is success and its stderr is not a failure.
499
477
  const signalled = target.live && SIGNALLED_LIVE_EXIT_CODES.has(exitCode);
500
478
  // A clean interrupt delivers the transcript and exits 128+signal saying nothing (rust/src/cli/record.rs:82),
@@ -523,7 +501,14 @@ export async function detectAudioLanguageEngine(
523
501
  if (!isEngineInstalled()) return null;
524
502
  const run = await runEngine(["detect-lang", audioPath], opts);
525
503
  // The tolerant form: a noisy onnxruntime warning must not blind a best-effort guess.
526
- if (reportedFailure(run)) return null;
504
+ if (reportedFailure(run)) {
505
+ const cause = run.error ? renderError(run.error) : `kesha-engine detect-lang exited with code ${run.exitCode}`;
506
+ log.warn(
507
+ `Audio language detection failed: ${cause}\n` +
508
+ " Fix: run `kesha install` to reinstall the engine and its language-ID model.",
509
+ );
510
+ return null;
511
+ }
527
512
  return parseLangResult(run.stdout);
528
513
  }
529
514
 
@@ -533,7 +518,7 @@ export async function detectTextLanguageEngine(
533
518
  ): Promise<LangDetectResult | null> {
534
519
  if (text.trim().length === 0) return null;
535
520
  if (!isEngineInstalled()) return null;
536
- const run = await runEngine(["detect-text-lang", text], opts);
521
+ const run = await runEngine(["detect-text-lang"], { ...opts, stdin: text });
537
522
  if (reportedFailure(run)) {
538
523
  const warning = textLangFailureWarning(run.stderr);
539
524
  if (warning) log.warn(warning);
@@ -71,6 +71,11 @@ export function kokoroAneDir(options: FluidRootsOptions = {}): string {
71
71
  return join(kokoroBundleRoot(options), "ANE");
72
72
  }
73
73
 
74
+ /** The Mandarin sibling bundle; mirrors `models/paths.rs::fluidaudio_ane_zh_kokoro_dir`. */
75
+ export function kokoroAneZhDir(options: FluidRootsOptions = {}): string {
76
+ return join(kokoroBundleRoot(options), "ANE-zh");
77
+ }
78
+
74
79
  /**
75
80
  * FluidAudio's own VAD copy, distinct from the Silero model Kesha pins under `<cache>/models`.
76
81
  * The bridge passes no directory for VAD, so this stays put whatever the models root is —
package/src/format.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { LANG_CONFIDENCE_FLOOR, routeLanguage } from "./language-routing";
1
2
  import type { TranscribeErrorRecord, TranscribeJsonOutput, TranscribeResult } from "./types";
2
3
 
4
+ const BELOW_FLOOR_NOTE = `below the ${LANG_CONFIDENCE_FLOOR} floor, ignored for lang`;
5
+
3
6
  export function formatTextOutput(results: TranscribeResult[]): string {
4
7
  const [first] = results;
5
8
  if (first && results.length === 1) {
@@ -10,7 +13,9 @@ export function formatTextOutput(results: TranscribeResult[]): string {
10
13
  .join("");
11
14
  }
12
15
 
13
- export function formatVerboseOutput(results: TranscribeResult[]): string {
16
+ /** The `--verbose` block for stderr: detection details and timing, never the transcript (that is stdout's). */
17
+ export function formatVerboseDiagnostics(results: TranscribeResult[]): string {
18
+ if (results.length === 0) return "";
14
19
  return results
15
20
  .map((r, i) => {
16
21
  const lines: string[] = [];
@@ -18,19 +23,20 @@ export function formatVerboseOutput(results: TranscribeResult[]): string {
18
23
  if (i > 0) lines.push("");
19
24
  lines.push(`=== ${r.file} ===`);
20
25
  }
26
+ const route = routeLanguage({ audioLanguage: r.audioLanguage, textLanguage: r.textLanguage });
21
27
  if (r.audioLanguage) {
22
- lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)})`);
28
+ const note = route.belowFloor.audio ? `, ${BELOW_FLOOR_NOTE}` : "";
29
+ lines.push(`Audio language: ${r.audioLanguage.code} (confidence: ${r.audioLanguage.confidence.toFixed(2)}${note})`);
23
30
  }
24
31
  const textLang = r.textLanguage ?? (r.lang ? { code: r.lang, confidence: 0 } : null);
25
32
  if (textLang) {
26
- const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
33
+ const note = route.belowFloor.text ? `, ${BELOW_FLOOR_NOTE}` : "";
34
+ const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)}${note})` : "";
27
35
  lines.push(`Text language: ${textLang.code}${confStr}`);
28
36
  }
29
37
  if (r.sttTimeMs !== undefined) {
30
38
  lines.push(`STT time: ${r.sttTimeMs}ms`);
31
39
  }
32
- lines.push("---");
33
- lines.push(r.text);
34
40
  return lines.join("\n");
35
41
  })
36
42
  .join("\n") + "\n";
@@ -41,6 +41,16 @@ interface LockOwner {
41
41
  startedAt: number;
42
42
  }
43
43
 
44
+ /**
45
+ * An owner file by name. `live` carries its record; `corrupt` read but does not parse and is still
46
+ * a name to unlink; `unreadable` is one the read itself refused (EACCES, EIO) — nothing to judge,
47
+ * so it counts as held rather than as a corrupt owner to clear.
48
+ */
49
+ type LockHolder =
50
+ | { kind: "live"; token: string; owner: LockOwner }
51
+ | { kind: "corrupt"; token: string }
52
+ | { kind: "unreadable"; token: string };
53
+
44
54
  /**
45
55
  * The owner is named by its file rather than identified by a field inside one: unlinking that
46
56
  * exact name is what makes both release and stale-lock takeover exclusive. Of two waiters
@@ -51,7 +61,7 @@ function ownerPath(lockDir: string, token: string): string {
51
61
  return join(lockDir, `${OWNER_PREFIX}${token}${OWNER_SUFFIX}`);
52
62
  }
53
63
 
54
- function readOwner(lockDir: string): LockOwner | null {
64
+ function readOwner(lockDir: string): LockHolder | null {
55
65
  let entries: string[];
56
66
  try {
57
67
  entries = readdirSync(lockDir);
@@ -60,10 +70,21 @@ function readOwner(lockDir: string): LockOwner | null {
60
70
  }
61
71
  const name = entries.find((e) => e.startsWith(OWNER_PREFIX) && e.endsWith(OWNER_SUFFIX));
62
72
  if (!name) return null;
73
+ const token = name.slice(OWNER_PREFIX.length, -OWNER_SUFFIX.length);
74
+ let raw: string;
63
75
  try {
64
- return JSON.parse(readFileSync(join(lockDir, name), "utf8")) as LockOwner;
65
- } catch {
66
- return null;
76
+ raw = readFileSync(join(lockDir, name), "utf8");
77
+ } catch (e) {
78
+ // The owner released between the listing and the read: no owner file, not a holder we cannot read.
79
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return null;
80
+ log.debug(`install lock owner ${join(lockDir, name)} cannot be read (${errorMessage(e)}); treating it as held.`);
81
+ return { kind: "unreadable", token };
82
+ }
83
+ try {
84
+ return { kind: "live", token, owner: JSON.parse(raw) as LockOwner };
85
+ } catch (e) {
86
+ log.debug(`install lock owner ${join(lockDir, name)} does not parse (${errorMessage(e)}); treating it as stale.`);
87
+ return { kind: "corrupt", token };
67
88
  }
68
89
  }
69
90
 
@@ -77,9 +98,19 @@ function pidAlive(pid: number): boolean {
77
98
  }
78
99
  }
79
100
 
80
- function lockIsStale(owner: LockOwner): boolean {
81
- if (owner.host === hostname() && !pidAlive(owner.pid)) return true;
82
- return Date.now() - owner.startedAt > STALE_LOCK_MS;
101
+ function lockIsStale(holder: LockHolder | null): boolean {
102
+ if (holder === null) return true;
103
+ switch (holder.kind) {
104
+ case "corrupt":
105
+ return true;
106
+ case "unreadable":
107
+ return false;
108
+ case "live": {
109
+ const { owner } = holder;
110
+ if (owner.host === hostname() && !pidAlive(owner.pid)) return true;
111
+ return Date.now() - owner.startedAt > STALE_LOCK_MS;
112
+ }
113
+ }
83
114
  }
84
115
 
85
116
  /**
@@ -244,18 +275,21 @@ export async function acquireInstallLock(
244
275
  if (outcome === "impossible") return () => {};
245
276
 
246
277
  const holder = readOwner(lockDir);
247
- if ((holder === null || lockIsStale(holder)) && clearLock(lockDir, holder?.token ?? null)) {
278
+ if (lockIsStale(holder) && clearLock(lockDir, holder?.token ?? null)) {
248
279
  continue;
249
280
  }
281
+ const held = holder?.kind === "live" ? holder.owner : null;
250
282
 
251
283
  if (!announced) {
252
284
  announced = true;
285
+ // The default wait is hours long, so the way out is stated up front rather than after the timeout (S4-F2).
253
286
  log.warn(
254
- `Another \`kesha install\`${holder ? ` (pid ${holder.pid})` : ""} is using ` +
255
- `${dirname(binPath)}; waiting for it to finish...`,
287
+ `Another \`kesha install\`${held ? ` (pid ${held.pid} on ${held.host})` : ""} is using ` +
288
+ `${dirname(binPath)}; waiting for it to finish... If no install is running, delete ` +
289
+ `${lockDir} and re-run.`,
256
290
  );
257
291
  }
258
- if (Date.now() + delay >= deadline) throw waitTimedOut(binPath, holder, maxWaitMs);
292
+ if (Date.now() + delay >= deadline) throw waitTimedOut(binPath, held, maxWaitMs);
259
293
  await Bun.sleep(delay);
260
294
  delay = Math.min(delay * 2, POLL_MAX_MS);
261
295
  }