mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
992224f2e8
Generate a synced read-aloud EPUB from a book's EPUB and audiobook through an optional STAlign worker (Docker on CPU, or native with MLX on Apple Silicon), with review, re-sync, replace in place, restart recovery and live progress on the Files tab. Book details also stack the ebook and audiobook covers when both exist. See docs/READ_ALOUD.md.
115 lines
3.8 KiB
JavaScript
115 lines
3.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { fork } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { describe, test } from "node:test";
|
|
import { FAKE_PIPELINE, makeSandbox, stagesRun } from "./helpers.mjs";
|
|
|
|
const RUNNER = new URL("../dist/runner.js", import.meta.url);
|
|
|
|
describe("runner without its control process", () => {
|
|
test("a runner whose control process goes away kills what it started", async () => {
|
|
const sandbox = makeSandbox();
|
|
const job = runnerJob(sandbox);
|
|
const runner = startRunner(sandbox, "orphan-child", { detached: true });
|
|
let orphan = null;
|
|
try {
|
|
runner.child.send({ type: "start", job });
|
|
const pidFile = join(job.workDir, "orphan.pid");
|
|
await until(() => existsSync(pidFile), "the stand-in ffmpeg to start");
|
|
orphan = Number(readFileSync(pidFile, "utf8"));
|
|
assert.ok(isAlive(orphan), "the stand-in ffmpeg is running");
|
|
|
|
runner.child.disconnect();
|
|
const exit = await runner.exited;
|
|
assert.equal(exit.signal, "SIGKILL", "the runner went down with its own process group");
|
|
await until(() => !isAlive(orphan), "the orphaned child to be killed");
|
|
} finally {
|
|
runner.stop();
|
|
if (orphan !== null && isAlive(orphan)) process.kill(orphan, "SIGKILL");
|
|
sandbox.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a runner that leads no process group signals nothing outside itself", async () => {
|
|
const sandbox = makeSandbox();
|
|
const job = runnerJob(sandbox);
|
|
// Not detached: the runner shares this test's process group, as it would if it were started by hand.
|
|
const runner = startRunner(sandbox, "hang", { detached: false });
|
|
try {
|
|
runner.child.send({ type: "start", job });
|
|
await until(() => stagesRun(sandbox, job.jobId).some((entry) => entry.stage === "generating_emissions"), "the runner to reach inference");
|
|
|
|
runner.child.disconnect();
|
|
const exit = await runner.exited;
|
|
assert.equal(exit.signal, null, "the group kill found no group of its own and signalled nothing");
|
|
assert.ok(isAlive(process.pid));
|
|
} finally {
|
|
runner.stop();
|
|
sandbox.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
function startRunner(sandbox, mode, { detached }) {
|
|
const child = fork(RUNNER, [], {
|
|
execArgv: [],
|
|
detached,
|
|
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
env: { ...process.env, READ_ALOUD_RUNNER_PIPELINE: FAKE_PIPELINE, FAKE_PIPELINE_MODE: mode, FAKE_PIPELINE_LOG: sandbox.logPath },
|
|
});
|
|
const exited = new Promise((resolve) => child.once("exit", (code, signal) => resolve({ code, signal })));
|
|
return {
|
|
child,
|
|
exited,
|
|
stop() {
|
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
},
|
|
};
|
|
}
|
|
|
|
function runnerJob(sandbox) {
|
|
const jobId = randomUUID();
|
|
return {
|
|
jobId,
|
|
attempt: 1,
|
|
workDir: join(sandbox.workRoot, jobId),
|
|
epub: fingerprint(join(sandbox.book, "book.epub")),
|
|
audio: [fingerprint(join(sandbox.book, "01.mp3"))],
|
|
language: "en",
|
|
granularity: "sentence",
|
|
model: "quartznet15x5",
|
|
maxOutputBytes: 1_000_000,
|
|
decisions: { chapters: {}, audio: [] },
|
|
resumeFrom: null,
|
|
device: "cpu",
|
|
dtype: null,
|
|
batchSize: null,
|
|
redactions: [],
|
|
};
|
|
}
|
|
|
|
function fingerprint(path) {
|
|
const info = statSync(path);
|
|
return { path, size: info.size, mtimeMs: Math.trunc(info.mtimeMs) };
|
|
}
|
|
|
|
async function until(predicate, what, timeoutMs = 5_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!predicate()) {
|
|
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${what}`);
|
|
await sleep(25);
|
|
}
|
|
}
|
|
|
|
function isAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|