fix(runner): Dev-Neustart bei geänderten Variablen, Liveness-Prüfung, npm-ci-Fallback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent cd9a6e0cf1
commit 96c26fb9b8
8 files changed
+139 -10

No files matched your search

+21 -5
View File
@@ -1,5 +1,6 @@
// Dev-Server bei Bedarf: Container mit `npm run dev` starten, bei Leerlauf stoppen, max. devMax gleichzeitig (LRU-Verdrängung).
import net from 'node:net';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import * as nodeFs from 'node:fs/promises';
import { devRunArgs } from './docker.js';
@@ -11,6 +12,9 @@ const POLL_MS = 1000;
const RETRY_MS = 60e3;
const UID = 1000;
const PREFIX = 'preview-dev-';
const LIVE_CHECK_MS = 10e3;
// I2: Stand der Startparameter (Variablen, Branch, Modus): Aenderung => Neustart
const cfgHash = (job) => createHash('sha256').update(JSON.stringify([job.branch, job.mode, Object.entries(job.env || {}).sort(([a], [b]) => (a < b ? -1 : 1))])).digest('hex');
const cacheName = (repo) => repo.replace('/', '__');
async function chownR(p) {
@@ -126,7 +130,14 @@ export function createDev({
async function ensure(sub, job) {
specs.set(sub, job);
const cur = entries.get(sub);
if (cur && cur.sha === job.sha) {
// I7: ein abgestuerzter/beendeter Container darf nicht als online gelten
if (cur && cur.state === 'online' && docker.running && now() - (cur.checkedAt || 0) >= LIVE_CHECK_MS) {
let alive = true;
try { alive = await docker.running(PREFIX + sub); } catch { /* unklar: als lebendig behandeln */ }
cur.checkedAt = now();
if (!alive && entries.get(sub) === cur && cur.state === 'online') { console.error('dev', sub, 'Container laeuft nicht mehr - Neustart'); await shutdown(cur, 'sleeping'); }
}
if (cur && cur.sha === job.sha && cur.cfg === cfgHash(job)) {
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' }; }
@@ -138,7 +149,7 @@ export function createDev({
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: jobs.retainCache(job.repo), promise: null, cancelled: false, created: false, stopping: null, failedAt: 0 };
const e = { sub, state: 'starting', sha: job.sha, cfg: cfgHash(job), checkedAt: now(), 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.allSettled(stops).then(() => start(e, job));
return { state: 'starting' };
@@ -147,9 +158,9 @@ export function createDev({
// R12: Spezifikation merken, ohne zu starten. Läuft der sub mit anderem sha, wird nach den üblichen Regeln neu gestartet.
async function register(sub, job) {
const cur = entries.get(sub);
if (cur && isRunning(cur) && cur.sha !== job.sha) return ensure(sub, job);
if (cur && isRunning(cur) && (cur.sha !== job.sha || cur.cfg !== cfgHash(job))) return ensure(sub, job);
specs.set(sub, job);
if (cur && cur.sha === job.sha && (cur.state === 'online' || cur.state === 'starting' || cur.state === 'failed')) return { state: cur.state };
if (cur && cur.sha === job.sha && cur.cfg === cfgHash(job) && (cur.state === 'online' || cur.state === 'starting' || cur.state === 'failed')) return { state: cur.state };
return { state: 'sleeping' };
}
// R12: Start mit der gemerkten Spezifikation (Proxy kennt nur die Subdomain).
@@ -159,6 +170,11 @@ export function createDev({
return ensure(sub, job);
}
// I7: Proxy meldet einen nicht erreichbaren Container: als schlafend markieren und entfernen (naechster Aufruf startet neu).
async function markDead(sub) {
const e = entries.get(sub);
if (e && e.state === 'online') await shutdown(e, 'sleeping');
}
const touch = (sub) => { const e = entries.get(sub); if (e) e.lastUsed = now(); };
async function stop(sub) {
specs.delete(sub); // 'off': Spezifikation vergessen, bis das Portal neu registriert
@@ -184,5 +200,5 @@ export function createDev({
}
const idle = () => Promise.all([...entries.values()].map((e) => e.promise)).then(() => {});
return { ensure, register, wake, touch, stop, list, status, sweep, cleanupOrphans, idle };
return { ensure, register, wake, touch, markDead, stop, list, status, sweep, cleanupOrphans, idle };
}
+7 -1
View File
@@ -177,10 +177,16 @@ export function createDocker({ exec = spawnDocker, baseEnv = process.env, envDir
return {
run,
async ps(label) {
const { code, lines } = await capture(['ps', '--filter', `label=${label}`, '--format', '{{.Names}}']);
const { code, lines } = await capture(['ps', '-a', '--filter', `label=${label}`, '--format', '{{.Names}}']);
if (code !== 0) throw new Error(`docker ps fehlgeschlagen (${code})`);
return lines.map((l) => l.trim()).filter((l) => NAME_RE.test(l));
},
// Laeuft der Container? (inspect; fehlt er oder ist beendet, dann false)
async running(name) {
checkName(name);
const { code, lines } = await capture(['inspect', '-f', '{{.State.Running}}', name]);
return code === 0 && lines.some((l) => l.trim() === 'true');
},
async rm(name) { checkName(name); return capture(['rm', '-f', name]); },
async stop(name) { checkName(name); return capture(['rm', '-f', name]); },
};
+6 -1
View File
@@ -139,7 +139,12 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem
await gitFn({ repo: job.repo, branch: job.branch, dir: workDir, onLine, timeoutMs: Math.max(1000, deadline - now()) });
const detected = detectFromDir(workDir, { mode: job.mode, index: job.index });
if (detected.error || detected.kind === 'static' || !detected.install?.length) return { workDir, detected };
const r = await containerStep({ job, id, step: 'install', cmd: detected.install, pm: detected.pm, workDir, cacheDir, onLine, deadline });
let r = await containerStep({ job, id, step: 'install', cmd: detected.install, pm: detected.pm, workDir, cacheDir, onLine, deadline });
// I8: `npm ci` scheitert bei nicht passender Lock-Datei; wie der bisherige Build-Weg auf `npm install` ausweichen.
if (r.code !== 0 && detected.pm === 'npm' && detected.install[1] === 'ci') {
onLine('npm ci fehlgeschlagen – Wiederholung mit npm install');
r = await containerStep({ job, id, step: 'install2', cmd: ['npm', 'install', '--include=dev', '--no-audit', '--no-fund'], pm: detected.pm, workDir, cacheDir, onLine, deadline });
}
if (r.code !== 0) return { workDir, detected, error: failText('Installation', r.code, config.buildTimeoutMs) };
return { workDir, detected };
}
+2 -1
View File
@@ -83,7 +83,8 @@ export function createProxy({ config, dev, request = http.request, upstreamTimeo
});
let timedOut = false;
up.setTimeout(upstreamTimeoutMs, () => { timedOut = true; up.destroy(); });
up.on('error', () => {
up.on('error', (err) => {
if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'EAI_AGAIN'].includes(err?.code)) dev.markDead?.(sub)?.catch?.(() => {});
if (!res.headersSent) {
const body = timedOut ? 'Die Preview antwortet nicht rechtzeitig (504).' : 'Die Preview ist gerade nicht erreichbar (502).';
res.writeHead(timedOut ? 504 : 502, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });