Runner: Dev-Server Start/Abbruch-Korrekturen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent 71f9243bf2
commit 6cd50bba9f
2 files changed
+103 -15

No files matched your search

+30 -9
View File
@@ -8,6 +8,7 @@ import { readMemAvailable } from './server.js';
const LOG_LINES = 300;
const MAX_LINE = 2000;
const POLL_MS = 1000;
const RETRY_MS = 60e3;
const UID = 1000;
const PREFIX = 'preview-dev-';
const cacheName = (repo) => repo.replace('/', '__');
@@ -39,6 +40,7 @@ export function tcpConnect(host, port, timeoutMs = 2000) {
export function createDev({
config, docker, jobs, mem = readMemAvailable, now = Date.now, connect = tcpConnect,
setTimer = (fn, ms) => setTimeout(fn, ms).unref?.() ?? 0, prepareDirs = defaultPrepareDirs,
rmDir = (p) => nodeFs.rm(p, { recursive: true, force: true }),
}) {
const entries = new Map(); // sub ⇒ { sub, state, sha, lastUsed, log, port, job, release, promise, cancelled }
const sleep = (ms) => new Promise((r) => setTimer(r, ms));
@@ -56,8 +58,16 @@ export function createDev({
async function shutdown(e, finalState) {
e.cancelled = true;
e.release?.(); e.release = null;
if (finalState === 'off') entries.delete(e.sub); else { e.state = finalState; e.port = null; }
try { await docker.rm(PREFIX + e.sub); } catch (err) { console.error('dev rm', e.sub, err); }
if (finalState === 'off') { if (entries.get(e.sub) === e) entries.delete(e.sub); } else { e.state = finalState; e.port = null; }
// Nur der Eintrag, der den Container angelegt hat, entfernt ihn (Name ist je sub geteilt). Läuft `docker run` noch,
// räumt start() nach der Rückkehr selbst auf.
if (!e.stopping) {
e.stopping = (async () => {
if (!e.created) return;
try { const r = await docker.rm(PREFIX + e.sub); if (r && r.code) console.error('dev rm', e.sub, 'Exit-Code', r.code); } catch (err) { console.error('dev rm', e.sub, err); }
})();
}
return e.stopping;
}
async function start(e, job) {
@@ -69,12 +79,14 @@ export function createDev({
log(msg);
e.release?.(); e.release = null;
e.state = 'failed'; e.port = null;
try { await docker.rm(name); } catch { /* egal */ }
e.failedAt = now();
if (e.created) { try { await docker.rm(name); } catch { /* egal */ } }
};
try {
e.release = jobs.retainCache(job.repo);
if (e.cancelled) return; // vor dem Start abgebrochen: nichts einreihen (ein laufendes prepareWorkspace ließe sich nicht abbrechen)
const workDir = join(config.workRoot, 'dev', e.sub);
const cacheDir = join(config.cacheRoot, cacheName(job.repo));
// Bleibt ein Rest: ist prepareWorkspace schon gestartet, belegt es den Build-Slot bis zum Ende, auch nach Abbruch.
const prep = await jobs.prepareWorkspace(job, { onLine: log, workDir });
if (e.cancelled) return;
const d = prep.detected;
@@ -88,6 +100,7 @@ export function createDev({
});
// Feste Variable zuletzt, damit Projektvariablen sie nicht überschreiben; Werte laufen per --env-file (R10).
const env = { ...job.env, __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${config.baseHost}` };
e.created = true;
const r = await docker.run(args, { env, onLine: (l) => log(l) });
if (e.cancelled) { await docker.rm(name).catch(() => {}); return; }
if (r.code !== 0) return await fail(`Container konnte nicht gestartet werden (Exit-Code ${r.code})`);
@@ -112,6 +125,7 @@ export function createDev({
async function ensure(sub, job) {
const cur = entries.get(sub);
if (cur && cur.sha === job.sha) {
if (cur.state === 'failed' && now() - cur.failedAt < RETRY_MS) return { state: 'failed' }; // nicht öfter als alle 60 s neu versuchen
if (cur.state === 'online') { cur.lastUsed = now(); return { state: 'online', target: `http://${PREFIX}${sub}:${cur.port}` }; }
if (cur.state === 'starting') { cur.lastUsed = now(); return { state: 'starting' }; }
}
@@ -119,17 +133,23 @@ export function createDev({
try { free = mem(); } catch { /* Fehler = zu wenig Speicher */ }
if (!(free >= config.minFreeBytes)) throw Object.assign(new Error('Server ausgelastet'), { status: 503 });
const stops = [];
if (cur) stops.push(shutdown(cur, 'sleeping')); // neuer sha oder failed/sleeping: frisch starten
if (cur) stops.push(shutdown(cur, 'sleeping'), cur.promise); // neuer sha oder failed/sleeping: frisch starten, erst nach dem alten Eintrag
const others = [...entries.values()].filter((e) => e !== cur && isRunning(e)).sort((a, b) => a.lastUsed - b.lastUsed);
while (others.length >= config.devMax) stops.push(shutdown(others.shift(), 'sleeping')); // LRU
const e = { sub, state: 'starting', sha: job.sha, lastUsed: now(), log: [], port: null, release: null, promise: null, cancelled: false };
const e = { sub, state: 'starting', sha: job.sha, lastUsed: now(), log: [], port: null, release: jobs.retainCache(job.repo), promise: null, cancelled: false, created: false, stopping: null, failedAt: 0 };
entries.set(sub, e);
e.promise = Promise.all(stops).then(() => start(e, job));
e.promise = Promise.allSettled(stops).then(() => start(e, job));
return { state: 'starting' };
}
const touch = (sub) => { const e = entries.get(sub); if (e) e.lastUsed = now(); };
async function stop(sub) { const e = entries.get(sub); if (e) await shutdown(e, 'off'); }
async function stop(sub) {
const e = entries.get(sub);
if (!e) return;
await shutdown(e, 'off');
await e.promise.catch(() => {});
if (!entries.has(sub)) await rmDir(join(config.workRoot, 'dev', sub)).catch(() => {}); // Arbeitsordner aufräumen
}
const list = () => [...entries.values()].map((e) => ({ sub: e.sub, state: e.state, lastUsed: e.lastUsed, sha: e.sha }));
function status(sub) {
const e = entries.get(sub);
@@ -141,7 +161,8 @@ export function createDev({
}
// Zustand liegt nur im Speicher: nach Runner-Neustart übrig gebliebene Dev-Container entfernen.
async function cleanupOrphans() {
for (const n of await docker.ps('webklar.preview')) if (n.startsWith(PREFIX)) await docker.rm(n);
const names = (await docker.ps('webklar.preview')).filter((n) => n.startsWith(PREFIX));
for (const r of await Promise.allSettled(names.map((n) => docker.rm(n)))) if (r.status === 'rejected') console.error('orphan rm', r.reason);
}
const idle = () => Promise.all([...entries.values()].map((e) => e.promise)).then(() => {});