@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.
- package/README.md +5 -5
- package/SKILL.md +1 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +0 -1
- package/completions/kesha.zsh +3 -1
- package/docs/diagnostic-logs.md +36 -3
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +130 -0
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/action-result.ts +2 -1
- package/src/cli/command-session.ts +14 -3
- package/src/cli/completions.ts +4 -2
- package/src/cli/dispatch.ts +37 -5
- package/src/cli/init.ts +4 -4
- package/src/cli/install.ts +9 -6
- package/src/cli/main.ts +61 -46
- package/src/cli/mcp.ts +2 -0
- package/src/cli/options.ts +79 -0
- package/src/cli/record.ts +2 -1
- package/src/cli/say.ts +92 -11
- package/src/cli/stats.ts +9 -4
- package/src/cli/support-bundle.ts +3 -3
- package/src/cli.ts +1 -1
- package/src/diagnostic-log.ts +2 -9
- package/src/doctor.ts +62 -11
- package/src/engine/events.ts +20 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +230 -109
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +87 -102
- package/src/fluid-roots.ts +5 -0
- package/src/format.ts +11 -5
- package/src/install-lock.ts +45 -11
- package/src/install-plan.ts +96 -48
- package/src/kokoro-ane.ts +67 -4
- package/src/language-routing.ts +51 -0
- package/src/lib.ts +96 -61
- package/src/log.ts +2 -0
- package/src/mcp/audio-output.ts +2 -2
- package/src/mcp/tools.ts +10 -14
- package/src/package-info.ts +28 -2
- package/src/paths.ts +2 -2
- package/src/process-tree.ts +70 -18
- package/src/semver.mjs +7 -10
- package/src/shell-artifacts.ts +4 -3
- package/src/state-paths.ts +84 -0
- package/src/stats.ts +2 -10
- package/src/status.ts +39 -42
- package/src/synth.ts +58 -79
- package/src/transcribe.ts +18 -3
- package/src/voice-inventory.ts +51 -0
- package/src/voice-routing.ts +97 -6
- 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,
|
|
9
|
-
import {
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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: {
|
|
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
|
|
137
|
+
let identity: string;
|
|
181
138
|
try {
|
|
182
|
-
|
|
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?.
|
|
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 = {
|
|
165
|
+
cachedDescribe = { identity, doc };
|
|
209
166
|
return doc;
|
|
210
167
|
}
|
|
211
168
|
|
|
212
|
-
/**
|
|
213
|
-
export
|
|
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(
|
|
188
|
+
return describeToCapabilities(await getDescribe({ signal: controller.signal }));
|
|
216
189
|
} catch (err) {
|
|
217
|
-
if (err instanceof KeshaError)
|
|
218
|
-
|
|
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
|
|
469
|
-
let exitCode: number;
|
|
444
|
+
let run: Awaited<ReturnType<typeof runEngineProcess>>;
|
|
470
445
|
try {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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
|
-
|
|
492
|
-
]);
|
|
470
|
+
},
|
|
471
|
+
});
|
|
493
472
|
} finally {
|
|
494
473
|
status.clear();
|
|
495
|
-
tree.dispose();
|
|
496
474
|
}
|
|
497
|
-
|
|
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))
|
|
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",
|
|
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);
|
package/src/fluid-roots.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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";
|
package/src/install-lock.ts
CHANGED
|
@@ -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):
|
|
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
|
-
|
|
65
|
-
} catch {
|
|
66
|
-
|
|
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(
|
|
81
|
-
if (
|
|
82
|
-
|
|
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 (
|
|
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\`${
|
|
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,
|
|
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
|
}
|