fix(ram): nur ein grosses Modell resident (Analyse starb im OOM-Killer)

rkllm_server braucht 6,5 GB von 7,74 GB. Blieb daneben ein anderer
Dauerhelfer resident, wurde rkllm beim Laden OOM-getoetet -- bei #101 in
allen 5 Startversuchen, weil das Retry nichts freigab. Ausloeser war der
warme CT2-Whisper-Daemon, den die 2-Sekunden-Aufnahme davor gestartet
hatte. Zweiter Fall: rkllm blieb nach der Analyse fuer immer resident, und
der Suchindex startete e5-small daneben.

gate.js bekommt eine Freigabe-Registry (registerReleaser/freeRam/
killAndWait, verdrahtet in server.js wie setProbe). Vor jedem rkllm-Versuch
und vor dem NPU-Whisper-Lauf werden die anderen Helfer beendet und ihr
Prozessende abgewartet; analyzeRecording gibt rkllm vor dem Suchindex frei.
Alle Helfer starten faul neu.

Verifiziert mit der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon
resident) -> Analyse #101 laeuft durch (221 s, done), rkllm lud in 22 s,
kein OOM, danach 5,3 GB frei statt 100 MB.
This commit is contained in:
2026-08-07 15:55:31 +02:00
parent c5daee25af
commit ff285011d2
8 changed files with 277 additions and 7 deletions
+36 -4
View File
@@ -12,8 +12,26 @@ import * as diar from './diar.js';
import * as spk from './spk.js';
import * as search from './search.js';
import * as emb from './emb.js';
import * as gate from './gate.js';
const pexec = promisify(execFile);
// Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen
// wird. gate.js fragt sie hier ab, statt recorder.js zu importieren (das
// waere ein Zyklus, weil recorder.js llm.js importiert).
gate.setProbe(() => rec.isRecording());
// Wer sich wie freigeben lässt, wenn ein großes Modell RAM braucht (gate.freeRam).
// Hier verdrahtet, damit gate.js keines der Module importieren muss (Zyklen) —
// dasselbe Muster wie setProbe oben. Jeder Helfer startet faul neu.
gate.registerReleaser('rkllm', async () => {
if (!llm.status().running) return false;
await llm.stopServer();
return true;
});
gate.registerReleaser('cpu-transcriber', () => rec.stopTranscribeDaemon());
gate.registerReleaser('text-embedder', () => emb.stop());
gate.registerReleaser('spk-embedder', () => spk.stop());
// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH
// soundalikes get a deterministic swap; term-only entries are left to the LLM hint.
function glossaryMap(glossary) {
@@ -254,7 +272,7 @@ async function analyzeRecording(id) {
// spk-embed helper OOM-dies next to it ("spk beendet" per cluster, #71 got
// 48 raw speakers). llm.analyze() below respawns it (~96s load, noise
// against a 15-min analysis).
llm.stopServer();
await llm.stopServer();
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
try {
const rec = await db.getRecording(id);
@@ -312,6 +330,13 @@ async function analyzeRecording(id) {
a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap));
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
await db.updateAnalysis(id, { ...a, status: 'done' });
// rkllm ist ab hier fertig und wird freigegeben, BEVOR der Suchindex unten
// das e5-Modell (1,15 GB) startet: am 2026-08-07 traf genau diese Reihenfolge
// den OOM-Killer, der den residenten rkllm erschlug (13:27:09, dmesg). Vorher
// blieb rkllm nach einer Analyse für immer resident — das war der Zustand
// "nur noch 100 MB frei", in dem jeder weitere Helferstart russisches
// Roulette war. Preis: die nächste Analyse lädt das Modell neu (~96 s).
await llm.stopServer();
// A speaker can be named WHILE the analysis runs (it takes ~15 min, and the
// marker/voiceprint passes themselves rename clusters). The finished text
// still carries the label from analysis start ("Sprecher 1"), so patch it
@@ -373,6 +398,10 @@ function withNpuLock(label, fn) {
const waiting = npuDepth - 1;
const run = npuChain.then(async () => {
if (waiting > 0) console.log(`npu-queue: ${label} start (${waiting} ahead done)`);
// Nie neben einer laufenden Aufnahme: rkllm haelt ~6,7 GB von 7,74 GB,
// eine zweite schwere Last traf am 2026-08-06 den globalen OOM-Killer
// und nahm den Dienst mit. Siehe gate.js.
await gate.waitIdle(label);
try { return await fn(); }
finally { npuDepth--; }
});
@@ -411,7 +440,7 @@ async function transcribeAndAnalyze(id, wavFile, numSpeakers) {
await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {});
return; // no transcript -> skip LLM analysis
}
analyzeRecording(id);
await analyzeRecording(id);
}
app.post('/api/record/stop', wrap(async (req, res) => {
@@ -593,7 +622,7 @@ app.post('/api/search/answer', wrap(async (req, res) => {
try {
res.json(await withNpuLock('search-answer', () => search.answer(q, hits)));
} finally {
llm.stopServer();
await llm.stopServer();
}
}));
app.get('/api/search/status', wrap(async (req, res) => {
@@ -666,7 +695,10 @@ async function rediarize(id, numSpeakers) {
// Free the resident rkllm first — diar's consolidation embeds every cluster
// via TitaNet, which OOM-dies next to a resident 5.9GB LLM (see
// analyzeRecording; on #71 that silently yielded 48 raw speakers).
llm.stopServer();
// Abwarten: ein nicht abgewartetes stopServer() laesst proc/ready gesetzt,
// bis das exit-Event eintrifft — der naechste ask() schreibt dann in die
// tote Pipe (EPIPE, riss am 2026-08-06 den Dienst mit).
await llm.stopServer();
// Diarization runs on the A76 cores; the board is fanless, so cap the clock
// for the whole run (transcribeWav does this for the record path — rediarize
// is the other entry point and must do it too, or a long re-diar overheats).