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
+23 -1
View File
@@ -4,6 +4,7 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import readline from 'node:readline';
import * as gate from './gate.js';
const SERVER = '/opt/callrec/rkllm_server';
const MODEL = process.env.RKLLM_MODEL || '/opt/rkllm-models/qwen2.5-3b.rkllm';
@@ -30,6 +31,14 @@ function spawnServer() {
let done = false;
proc = spawn('taskset', ['-c', '4-7', SERVER, MODEL], { stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => process.stderr.write('[rkllm] ' + d));
// Ein write() in die stdin eines toten Kindes loest 'error' (EPIPE) auf
// diesem Socket aus. Ohne Listener behandelt node das als unhandled
// 'error' event und beendet den GANZEN Dienst — genau so starb callrec am
// 2026-08-06 nach #98. Der Fehler gehoert an die offene Anfrage, nicht an
// den Prozess.
proc.stdin.on('error', e => {
if (pending) { const p = pending; pending = null; p.reject(new Error('rkllm stdin: ' + e.message)); }
});
ready = new Promise((resolve, reject) => {
settleReject = reject;
// 180s: Qwen3-4B (5.3GB) needs ~96s to load on the capped clock — the old
@@ -61,6 +70,11 @@ async function startServer() {
// loads succeed. Up to 5 attempts, ~4s apart.
let lastErr;
for (let attempt = 1; attempt <= 5; attempt++) {
// Vor JEDEM Versuch die anderen großen Helfer beenden. rkllm braucht ~6,5 GB
// von 7,74 GB; blieb z. B. der warme CT2-Daemon der vorherigen Kurz-Aufnahme
// resident, wird rkllm OOM-getötet — und ein Retry ohne freien Speicher
// stirbt genauso (2026-08-07: 5/5 Versuche, "KI-Analyse fehlgeschlagen").
await gate.freeRam('rkllm');
try { spawnServer(); await ready; return; }
catch (e) {
lastErr = e;
@@ -78,10 +92,18 @@ async function startServer() {
// next one (otherwise one bad chunk fails all following chunks instantly).
function ask(prompt) {
const run = chain.then(async () => {
// Haltepunkt zwischen den Chunks: startet mittendrin eine Aufnahme,
// pausiert die Analyse hier, statt neben dem offenen Mikrofon zu rechnen.
await gate.waitIdle('llm');
await startServer();
return await new Promise((resolve, reject) => {
// Zweites Netz: der Prozess kann zwischen startServer() und hier
// gestorben sein (exit-Event noch nicht verarbeitet).
if (!proc || !proc.stdin || proc.stdin.destroyed)
return reject(new Error('rkllm nicht bereit (Prozess weg)'));
pending = { resolve, reject };
proc.stdin.write(b64e(prompt) + '\n');
try { proc.stdin.write(b64e(prompt) + '\n'); }
catch (e) { pending = null; reject(new Error('rkllm stdin: ' + e.message)); }
});
});
chain = run.catch(() => {}); // next request starts from a clean, resolved chain