Files
Sascha ff285011d2 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.
2026-08-07 15:55:31 +02:00

88 lines
4.2 KiB
JavaScript

// Sperre für schwere Hintergrundarbeit, solange eine Aufnahme läuft.
//
// Warum: whisper, die sherpa-Diarisierung und rkllm dürfen nicht neben einer
// laufenden Aufnahme arbeiten. Auf diesem lüfterlosen 8-GB-Board hält rkllm
// allein ~6,7 GB (RssShmem 4,79 GB + RssAnon 1,93 GB von 7,74 GB MemTotal), es
// bleibt also ~1 GB Rest. Am 2026-08-06 lief die Analyse von #96 gleichzeitig
// mit der Transkription von #97 an — der globale OOM-Killer erschlug
// rkllm_server, systemd nahm den ganzen Dienst mit ('oom-kill'), und die GUI
// zeigte "failed to fetch". Dazu treibt die Zusatzlast die A76-Kerne auf ~80 °C,
// während das Mikrofon offen ist, und kostet Audioqualität.
//
// Der Recorder ist die einzige Wahrheit über den Aufnahmezustand; server.js
// hängt die Abfrage beim Start ein. Eigenes Modul, weil recorder.js bereits
// llm.js importiert — llm.js darf recorder.js deshalb nicht zurückimportieren
// (Zyklus).
let probe = () => false;
// Beim Start aus server.js: gate.setProbe(() => rec.isRecording()).
export function setProbe(fn) { probe = typeof fn === 'function' ? fn : (() => false); }
export function busy() {
try { return !!probe(); } catch { return false; }
}
// Parken, bis der Recorder wieder still ist. Ein- und Austritt werden geloggt,
// damit eine lange Pause im Journal als Pause erkennbar ist und nicht wie ein
// hängender Job aussieht.
export async function waitIdle(label) {
if (!busy()) return 0;
const t0 = Date.now();
console.log(`gate: ${label} pausiert — Aufnahme läuft`);
while (busy()) await new Promise(r => setTimeout(r, 2000));
const s = Math.round((Date.now() - t0) / 1000);
console.log(`gate: ${label} fortgesetzt nach ${s} s Pause`);
return s;
}
// --- Speicher freiräumen, bevor ein großes Modell lädt -----------------------
//
// Auf diesem 7,74-GB-Board darf nur EIN großes Modell resident sein. rkllm_server
// braucht allein ~6,5 GB (davon 4,79 GB unevictable NPU-Shmem), und daneben halten
// die faul gestarteten Dauerhelfer in der Spitze (gemessen, VmHWM):
// e5-small (embed_text.py) 1,15 GB
// CT2-Whisper (transcribe_daemon) 0,90 GB
// TitaNet (spk_embed) 0,70 GB
// Am 2026-08-07 kostete das die Analyse von #101: die 2-Sekunden-Aufnahme #100
// davor hatte den warmen CT2-Daemon gestartet (Kurz-Clip-Pfad, bleibt per Design
// resident), danach wurde rkllm in ALLEN 5 Startversuchen OOM-getötet → GUI
// "KI-Analyse fehlgeschlagen". zram hilft hier NICHT: der komprimierte Swap liegt
// selbst im RAM, ausgelagerte 0,9 GB kosten immer noch ~0,3-0,45 GB physisch.
//
// Jeder Helfer registriert deshalb, wie er sich beenden lässt; wer ein großes
// Modell laden will, ruft vorher freeRam(<eigener Name>). Alle Helfer starten
// beim nächsten Aufruf faul neu — es geht nur Ladezeit verloren, kein Zustand.
const releasers = new Map();
export function registerReleaser(name, stop) { releasers.set(name, stop); }
// Beendet einen Kindprozess und wartet, bis er wirklich weg ist (sonst hat der
// Kernel die Seiten beim Laden des nächsten Modells noch nicht zurück).
// Rückgabe: true, wenn etwas beendet wurde.
export function killAndWait(proc, timeoutMs = 5000) {
if (!proc || proc.exitCode !== null || proc.signalCode) return Promise.resolve(false);
return new Promise(resolve => {
let settled = false;
const done = () => { if (!settled) { settled = true; resolve(true); } };
proc.once('exit', done);
try { proc.kill('SIGTERM'); } catch { done(); return; }
setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} setTimeout(done, 500); }, timeoutMs);
});
}
// Alle registrierten Helfer außer `keep` beenden. Wird vor jedem Ladeversuch
// eines großen Modells aufgerufen — auch vor einem Retry, denn ein Retry ohne
// freien Speicher wird genauso OOM-getötet wie der erste Versuch.
export async function freeRam(keep) {
const freed = [];
for (const [name, stop] of releasers) {
if (name === keep) continue;
try { if (await stop()) freed.push(name); }
catch (e) { console.warn(`ram: ${name} nicht freigegeben — ${e.message}`); }
}
if (freed.length) {
console.log(`ram: für ${keep} freigegeben — ${freed.join(', ')}`);
await new Promise(r => setTimeout(r, 1500)); // Kernel-Reclaim abwarten
}
return freed;
}