1167 lines
35 KiB
JavaScript
1167 lines
35 KiB
JavaScript
/**
|
|
* Bare init daemon — lightweight service supervisor (systemd-like registration).
|
|
* Services start after the session console exists; failures are logged, not fatal.
|
|
* Drop-ins: `SocketActivationIpc=`, optional `IdleSec=` (with `stop`) for idle FIFO stop,
|
|
* `ReadinessPath=` (VFS or `exec:`), `ConditionPathExists=` / `AssertPathExists=`,
|
|
* `~/.config/bare-os/units.d/<name>/*.conf` fragments.
|
|
*/
|
|
|
|
import {
|
|
appendVarLog,
|
|
ensureBareOsVarLogTree,
|
|
INITD_LOG,
|
|
KERNEL_CONSOLE_LOG
|
|
} from '../vfs/bare-os-var-log.js'
|
|
import { appendBareInitdJournal } from './bare-initd-journal.js'
|
|
import {
|
|
BARE_INITD_DEFAULT_AFTER,
|
|
emptyUnitDropIn,
|
|
loadInitdUnitDropIns,
|
|
readInitdDisabledSet,
|
|
readInitdMaskedSet,
|
|
readUnitDropIn,
|
|
sortServicesForBoot
|
|
} from './bare-initd-user.js'
|
|
import { bareOsIpcLogicalToActualFifoName } from '../tools/bare-os-ipc-namespace.js'
|
|
|
|
/**
|
|
* @typedef {{
|
|
* name: string,
|
|
* description?: string,
|
|
* logPath?: string,
|
|
* start: (ctx: Record<string, unknown>) => void | Promise<void>,
|
|
* stop?: (ctx: Record<string, unknown>) => void | Promise<void>
|
|
* }} BareService
|
|
*/
|
|
|
|
/**
|
|
* Supervisor unit states (transition invariants documented in handbook).
|
|
* Runtime `phase` today maps: active→active, failed→failed, else→inactive.
|
|
*/
|
|
export const BARE_INITD_UNIT_STATES = Object.freeze([
|
|
'inactive',
|
|
'starting',
|
|
'active',
|
|
'stopping',
|
|
'skipped',
|
|
'failed',
|
|
'dead'
|
|
])
|
|
|
|
/** @typedef {{ phase: 'active'|'failed'|'inactive'|'starting'|'stopping'|'skipped'|'dead', startedAtMs: number, error?: string }} BareServiceRuntime */
|
|
|
|
/** @type {BareService[]} */
|
|
const registry = []
|
|
|
|
/** @type {Map<string, BareServiceRuntime>} */
|
|
const runtime = new Map()
|
|
|
|
/** @type {Map<string, ReturnType<typeof setInterval>>} */
|
|
const unitHealthTimers = new Map()
|
|
|
|
/** @type {(() => void)[]} */
|
|
const disposers = []
|
|
/** @type {Set<AbortController>} */
|
|
const socketLoopAbortControllers = new Set()
|
|
|
|
/** @type {(() => void | Promise<void>)[]} */
|
|
const kernelShutdownHooks = []
|
|
|
|
/** Last DAG snapshot JSON (set after {@link startBareInitd} completes ordering). */
|
|
let lastBareInitdDagJson = 'null\n'
|
|
|
|
/** Boot order used for suspend (reverse stop) and resume (forward start). */
|
|
let lastBareInitdBootOrder = []
|
|
|
|
/** Units stopped by {@link bareInitdSuspendForBareMobile} (resume in boot order). */
|
|
const bareInitdMobileSuspended = new Set()
|
|
|
|
let bareInitdMobileSuspendArmed = false
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} rel
|
|
*/
|
|
async function logicalPathExists(ctx, rel) {
|
|
const p = String(rel || '').trim()
|
|
if (!p) return false
|
|
const vfs = ctx.vfs
|
|
if (!vfs) return false
|
|
try {
|
|
if (typeof vfs.lstat === 'function') {
|
|
const st = await vfs.lstat(p)
|
|
if (st) return true
|
|
}
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
try {
|
|
const b = await vfs.readFile(p)
|
|
return !!(b && (b.length ?? 0) > 0)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} rel
|
|
*/
|
|
async function logicalPathIsDirectory(ctx, rel) {
|
|
const p = String(rel || '').trim()
|
|
if (!p) return false
|
|
const vfs = ctx.vfs
|
|
if (!vfs || typeof vfs.lstat !== 'function') return false
|
|
try {
|
|
const st = await vfs.lstat(p)
|
|
return !!(st && typeof st === 'object' && st.isDirectory())
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {import('./bare-initd-user.js').BareInitdUnitDropIn} di
|
|
* @returns {Promise<'ok'|'skip'>}
|
|
*/
|
|
async function evaluateUnitPathConditions(ctx, di) {
|
|
const maxLen = 512
|
|
const ass = di.assertPathExists?.trim()
|
|
if (ass) {
|
|
if (ass.length > maxLen)
|
|
throw new Error('AssertPathExists path too long (max 512)')
|
|
if (!(await logicalPathExists(ctx, ass))) {
|
|
throw new Error('AssertPathExists not satisfied: ' + ass)
|
|
}
|
|
}
|
|
const cond = di.conditionPathExists?.trim()
|
|
if (cond) {
|
|
if (cond.length > maxLen) return 'ok'
|
|
if (!(await logicalPathExists(ctx, cond))) return 'skip'
|
|
}
|
|
const assDir = di.assertPathIsDirectory?.trim()
|
|
if (assDir) {
|
|
if (assDir.length > maxLen)
|
|
throw new Error('AssertPathIsDirectory path too long (max 512)')
|
|
if (!(await logicalPathIsDirectory(ctx, assDir))) {
|
|
throw new Error('AssertPathIsDirectory not satisfied: ' + assDir)
|
|
}
|
|
}
|
|
const condDir = di.conditionPathIsDirectory?.trim()
|
|
if (condDir) {
|
|
if (condDir.length > maxLen) return 'ok'
|
|
if (!(await logicalPathIsDirectory(ctx, condDir))) return 'skip'
|
|
}
|
|
return 'ok'
|
|
}
|
|
|
|
/** Kernel logger: restore console on stop/restart */
|
|
let kernelLoggerWrapped = false
|
|
/** @type {((...args: unknown[]) => void) | null} */
|
|
let kernelLoggerOrigLog = null
|
|
/** @type {((...args: unknown[]) => void) | null} */
|
|
let kernelLoggerOrigErr = null
|
|
|
|
/**
|
|
* Register cleanup (e.g. clearInterval). Invoked on every {@link stopBareInitd}
|
|
* (e.g. each REPL session teardown); callbacks should be idempotent.
|
|
* @param {() => void} fn
|
|
*/
|
|
export function registerBareInitdDisposer(fn) {
|
|
if (typeof fn === 'function') disposers.push(fn)
|
|
}
|
|
|
|
/**
|
|
* Register a function to run when the REPL/kernel session ends (before initd disposers).
|
|
* Use for async teardown (flush buffers, close handles). Errors are swallowed.
|
|
* @param {() => void | Promise<void>} fn
|
|
*/
|
|
export function registerKernelShutdownHook(fn) {
|
|
if (typeof fn === 'function') kernelShutdownHooks.push(fn)
|
|
}
|
|
|
|
/**
|
|
* Runs shutdown hooks in reverse registration order (LIFO), then clears the queue.
|
|
*/
|
|
export async function runKernelShutdownHooks() {
|
|
while (kernelShutdownHooks.length) {
|
|
const fn = kernelShutdownHooks.pop()
|
|
try {
|
|
await fn()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop every active unit that defines `stop`, in reverse boot-DAG order (like mobile suspend).
|
|
* Swallows per-unit errors; clears health timers via {@link stopBareService}.
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
export async function bareInitdShutdownActiveUnitsReverse(ctx) {
|
|
const order =
|
|
lastBareInitdBootOrder.length > 0
|
|
? [...lastBareInitdBootOrder]
|
|
: [...runtime.entries()]
|
|
.filter(([, rt]) => rt.phase === 'active')
|
|
.map(([n]) => n)
|
|
for (const name of [...order].reverse()) {
|
|
const rt = runtime.get(name)
|
|
if (!rt || rt.phase !== 'active') continue
|
|
const s = findBareServiceDefinition(name)
|
|
if (s && typeof s.stop === 'function') {
|
|
try {
|
|
await stopBareService(ctx, name)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function stopBareInitd() {
|
|
for (const ac of socketLoopAbortControllers) {
|
|
try {
|
|
ac.abort()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
socketLoopAbortControllers.clear()
|
|
for (const t of unitHealthTimers.values()) clearInterval(t)
|
|
unitHealthTimers.clear()
|
|
for (const fn of disposers) {
|
|
try {
|
|
fn()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {BareService} service
|
|
*/
|
|
export function registerBareService(service) {
|
|
if (!service?.name || typeof service.start !== 'function') return
|
|
const i = registry.findIndex((s) => s.name === service.name)
|
|
if (i >= 0) registry[i] = service
|
|
else registry.push(service)
|
|
}
|
|
|
|
/** Remove a unit from the registry and drop its runtime row (does not call stop). */
|
|
export function unregisterBareService(name) {
|
|
const n = String(name || '')
|
|
const i = registry.findIndex((s) => s.name === n)
|
|
if (i >= 0) registry.splice(i, 1)
|
|
runtime.delete(n)
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @returns {BareService | undefined}
|
|
*/
|
|
export function findBareServiceDefinition(name) {
|
|
return registry.find((s) => s.name === name)
|
|
}
|
|
|
|
/**
|
|
* @returns {{ name: string, description: string, logPath?: string, hasStop: boolean }[]}
|
|
*/
|
|
export function listBareServices() {
|
|
return registry.map((s) => ({
|
|
name: s.name,
|
|
description: s.description || '',
|
|
logPath: s.logPath,
|
|
hasStop: typeof s.stop === 'function'
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @returns {BareServiceRuntime | undefined}
|
|
*/
|
|
export function getBareServiceRuntime(name) {
|
|
return runtime.get(name)
|
|
}
|
|
|
|
/**
|
|
* Live initd unit phases for readiness / observability (mirrors supervisor runtime map).
|
|
* @returns {{ schema: 1, units: { name: string, phase: string, startedAtMs: number, error?: string }[], note: string, atMs: number }}
|
|
*/
|
|
export function bareInitdReadinessSnapshot() {
|
|
const now = Date.now()
|
|
/** @type {{ name: string, phase: string, startedAtMs: number, error?: string }[]} */
|
|
const units = []
|
|
for (const [name, rt] of runtime) {
|
|
const row = {
|
|
name,
|
|
phase: rt.phase,
|
|
startedAtMs: rt.startedAtMs
|
|
}
|
|
if (rt.error) row.error = rt.error
|
|
units.push(row)
|
|
}
|
|
units.sort((a, b) => a.name.localeCompare(b.name))
|
|
return {
|
|
schema: 2,
|
|
units,
|
|
supervisionTelemetry: {
|
|
schema: 1,
|
|
restartJournalEvents: [
|
|
'start_scheduled',
|
|
'restart_attempt',
|
|
'start_error',
|
|
'failed_final',
|
|
'on_failure_ran',
|
|
'active'
|
|
],
|
|
journalPathPattern: '/run/bare-os/unit-journal/*.ndjson',
|
|
note: 'restart_attempt rows include backoffMsScheduled and restartPolicy when Restart= is set.'
|
|
},
|
|
note: 'Supervisor phases; per-unit file readiness uses ReadinessPath= in unit drop-ins when set.',
|
|
atMs: now
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {string} JSON string for `/proc/bare_os/initd_dag.json` (or `null` line if unavailable).
|
|
*/
|
|
export function getLastBareInitdDagSnapshotJson() {
|
|
return lastBareInitdDagJson
|
|
}
|
|
|
|
/**
|
|
* Stop active initd units that expose `stop` (for Bare suspend / mobile background).
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
export async function bareInitdSuspendForBareMobile(ctx) {
|
|
if (bareInitdMobileSuspendArmed) return
|
|
bareInitdMobileSuspendArmed = true
|
|
const order =
|
|
lastBareInitdBootOrder.length > 0
|
|
? [...lastBareInitdBootOrder]
|
|
: [...runtime.entries()]
|
|
.filter(([, rt]) => rt.phase === 'active')
|
|
.map(([n]) => n)
|
|
for (const name of [...order].reverse()) {
|
|
const rt = runtime.get(name)
|
|
if (!rt || rt.phase !== 'active') continue
|
|
const s = findBareServiceDefinition(name)
|
|
if (s && typeof s.stop === 'function') {
|
|
try {
|
|
await stopBareService(ctx, name)
|
|
bareInitdMobileSuspended.add(name)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restart units stopped by {@link bareInitdSuspendForBareMobile}.
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
export async function bareInitdResumeAfterBareMobile(ctx) {
|
|
const order =
|
|
lastBareInitdBootOrder.length > 0
|
|
? [...lastBareInitdBootOrder]
|
|
: [...bareInitdMobileSuspended]
|
|
for (const name of order) {
|
|
if (!bareInitdMobileSuspended.has(name)) continue
|
|
bareInitdMobileSuspended.delete(name)
|
|
try {
|
|
await startBareService(ctx, name)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
bareInitdMobileSuspendArmed = false
|
|
}
|
|
|
|
/**
|
|
* Poll until all listed units are `active` or `timeoutMs` elapses.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string[]} names
|
|
* @param {number} [timeoutMs]
|
|
* @returns {Promise<boolean>} true if every unit became active
|
|
*/
|
|
export async function waitForBareInitdUnits(ctx, names, timeoutMs = 60000) {
|
|
void ctx
|
|
const need = [...names].filter(Boolean)
|
|
if (!need.length) return true
|
|
const ms = Number(timeoutMs) > 0 ? timeoutMs : 60000
|
|
const deadline = Date.now() + ms
|
|
while (Date.now() < deadline) {
|
|
let ok = true
|
|
for (const n of need) {
|
|
const rt = runtime.get(n)
|
|
if (!rt || rt.phase !== 'active') {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if (ok) return true
|
|
await new Promise((r) => setTimeout(r, 25))
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
export async function startBareService(ctx, name) {
|
|
const s = findBareServiceDefinition(name)
|
|
if (!s) throw new Error(`Unknown unit: ${name}`)
|
|
const vfs = ctx.vfs
|
|
if (vfs && typeof vfs.readFile === 'function') {
|
|
const masked = await readInitdMaskedSet(vfs)
|
|
if (masked.has(name)) throw new Error(`Unit is masked: ${name}`)
|
|
}
|
|
const rt = runtime.get(name)
|
|
if (rt?.phase === 'active') {
|
|
return { noop: true, message: `${name} is already active` }
|
|
}
|
|
const di =
|
|
vfs && typeof vfs.readFile === 'function'
|
|
? await readUnitDropIn(vfs, name)
|
|
: emptyUnitDropIn()
|
|
const startSec = di.timeoutStartSec
|
|
const t0 = Date.now()
|
|
try {
|
|
const cond = await evaluateUnitPathConditions(ctx, di)
|
|
if (cond === 'skip') {
|
|
runtime.set(name, { phase: 'skipped', startedAtMs: t0 })
|
|
return { skipped: true }
|
|
}
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
runtime.set(name, { phase: 'failed', startedAtMs: t0, error: msg })
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, msg)
|
|
throw e
|
|
}
|
|
try {
|
|
await withTimeoutSec(s.start(ctx), startSec, `start ${name}`)
|
|
if (di.readinessPath) {
|
|
const rsec = di.readinessTimeoutSec ?? 30
|
|
await waitForReadinessPath(ctx, di.readinessPath, rsec)
|
|
}
|
|
runtime.set(name, { phase: 'active', startedAtMs: t0 })
|
|
const post = di.execStartPost
|
|
if (post && typeof ctx.execLine === 'function' && post.trim()) {
|
|
try {
|
|
await ctx.execLine(post.trim())
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name} ExecStartPost: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, 'ExecStartPost: ' + msg)
|
|
}
|
|
}
|
|
return { ok: true }
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
runtime.set(name, { phase: 'failed', startedAtMs: t0, error: msg })
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, msg)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Promise<void>} promise
|
|
* @param {number | null | undefined} sec
|
|
* @param {string} label
|
|
*/
|
|
async function withTimeoutSec(promise, sec, label) {
|
|
if (sec == null || !Number.isFinite(sec) || sec <= 0) return promise
|
|
const ms = Math.round(sec * 1000)
|
|
return await new Promise((resolve, reject) => {
|
|
const t = setTimeout(() => {
|
|
reject(new Error(`${label} timed out after ${sec}s`))
|
|
}, ms)
|
|
Promise.resolve(promise).then(
|
|
() => {
|
|
clearTimeout(t)
|
|
resolve(undefined)
|
|
},
|
|
(e) => {
|
|
clearTimeout(t)
|
|
reject(e)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Poll VFS until `path` exists or timeout.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} logicalPath
|
|
* @param {number} timeoutSec
|
|
*/
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} name
|
|
* @param {import('./bare-initd-user.js').BareInitdUnitDropIn} dropIn
|
|
*/
|
|
function scheduleUnitHealth(ctx, name, dropIn) {
|
|
if (!dropIn.execHealthCmd || !dropIn.healthIntervalSec) return
|
|
if (typeof ctx.execLine !== 'function') return
|
|
const intervalMs = Math.max(5, dropIn.healthIntervalSec) * 1000
|
|
const threshold = Math.max(1, dropIn.healthFailureThreshold ?? 3)
|
|
let fails = 0
|
|
const t = setInterval(() => {
|
|
void (async () => {
|
|
const rt = runtime.get(name)
|
|
if (!rt || rt.phase !== 'active') return
|
|
const env = ctx.vfs?.env
|
|
const saved = env ? env.BARE_OS_EXEC_MAX_DEPTH : undefined
|
|
if (dropIn.bareMaxExecDepth != null && env) {
|
|
env.BARE_OS_EXEC_MAX_DEPTH = String(dropIn.bareMaxExecDepth)
|
|
}
|
|
try {
|
|
await ctx.execLine(dropIn.execHealthCmd.trim())
|
|
if ((Number(ctx.exitCode) || 0) === 0) fails = 0
|
|
else fails++
|
|
} catch {
|
|
fails++
|
|
} finally {
|
|
if (env && saved !== undefined) env.BARE_OS_EXEC_MAX_DEPTH = saved
|
|
}
|
|
if (fails >= threshold) {
|
|
clearInterval(t)
|
|
unitHealthTimers.delete(name)
|
|
const msg = 'health check failed'
|
|
runtime.set(name, {
|
|
phase: 'failed',
|
|
startedAtMs: Date.now(),
|
|
error: msg
|
|
})
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, msg)
|
|
appendBareInitdJournal(name, { event: 'health_failed', error: msg })
|
|
}
|
|
})()
|
|
}, intervalMs)
|
|
unitHealthTimers.set(name, t)
|
|
}
|
|
|
|
async function waitForReadinessPath(ctx, logicalPath, timeoutSec) {
|
|
const raw = String(logicalPath || '').trim()
|
|
if (raw.toLowerCase().startsWith('exec:')) {
|
|
const cmd = raw.slice(5).trim()
|
|
if (!cmd || typeof ctx.execLine !== 'function') return
|
|
const sec = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 30
|
|
await withTimeoutSec(ctx.execLine(cmd), sec, 'readiness exec')
|
|
if ((Number(ctx.exitCode) || 0) !== 0) {
|
|
throw new Error(
|
|
`readiness exec failed (exit ${ctx.exitCode}): ${cmd.slice(0, 200)}`
|
|
)
|
|
}
|
|
return
|
|
}
|
|
const vfs = ctx.vfs
|
|
if (!vfs || typeof vfs.exists !== 'function') return
|
|
const sec = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 30
|
|
const deadline = Date.now() + Math.round(sec * 1000)
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if (await vfs.exists(logicalPath)) return
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
throw new Error(`readiness path not ready: ${logicalPath} (${sec}s)`)
|
|
}
|
|
|
|
export async function stopBareService(ctx, name) {
|
|
const s = findBareServiceDefinition(name)
|
|
if (!s) throw new Error(`Unknown unit: ${name}`)
|
|
const ht = unitHealthTimers.get(name)
|
|
if (ht) {
|
|
clearInterval(ht)
|
|
unitHealthTimers.delete(name)
|
|
}
|
|
if (typeof s.stop !== 'function') {
|
|
throw new Error(`Unit ${name} does not support stop (no stop handler)`)
|
|
}
|
|
const vfs = ctx.vfs
|
|
let stopSec = null
|
|
if (vfs && typeof vfs.readFile === 'function') {
|
|
const di = await readUnitDropIn(vfs, name)
|
|
stopSec = di.timeoutStopSec
|
|
}
|
|
await withTimeoutSec(s.stop(ctx), stopSec, `stop ${name}`)
|
|
const prev = runtime.get(name)
|
|
runtime.set(name, {
|
|
phase: 'inactive',
|
|
startedAtMs: prev?.startedAtMs ?? Date.now()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} name
|
|
*/
|
|
export async function restartBareService(ctx, name) {
|
|
const s = findBareServiceDefinition(name)
|
|
if (!s) throw new Error(`Unknown unit: ${name}`)
|
|
if (typeof s.stop !== 'function') {
|
|
throw new Error(
|
|
`Unit ${name} does not support restart (no stop handler; session teardown only)`
|
|
)
|
|
}
|
|
await stopBareService(ctx, name)
|
|
const vfs = ctx.vfs
|
|
const di =
|
|
vfs && typeof vfs.readFile === 'function'
|
|
? await readUnitDropIn(vfs, name)
|
|
: emptyUnitDropIn()
|
|
const startSec = di.timeoutStartSec
|
|
const t0 = Date.now()
|
|
try {
|
|
const pathEv = await evaluateUnitPathConditions(ctx, di)
|
|
if (pathEv === 'skip') {
|
|
runtime.set(name, { phase: 'skipped', startedAtMs: t0 })
|
|
return
|
|
}
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
runtime.set(name, { phase: 'failed', startedAtMs: t0, error: msg })
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, msg)
|
|
throw e
|
|
}
|
|
try {
|
|
await withTimeoutSec(s.start(ctx), startSec, `start ${name}`)
|
|
runtime.set(name, { phase: 'active', startedAtMs: t0 })
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
runtime.set(name, { phase: 'failed', startedAtMs: t0, error: msg })
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, name, msg)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {BareService} s
|
|
* @param {import('./bare-initd-user.js').BareInitdUnitDropIn} dropIn
|
|
*/
|
|
async function runOnFailureHookForUnit(ctx, s, dropIn) {
|
|
if (!dropIn.onFailure?.trim() || dropIn.failureAction === 'none') return
|
|
if (typeof ctx.execLine !== 'function') return
|
|
try {
|
|
await ctx.execLine(dropIn.onFailure.trim())
|
|
appendBareInitdJournal(s.name, { event: 'on_failure_ran' })
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${s.name} OnFailure: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {BareService} s
|
|
* @param {import('./bare-initd-user.js').BareInitdUnitDropIn} dropIn
|
|
*/
|
|
async function startNormalBareInitdUnit(ctx, s, dropIn) {
|
|
const t0 = Date.now()
|
|
const startSec = dropIn.timeoutStartSec
|
|
let defaultRestartAttempts = 3
|
|
const envD = ctx.env?.BARE_OS_INITD_RESTART_MAX_DEFAULT
|
|
if (envD) {
|
|
const n = Number.parseInt(String(envD), 10)
|
|
if (Number.isFinite(n) && n >= 1 && n <= 32) defaultRestartAttempts = n
|
|
}
|
|
const maxAttempts =
|
|
dropIn.restartMaxAttempts != null && dropIn.restartMaxAttempts > 0
|
|
? Math.min(Math.floor(dropIn.restartMaxAttempts), 32)
|
|
: dropIn.restart === 'on-failure' || dropIn.restart === 'always'
|
|
? defaultRestartAttempts
|
|
: 1
|
|
const restartDelayMs =
|
|
dropIn.restartSec != null && dropIn.restartSec >= 0
|
|
? Math.round(dropIn.restartSec * 1000)
|
|
: 1000
|
|
appendBareInitdJournal(s.name, { event: 'start_scheduled' })
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
if (attempt > 0) {
|
|
const backoffMs = Math.min(
|
|
120000,
|
|
Math.round(restartDelayMs * Math.pow(2, attempt - 1))
|
|
)
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'restart_attempt',
|
|
attempt,
|
|
backoffMsScheduled: backoffMs,
|
|
restartPolicy: dropIn.restart,
|
|
restartMaxAttempts: maxAttempts,
|
|
telemetrySchema: 1
|
|
})
|
|
await new Promise((r) => setTimeout(r, backoffMs))
|
|
}
|
|
const pathEv = await evaluateUnitPathConditions(ctx, dropIn)
|
|
if (pathEv === 'skip') {
|
|
runtime.set(s.name, {
|
|
phase: 'skipped',
|
|
startedAtMs: Date.now()
|
|
})
|
|
appendBareInitdJournal(s.name, { event: 'skipped_condition_path' })
|
|
return
|
|
}
|
|
await withTimeoutSec(s.start(ctx), startSec, `start ${s.name}`)
|
|
if (dropIn.readinessPath) {
|
|
const rsec = dropIn.readinessTimeoutSec ?? 30
|
|
await waitForReadinessPath(ctx, dropIn.readinessPath, rsec)
|
|
}
|
|
runtime.set(s.name, { phase: 'active', startedAtMs: t0 })
|
|
appendBareInitdJournal(s.name, { event: 'active', attempt })
|
|
const post = dropIn.execStartPost
|
|
if (post && typeof ctx.execLine === 'function' && post.trim()) {
|
|
try {
|
|
await ctx.execLine(post.trim())
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
try {
|
|
ctx.console?.error?.(
|
|
`[bare-initd] ${s.name} ExecStartPost: ${msg}`
|
|
)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, s.name, 'ExecStartPost: ' + msg)
|
|
}
|
|
}
|
|
scheduleUnitHealth(ctx, s.name, dropIn)
|
|
return
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'start_error',
|
|
attempt,
|
|
error: msg
|
|
})
|
|
if (attempt === maxAttempts - 1) {
|
|
runtime.set(s.name, { phase: 'failed', startedAtMs: t0, error: msg })
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${s.name}: ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, s.name, msg)
|
|
await runOnFailureHookForUnit(ctx, s, dropIn)
|
|
appendBareInitdJournal(s.name, { event: 'failed_final', error: msg })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
export async function startBareInitd(ctx) {
|
|
runtime.clear()
|
|
await ensureBareOsVarLogTree(ctx)
|
|
const vfs = ctx.vfs
|
|
/** @type {Map<string, import('./bare-initd-user.js').BareInitdUnitDropIn>} */
|
|
let dropInsMap = new Map()
|
|
let ordered = registry
|
|
if (vfs && typeof vfs.readFile === 'function') {
|
|
const disabled = await readInitdDisabledSet(vfs)
|
|
const masked = await readInitdMaskedSet(vfs)
|
|
const disabledForBoot = new Set([...disabled, ...masked])
|
|
dropInsMap = await loadInitdUnitDropIns(
|
|
vfs,
|
|
registry,
|
|
BARE_INITD_DEFAULT_AFTER
|
|
)
|
|
/** @type {Map<string, string[]>} */
|
|
const requiresMap = new Map()
|
|
/** @type {Map<string, string[]>} */
|
|
const wantsMap = new Map()
|
|
/** @type {Map<string, string[]>} */
|
|
const afterMap = new Map()
|
|
const nameSet = new Set(registry.map((s) => s.name))
|
|
for (const [name, di] of dropInsMap) {
|
|
afterMap.set(name, [...(di.after || [])])
|
|
requiresMap.set(name, di.requires)
|
|
wantsMap.set(name, di.wants)
|
|
}
|
|
for (const [name, di] of dropInsMap) {
|
|
for (const b of di.before || []) {
|
|
if (!nameSet.has(b)) continue
|
|
const cur = afterMap.get(b) || []
|
|
if (!cur.includes(name)) cur.push(name)
|
|
afterMap.set(b, cur)
|
|
}
|
|
}
|
|
ordered = sortServicesForBoot(
|
|
registry,
|
|
disabledForBoot,
|
|
afterMap,
|
|
requiresMap,
|
|
wantsMap
|
|
)
|
|
}
|
|
lastBareInitdBootOrder = ordered.map((s) => s.name)
|
|
|
|
const rawPar = ctx.env && ctx.env.BARE_OS_INITD_MAX_PARALLEL
|
|
const maxP = Math.max(
|
|
1,
|
|
Math.min(32, Number.parseInt(String(rawPar ?? '1'), 10) || 1)
|
|
)
|
|
|
|
const activeNames = new Set(ordered.map((s) => s.name))
|
|
/** @type {Map<string, Set<string>>} */
|
|
const prereq = new Map()
|
|
for (const s of ordered) {
|
|
const di = dropInsMap.get(s.name) || emptyUnitDropIn()
|
|
const inc = new Set()
|
|
for (const a of di.after) if (activeNames.has(a)) inc.add(a)
|
|
for (const r of di.requires) if (activeNames.has(r)) inc.add(r)
|
|
for (const w of di.wants) if (activeNames.has(w)) inc.add(w)
|
|
prereq.set(s.name, inc)
|
|
}
|
|
for (const s of ordered) {
|
|
const di = dropInsMap.get(s.name) || emptyUnitDropIn()
|
|
for (const b of di.before || []) {
|
|
if (!activeNames.has(b)) continue
|
|
const inc = prereq.get(b) || new Set()
|
|
inc.add(s.name)
|
|
prereq.set(b, inc)
|
|
}
|
|
}
|
|
|
|
/** @param {string[]} names @returns {string[][]} */
|
|
function computeLevels(names) {
|
|
const remaining = new Set(names)
|
|
/** @type {string[][]} */
|
|
const levels = []
|
|
while (remaining.size) {
|
|
const ready = [...remaining].filter((n) => {
|
|
for (const p of prereq.get(n) || []) {
|
|
if (remaining.has(p)) return false
|
|
}
|
|
return true
|
|
})
|
|
if (!ready.length) {
|
|
const n = [...remaining].sort()[0]
|
|
levels.push([n])
|
|
remaining.delete(n)
|
|
continue
|
|
}
|
|
ready.sort()
|
|
levels.push(ready)
|
|
for (const n of ready) remaining.delete(n)
|
|
}
|
|
return levels
|
|
}
|
|
|
|
const levels = computeLevels(ordered.map((s) => s.name))
|
|
|
|
try {
|
|
const nodes = ordered.map((s) => s.name)
|
|
/** @type {[string, string][]} */
|
|
const edges = []
|
|
for (const name of nodes) {
|
|
for (const p of prereq.get(name) || []) {
|
|
edges.push([p, name])
|
|
}
|
|
}
|
|
const escDot = (s) =>
|
|
String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
const dot =
|
|
'digraph bare_initd {\n' +
|
|
nodes.map((n) => ` "${escDot(n)}";\n`).join('') +
|
|
edges.map(([a, b]) => ` "${escDot(a)}" -> "${escDot(b)}";\n`).join('') +
|
|
'}\n'
|
|
lastBareInitdDagJson = `${JSON.stringify({
|
|
schema: 1,
|
|
nodes,
|
|
levels,
|
|
edges,
|
|
dot,
|
|
supervision: {
|
|
schema: 1,
|
|
restartKeys: [
|
|
'Restart',
|
|
'RestartSec',
|
|
'RestartMaxAttempts',
|
|
'OnFailure'
|
|
],
|
|
reference: 'packages/bare-os-booter/lib/initd/bare-initd-user.js',
|
|
note: 'systemd-inspired subset parsed from unit drop-ins; backoff is host/runtime specific.'
|
|
},
|
|
atMs: Date.now()
|
|
})}\n`
|
|
} catch {
|
|
lastBareInitdDagJson = 'null\n'
|
|
}
|
|
|
|
for (const level of levels) {
|
|
/** @type {{ s: BareService, di: import('./bare-initd-user.js').BareInitdUnitDropIn }[]} */
|
|
const sockets = []
|
|
/** @type {{ s: BareService, di: import('./bare-initd-user.js').BareInitdUnitDropIn }[]} */
|
|
const normals = []
|
|
for (const name of level) {
|
|
const s = registry.find((x) => x.name === name)
|
|
if (!s) continue
|
|
const di = dropInsMap.get(s.name) || emptyUnitDropIn()
|
|
if (di.socketActivationIpc && ctx.bareOsIpc) sockets.push({ s, di })
|
|
else normals.push({ s, di })
|
|
}
|
|
|
|
for (const { s, di } of sockets) {
|
|
const t0 = Date.now()
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
|
: {}
|
|
const ipcActual = bareOsIpcLogicalToActualFifoName(
|
|
di.socketActivationIpc,
|
|
env
|
|
)
|
|
try {
|
|
ctx.bareOsIpc.create(ipcActual)
|
|
} catch {
|
|
/* exists */
|
|
}
|
|
const ipcName = ipcActual
|
|
const startSec = di.timeoutStartSec
|
|
const idleSec =
|
|
di.idleSec != null && di.idleSec > 0 ? Math.min(di.idleSec, 86400) : 0
|
|
const idleStopOk = idleSec > 0 && typeof s.stop === 'function'
|
|
void (async () => {
|
|
for (;;) {
|
|
appendBareInitdJournal(s.name, { event: 'socket_wait' })
|
|
runtime.set(s.name, { phase: 'inactive', startedAtMs: Date.now() })
|
|
const outerWaitAc = new AbortController()
|
|
socketLoopAbortControllers.add(outerWaitAc)
|
|
try {
|
|
await ctx.bareOsIpc.take(ipcName, { signal: outerWaitAc.signal })
|
|
} catch (e) {
|
|
socketLoopAbortControllers.delete(outerWaitAc)
|
|
if (
|
|
e &&
|
|
typeof e === 'object' &&
|
|
/** @type {Error} */ (e).name === 'AbortError'
|
|
) {
|
|
runtime.set(s.name, {
|
|
phase: 'inactive',
|
|
startedAtMs: Date.now()
|
|
})
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'socket_loop_stopped'
|
|
})
|
|
return
|
|
}
|
|
const msg = e?.message || String(e)
|
|
runtime.set(s.name, {
|
|
phase: 'failed',
|
|
startedAtMs: t0,
|
|
error: msg
|
|
})
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${s.name} (socket): ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, s.name, msg)
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'failed_socket',
|
|
error: msg
|
|
})
|
|
await runOnFailureHookForUnit(ctx, s, di)
|
|
return
|
|
} finally {
|
|
socketLoopAbortControllers.delete(outerWaitAc)
|
|
}
|
|
const t1 = Date.now()
|
|
try {
|
|
const pe = await evaluateUnitPathConditions(ctx, di)
|
|
if (pe === 'skip') {
|
|
runtime.set(s.name, { phase: 'skipped', startedAtMs: t1 })
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'skipped_condition_path_socket'
|
|
})
|
|
continue
|
|
}
|
|
await withTimeoutSec(s.start(ctx), startSec, `start ${s.name}`)
|
|
if (di.readinessPath) {
|
|
const rsec = di.readinessTimeoutSec ?? 30
|
|
await waitForReadinessPath(ctx, di.readinessPath, rsec)
|
|
}
|
|
runtime.set(s.name, { phase: 'active', startedAtMs: t1 })
|
|
appendBareInitdJournal(s.name, { event: 'active', socket: true })
|
|
const post = di.execStartPost
|
|
if (post && typeof ctx.execLine === 'function' && post.trim()) {
|
|
try {
|
|
await ctx.execLine(post.trim())
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
try {
|
|
ctx.console?.error?.(
|
|
`[bare-initd] ${s.name} ExecStartPost: ${msg}`
|
|
)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(
|
|
ctx,
|
|
INITD_LOG,
|
|
s.name,
|
|
'ExecStartPost: ' + msg
|
|
)
|
|
}
|
|
}
|
|
scheduleUnitHealth(ctx, s.name, di)
|
|
if (!idleStopOk) return
|
|
while (runtime.get(s.name)?.phase === 'active') {
|
|
const ac = new AbortController()
|
|
socketLoopAbortControllers.add(ac)
|
|
const takeP = ctx.bareOsIpc.take(ipcName, { signal: ac.signal })
|
|
const idleMs = idleSec * 1000
|
|
const race = await Promise.race([
|
|
takeP.then(() => 'ipc'),
|
|
new Promise((r) => setTimeout(() => r('idle'), idleMs))
|
|
])
|
|
if (race === 'idle') {
|
|
ac.abort()
|
|
socketLoopAbortControllers.delete(ac)
|
|
try {
|
|
await takeP
|
|
} catch {
|
|
/* AbortError */
|
|
}
|
|
try {
|
|
await stopBareService(ctx, s.name)
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
try {
|
|
ctx.console?.error?.(
|
|
`[bare-initd] ${s.name} idle stop: ${msg}`
|
|
)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'idle_stop',
|
|
socket: true
|
|
})
|
|
break
|
|
}
|
|
socketLoopAbortControllers.delete(ac)
|
|
}
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
runtime.set(s.name, {
|
|
phase: 'failed',
|
|
startedAtMs: t0,
|
|
error: msg
|
|
})
|
|
try {
|
|
ctx.console?.error?.(`[bare-initd] ${s.name} (socket): ${msg}`)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
void appendVarLog(ctx, INITD_LOG, s.name, msg)
|
|
appendBareInitdJournal(s.name, {
|
|
event: 'failed_socket',
|
|
error: msg
|
|
})
|
|
await runOnFailureHookForUnit(ctx, s, di)
|
|
return
|
|
}
|
|
}
|
|
})()
|
|
}
|
|
|
|
for (let i = 0; i < normals.length; i += maxP) {
|
|
const chunk = normals.slice(i, i + maxP)
|
|
await Promise.all(
|
|
chunk.map(({ s, di }) => startNormalBareInitdUnit(ctx, s, di))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopKernelLogger(ctx) {
|
|
const c = ctx.console
|
|
if (!kernelLoggerWrapped || !c) return
|
|
if (kernelLoggerOrigLog) c.log = kernelLoggerOrigLog
|
|
if (kernelLoggerOrigErr) c.error = kernelLoggerOrigErr
|
|
kernelLoggerWrapped = false
|
|
kernelLoggerOrigLog = null
|
|
kernelLoggerOrigErr = null
|
|
}
|
|
|
|
function startKernelLogger(ctx) {
|
|
const c = ctx.console
|
|
if (!c || typeof c.log !== 'function') return
|
|
if (kernelLoggerWrapped) return
|
|
|
|
kernelLoggerOrigLog = c.log.bind(c)
|
|
kernelLoggerOrigErr =
|
|
typeof c.error === 'function' ? c.error.bind(c) : kernelLoggerOrigLog
|
|
|
|
c.log = (...args) => {
|
|
const text = args.map(String).join(' ')
|
|
void appendVarLog(ctx, KERNEL_CONSOLE_LOG, 'log', text)
|
|
return kernelLoggerOrigLog(...args)
|
|
}
|
|
c.error = (...args) => {
|
|
const text = args.map(String).join(' ')
|
|
void appendVarLog(ctx, KERNEL_CONSOLE_LOG, 'error', text)
|
|
return kernelLoggerOrigErr(...args)
|
|
}
|
|
kernelLoggerWrapped = true
|
|
}
|
|
|
|
function registerKernelLoggerService() {
|
|
registerBareService({
|
|
name: 'kernel-logger',
|
|
description:
|
|
'Mirrors console.log / console.error to /var/log/bare-os/kernel-console.log',
|
|
logPath: KERNEL_CONSOLE_LOG,
|
|
start: startKernelLogger,
|
|
stop: stopKernelLogger
|
|
})
|
|
}
|
|
|
|
registerKernelLoggerService()
|