import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { performance } from 'node:perf_hooks'; const sleep = milliseconds => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); // Synchronous so the lifecycle lock remains held through startup/readiness. // Adapters also allow deterministic testing without real time or Docker. export function waitForHealth({ inspect, probe, timeoutSeconds = 120, stableSeconds = 10, now = () => performance.now(), pause = sleep, startedAtMs }) { if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || !Number.isSafeInteger(stableSeconds) || stableSeconds < 1 || stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed positive stability window'); const started = startedAtMs ?? now(), deadline = started + timeoutSeconds * 1000; let identity, restarts, startedAt, healthySince, lastState = null, lastProbe = null; const result = (ok, reason) => ({ ok, reason, elapsed_ms: Math.round(now() - started), timeout_seconds: timeoutSeconds, stable_seconds: stableSeconds, state: lastState, probe: lastProbe }); const budget = () => Math.max(1, Math.ceil(deadline - now())); try { while (now() < deadline) { const container = inspect(budget()); if (!container) return result(false, 'missing-container'); lastState = { id: container.Id, status: container.State?.Status, running: container.State?.Running, restarting: container.State?.Restarting, paused: container.State?.Paused, dead: container.State?.Dead, exit_code: container.State?.ExitCode, oom_killed: container.State?.OOMKilled, error: container.State?.Error, restart_count: container.RestartCount, started_at: container.State?.StartedAt, health: container.State?.Health?.Status }; if (identity === undefined) { identity = container.Id; restarts = container.RestartCount; startedAt = container.State?.StartedAt; } if (!identity || !Number.isSafeInteger(restarts) || restarts < 0) return result(false, 'invalid-container-state'); if (identity !== container.Id) return result(false, 'container-replaced'); if (container.State?.Restarting || container.RestartCount !== restarts || container.State?.StartedAt !== startedAt) return result(false, 'restart-loop'); if (!container.State?.Running || container.State?.Dead || ['exited', 'dead', 'removing'].includes(container.State?.Status)) return result(false, 'crash'); if (container.State?.Paused) return result(false, 'paused'); if (!container.State?.Health) return result(false, 'missing-healthcheck'); if (now() >= deadline) break; if (container.State.Health.Status === 'healthy') { lastProbe = probe(container.Id, budget()); if (now() >= deadline) break; if (lastProbe.status === 0) { healthySince ??= now(); if (now() - healthySince >= stableSeconds * 1000) return result(true, 'healthy'); } else healthySince = undefined; } else healthySince = undefined; pause(Math.min(1000, Math.max(0, deadline - now()))); } return result(false, 'timeout'); } catch (error) { lastProbe = { ...lastProbe, error: error.message }; return result(false, now() >= deadline ? 'timeout' : 'inspection-error'); } } export function saveHealthReport({ root, project, releaseIdentity, result, logs }) { const directory = path.join(root, 'diagnostics'); if (fs.existsSync(directory) && (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink())) throw new Error('Unsafe diagnostics directory'); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`; const reportPath = path.join(directory, `health-${id}.json`), logsPath = path.join(directory, `health-${id}.log`); let backup = null; try { backup = JSON.parse(fs.readFileSync(path.join(root, '.mcpack-backup-operation.json'), 'utf8')).snapshot ?? null; } catch { /* No prior backup. */ } const report = { schema_version: 1, created_at: new Date().toISOString(), project, release_manifest_sha256: releaseIdentity, ...result, recovery: { runtime_preserved: true, release_binding_preserved: true, active_release_changed: false, automatic_rollback: false, backup_snapshot: backup }, logs: logsPath }; fs.writeFileSync(logsPath, logs, { mode: 0o600, flag: 'wx' }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); return { reportPath, logsPath }; } export function healthOptions(args, defaultTimeout = 120) { args = args.flatMap(item => { const match = /^(--wait-timeout|--stable-seconds)=(.*)$/.exec(item); if (item.startsWith('--wait=')) throw new Error('Use --wait without a value'); return match ? [match[1], match[2]] : [item]; }); const command = args[0], wait = command === 'health' || args.includes('--wait'); let timeoutSeconds = defaultTimeout, stableSeconds = 10; const seen = new Set(), forwarded = [command === 'health' ? 'ps' : command]; for (let index = 1; index < args.length; index++) { const item = args[index]; if (['--wait', '--wait-timeout', '--stable-seconds'].includes(item)) { if (seen.has(item)) throw new Error(`Repeated health argument: ${item}`); seen.add(item); if (item === '--wait') continue; const value = args[++index]; if (!/^[1-9]\d*$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) throw new Error(`Invalid ${item}`); if (item === '--wait-timeout') timeoutSeconds = Number(value); else stableSeconds = Number(value); } else { if (command === 'health') throw new Error(`Unexpected health argument: ${item}`); forwarded.push(item); } } if (seen.size && !wait) throw new Error('Health options require --wait'); if (wait && !['up', 'start', 'restart', 'health'].includes(command)) throw new Error('--wait supports up/start/restart/health only'); if (wait && stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed stability window; use --stable-seconds'); if (wait && command === 'up' && (forwarded.includes('--abort-on-container-exit') || forwarded.includes('--abort-on-container-failure') || forwarded.includes('--attach') || forwarded.includes('--attach-dependencies') || forwarded.includes('--exit-code-from'))) throw new Error('Health startup requires detached Compose'); if (wait && command === 'up' && !forwarded.includes('-d') && !forwarded.includes('--detach')) forwarded.push('-d'); return { wait, timeoutSeconds, stableSeconds, forwarded }; }