Files
bare-operating-system/packages/bare-os-booter/lib/bare-cron.js
T
Raven Scott de176addd9 feat(bare-os): kernel wave 3 — bits3, seed RPCs, policy, proc, CI schemas
Add third capability word (STOCK_V3) and word2 tail bits; seed RPCs
snapshot_hints and peer_firewall_stats; /proc mirrors (snapshot_hints,
pear_trust, rlimits, hdms_health, initd_graph) and /proc/net/udp; boot
policy v3 (requireFeatureBits2/3, Pear IPC allowlist, VFS deny prefixes,
initd restart cap); VFS enforcement for boot-policy path denies; delegate
in-flight caps; gated shell local/declare; initd path conditions, ordered
suspend/resume, OnInactiveSec timers; vfs.watch swarm/replication;
validate-example-schemas.mjs + ajv in pretest; extend verify-ctx for V3;
bump ctx API to 1.12.0; ADR 001, protocol, handbook, dev-guide, and
reference docs; seeder/kernel parity and booter tests.

Chore: Prettier table alignment in CHANGELOG and READMEs; compact
bareOsRequestPearReload signature in bare-os-ctx.d.ts.
2026-04-04 02:34:21 -04:00

640 lines
18 KiB
JavaScript

/**
* User crontab at ~/.crontab — five fields (minute hour dom month dow) + command.
* Registered as bare-initd service `bare-cron`.
*/
import { registerBareInitdDisposer, registerBareService } from './bare-initd.js'
import {
BARE_INITD_TIMERS_DIR,
parseBareOsTimerFile
} from './bare-initd-user.js'
import { appendVarLog, CRON_LOG } from './bare-os-var-log.js'
/** @type {ReturnType<typeof setTimeout> | null} */
let cronTimeoutId = null
/** @type {ReturnType<typeof setInterval> | null} */
let cronIntervalId = null
/** @type {ReturnType<typeof setInterval>[]} */
let everyMsIntervalIds = []
/** @type {ReturnType<typeof setTimeout>[]} */
let onInactiveTimerIds = []
/** @type {Record<string, unknown> | null} */
let cronExecCtx = null
/** @type {Set<number>} */
const cronRebootLinesDone = new Set()
export function stopBareCron() {
if (cronTimeoutId != null) {
clearTimeout(cronTimeoutId)
cronTimeoutId = null
}
if (cronIntervalId != null) {
clearInterval(cronIntervalId)
cronIntervalId = null
}
for (const id of everyMsIntervalIds) clearInterval(id)
everyMsIntervalIds = []
for (const id of onInactiveTimerIds) clearTimeout(id)
onInactiveTimerIds = []
cronRebootLinesDone.clear()
}
/** @param {Date} d */
function msToNextMinuteBoundary(d = new Date()) {
return 60000 - (d.getSeconds() * 1000 + d.getMilliseconds())
}
/**
* @param {string} spec
* @param {number} value
* @param {number} min
* @param {number} max
*/
export function fieldMatches(spec, value, min, max) {
const s = spec.trim()
if (!s) return false
for (const part of s.split(',')) {
const p = part.trim()
if (!p) continue
if (partMatches(p, value, min, max)) return true
}
return false
}
/**
* @param {string} p
* @param {number} value
* @param {number} min
* @param {number} max
*/
function partMatches(p, value, min, max) {
if (p === '*') return true
const slash = p.indexOf('/')
if (slash !== -1) {
const range = p.slice(0, slash)
const step = Number.parseInt(p.slice(slash + 1), 10)
if (!Number.isFinite(step) || step < 1) return false
let lo
let hi
if (range === '*') {
lo = min
hi = max
} else if (range.includes('-')) {
const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10))
lo = Math.min(a, b)
hi = Math.max(a, b)
} else {
lo = hi = Number.parseInt(range, 10)
}
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return false
return value >= lo && value <= hi && (value - lo) % step === 0
}
if (p.includes('-')) {
const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10))
const lo = Math.min(a, b)
const hi = Math.max(a, b)
return value >= lo && value <= hi
}
const n = Number.parseInt(p, 10)
return Number.isFinite(n) && value === n
}
/**
* @param {string} spec
* @param {number} jsDow 0=Sun … 6=Sat (Date#getDay)
*/
export function dowFieldMatches(spec, jsDow) {
const s = spec.trim()
if (!s) return false
for (const part of s.split(',')) {
const p = part.trim()
if (!p) continue
if (dowPartMatches(p, jsDow)) return true
}
return false
}
/**
* @param {string} p
* @param {number} jsDow
*/
function dowPartMatches(p, jsDow) {
if (p === '*') return true
const slash = p.indexOf('/')
if (slash !== -1) {
const range = p.slice(0, slash)
const step = Number.parseInt(p.slice(slash + 1), 10)
if (!Number.isFinite(step) || step < 1) return false
if (range === '*') {
for (let js = 0; js <= 6; js += step) {
if (jsDow === js) return true
}
return false
}
if (range.includes('-')) {
const [a, b] = range.split('-').map((x) => Number.parseInt(x.trim(), 10))
const lo = Math.min(a, b)
const hi = Math.max(a, b)
for (let k = lo; k <= hi; k++) {
if ((k - lo) % step !== 0) continue
const js = k === 7 ? 0 : k
if (js >= 0 && js <= 6 && jsDow === js) return true
}
return false
}
const k = Number.parseInt(range.trim(), 10)
if (!Number.isFinite(k)) return false
const js = k === 7 ? 0 : k
return js >= 0 && js <= 6 && jsDow === js
}
if (p.includes('-')) {
const [a, b] = p.split('-').map((x) => Number.parseInt(x.trim(), 10))
const lo = Math.min(a, b)
const hi = Math.max(a, b)
for (let k = lo; k <= hi; k++) {
const js = k === 7 ? 0 : k
if (js === jsDow) return true
}
return false
}
const n = Number.parseInt(p, 10)
if (!Number.isFinite(n)) return false
if (n === 7) return jsDow === 0
return jsDow === n
}
/**
* Strip optional `JitterSec=N` prefix from a command (spread load across hosts).
* @param {string} command
* @returns {{ jitterSec: number, command: string }}
*/
export function extractCronJitterPrefix(command) {
const raw = String(command).trim()
const m = /^JitterSec=(\d+)\s+/i.exec(raw)
if (!m) return { jitterSec: 0, command: raw }
const n = Number.parseInt(m[1], 10)
const jitterSec = Number.isFinite(n) ? Math.min(86400, Math.max(0, n)) : 0
return { jitterSec, command: raw.slice(m[0].length).trim() }
}
/**
* @param {string} line
* @returns {{ minute: string, hour: string, dom: string, month: string, dow: string, command: string, jitterSec: number } | null}
*/
export function parseCronLine(line) {
const t = line.trim()
if (!t || t.startsWith('#')) return null
const parts = t.split(/\s+/)
if (parts[0] === '@reboot') {
const tail = parts.slice(1).join(' ')
const { jitterSec, command } = extractCronJitterPrefix(tail)
if (!command) return null
return {
minute: '@reboot',
hour: '*',
dom: '*',
month: '*',
dow: '*',
command,
jitterSec
}
}
if (parts.length < 6) return null
const [minute, hour, dom, month, dow, ...rest] = parts
const joined = rest.join(' ')
const { jitterSec, command } = extractCronJitterPrefix(joined)
if (!command) return null
return { minute, hour, dom, month, dow, command, jitterSec }
}
/**
* @param {{ minute: string, hour: string, dom: string, month: string, dow: string }} job
* @param {Date} date
*/
export function jobMatchesDate(job, date) {
if (job.minute === '@reboot') return false
return (
fieldMatches(job.minute, date.getMinutes(), 0, 59) &&
fieldMatches(job.hour, date.getHours(), 0, 23) &&
fieldMatches(job.dom, date.getDate(), 1, 31) &&
fieldMatches(job.month, date.getMonth() + 1, 1, 12) &&
dowFieldMatches(job.dow, date.getDay())
)
}
/**
* @param {Record<string, unknown>} ctx
*/
async function readUserCrontabText(ctx) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readFile !== 'function') return ''
const home = vfs.env?.HOME || '/home/guest'
const h = String(home).replace(/\/$/, '')
const path = `${h}/.crontab`
try {
const b = await vfs.readFile(path)
if (!b) return ''
return ctx.b4a.toString(b)
} catch {
return ''
}
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<string>}
*/
async function readSystemCrontabText(ctx) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readFile !== 'function') return ''
try {
const b = await vfs.readFile('/etc/bare-os/crontab')
if (!b) return ''
return ctx.b4a.toString(b)
} catch {
return ''
}
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string, jitterSec: number }[]>}
*/
async function readTimerJobs(ctx) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function' || !vfs.readFile) return []
/** @type {string[]} */
let names = []
try {
names = await vfs.readdir(BARE_INITD_TIMERS_DIR)
} catch {
return []
}
/** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string, jitterSec: number }[]} */
const jobs = []
let idx = 100000
for (const name of names) {
if (!name.endsWith('.timer')) continue
const p = `${BARE_INITD_TIMERS_DIR}/${name}`
let buf
try {
buf = await vfs.readFile(p)
} catch {
continue
}
if (!buf) continue
const text = ctx.b4a.toString(buf)
const t = parseBareOsTimerFile(text)
if (!t) {
void appendVarLog(ctx, CRON_LOG, 'timer', `invalid ${p}`)
continue
}
if (t.kind === 'everyMs' || t.kind === 'onInactiveSec') continue
const synthetic = `${t.onCalendar} ${t.execLine}`
const job = parseCronLine(synthetic)
if (job)
jobs.push({
lineIndex: idx++,
...job,
jitterSec: Math.max(job.jitterSec || 0, t.jitterSec || 0)
})
else void appendVarLog(ctx, CRON_LOG, 'timer', `bad schedule ${p}`)
}
return jobs
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<{ path: string, everyMs: number, execLine: string, jitterSec: number }[]>}
*/
async function readEveryMsTimerJobs(ctx) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function' || !vfs.readFile) return []
/** @type {string[]} */
let names = []
try {
names = await vfs.readdir(BARE_INITD_TIMERS_DIR)
} catch {
return []
}
/** @type {{ path: string, everyMs: number, execLine: string, jitterSec: number }[]} */
const out = []
for (const name of names) {
if (!name.endsWith('.timer')) continue
const p = `${BARE_INITD_TIMERS_DIR}/${name}`
let buf
try {
buf = await vfs.readFile(p)
} catch {
continue
}
if (!buf) continue
const text = ctx.b4a.toString(buf)
const t = parseBareOsTimerFile(text)
if (t && t.kind === 'everyMs') {
out.push({
path: p,
everyMs: t.everyMs,
execLine: t.execLine,
jitterSec: t.jitterSec || 0
})
}
}
return out
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<{ path: string, inactiveSec: number, execLine: string, jitterSec: number, persistent: boolean }[]>}
*/
async function readOnInactiveTimerJobs(ctx) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function' || !vfs.readFile) return []
/** @type {string[]} */
let names = []
try {
names = await vfs.readdir(BARE_INITD_TIMERS_DIR)
} catch {
return []
}
/** @type {{ path: string, inactiveSec: number, execLine: string, jitterSec: number, persistent: boolean }[]} */
const out = []
for (const name of names) {
if (!name.endsWith('.timer')) continue
const p = `${BARE_INITD_TIMERS_DIR}/${name}`
let buf
try {
buf = await vfs.readFile(p)
} catch {
continue
}
if (!buf) continue
const text = ctx.b4a.toString(buf)
const t = parseBareOsTimerFile(text)
if (t && t.kind === 'onInactiveSec') {
out.push({
path: p,
inactiveSec: t.inactiveSec,
execLine: t.execLine,
jitterSec: t.jitterSec || 0,
persistent: t.persistent
})
}
}
return out
}
/** @type {Set<number>} */
const cronRunningJobs = new Set()
/**
* @param {Record<string, unknown>} ctx
*/
function startBareCron(ctx) {
stopBareCron()
cronExecCtx = ctx
void (async () => {
const c = cronExecCtx
if (!c) return
let msJobs = []
try {
msJobs = await readEveryMsTimerJobs(c)
} catch {
msJobs = []
}
for (const j of msJobs.slice(0, 8)) {
const jitterMs =
j.jitterSec > 0 ? Math.floor(Math.random() * j.jitterSec * 1000) : 0
const runMs = () => {
const cx = cronExecCtx
if (!cx || typeof cx.execLine !== 'function') return
const k = `everyMs:${j.path}`
if (cronRunningJobs.has(k)) return
cronRunningJobs.add(k)
void (async () => {
try {
await cx.execLine(j.execLine)
} catch (e) {
const msg = e?.message || String(e)
try {
cx.console?.error?.(`[bare-cron] ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(cx, CRON_LOG, 'timer-ms', msg)
} finally {
cronRunningJobs.delete(k)
}
})()
}
const bootMs = () => {
runMs()
everyMsIntervalIds.push(setInterval(runMs, j.everyMs))
}
if (jitterMs > 0) setTimeout(bootMs, jitterMs)
else bootMs()
}
let inactJobs = []
try {
inactJobs = await readOnInactiveTimerJobs(c)
} catch {
inactJobs = []
}
for (const j of inactJobs.slice(0, 8)) {
const jitterMs =
j.jitterSec > 0 ? Math.floor(Math.random() * j.jitterSec * 1000) : 0
const gapMs = Math.max(1000, j.inactiveSec * 1000)
const scheduleInact = (delayMs) => {
const tid = setTimeout(() => void runInact(), delayMs)
onInactiveTimerIds.push(tid)
}
const runInact = async () => {
const cx = cronExecCtx
if (!cx || typeof cx.execLine !== 'function') return
const k = `onInactive:${j.path}`
if (cronRunningJobs.has(k)) return
cronRunningJobs.add(k)
try {
await cx.execLine(j.execLine)
} catch (e) {
const msg = e?.message || String(e)
try {
cx.console?.error?.(`[bare-cron] ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(cx, CRON_LOG, 'timer-inactive', msg)
} finally {
cronRunningJobs.delete(k)
}
if (j.persistent) scheduleInact(gapMs)
}
scheduleInact(gapMs + jitterMs)
}
let userText0 = ''
let sysText0 = ''
try {
userText0 = await readUserCrontabText(c)
} catch {
userText0 = ''
}
try {
sysText0 = await readSystemCrontabText(c)
} catch {
sysText0 = ''
}
/** @type {{ lineIndex: number, command: string, jitterSec: number }[]} */
const rebootOnce = []
const collectReboot = (text, baseIndex) => {
const lines = text.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim()
if (!raw || raw.startsWith('#')) continue
const job = parseCronLine(lines[i])
if (job && job.minute === '@reboot') {
rebootOnce.push({
lineIndex: baseIndex + i,
command: job.command,
jitterSec: job.jitterSec || 0
})
}
}
}
collectReboot(sysText0, 0)
collectReboot(userText0, 10000)
for (const rj of rebootOnce) {
if (cronRebootLinesDone.has(rj.lineIndex)) continue
cronRebootLinesDone.add(rj.lineIndex)
const delayMs =
rj.jitterSec > 0 ? Math.floor(Math.random() * rj.jitterSec * 1000) : 0
const key = rj.lineIndex
setTimeout(() => {
if (cronRunningJobs.has(key)) return
cronRunningJobs.add(key)
void (async () => {
try {
const execLine = c.execLine
if (typeof execLine !== 'function') return
await execLine(rj.command)
} catch (e) {
const msg = e?.message || String(e)
try {
c.console?.error?.(`[bare-cron] ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(c, CRON_LOG, 'reboot', msg)
} finally {
cronRunningJobs.delete(key)
}
})()
}, delayMs)
}
})()
async function tick() {
const c = cronExecCtx
if (!c) return
let userText = ''
let sysText = ''
try {
userText = await readUserCrontabText(c)
} catch {
return
}
try {
sysText = await readSystemCrontabText(c)
} catch {
/* ignore */
}
/** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string, jitterSec: number }[]} */
const jobs = []
const pushValidatedLines = (text, baseIndex) => {
const lines = text.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim()
if (!raw || raw.startsWith('#')) continue
const job = parseCronLine(lines[i])
if (job) {
if (job.minute === '@reboot') continue
jobs.push({ lineIndex: baseIndex + i, ...job })
} else {
void appendVarLog(
c,
CRON_LOG,
'crontab',
`invalid line ${i + 1}: ${raw.slice(0, 100)}`
)
}
}
}
pushValidatedLines(sysText, 0)
pushValidatedLines(userText, 10000)
let timerJobs = []
try {
timerJobs = await readTimerJobs(c)
} catch {
timerJobs = []
}
jobs.push(...timerJobs)
if (!jobs.length) return
const now = new Date()
for (const job of jobs) {
if (!jobMatchesDate(job, now)) continue
const key = job.lineIndex
if (cronRunningJobs.has(key)) continue
cronRunningJobs.add(key)
const jitterMs =
job.jitterSec > 0
? Math.floor(Math.random() * job.jitterSec * 1000)
: 0
const run = () => {
void (async () => {
try {
const execLine = c.execLine
if (typeof execLine !== 'function') return
await execLine(job.command)
} catch (e) {
const msg = e?.message || String(e)
try {
c.console?.error?.(`[bare-cron] ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(c, CRON_LOG, 'error', msg)
} finally {
cronRunningJobs.delete(key)
}
})()
}
if (jitterMs > 0) setTimeout(run, jitterMs)
else run()
}
}
const delay = msToNextMinuteBoundary()
cronTimeoutId = setTimeout(() => {
cronTimeoutId = null
void tick()
cronIntervalId = setInterval(() => {
void tick()
}, 60000)
}, delay)
}
registerBareInitdDisposer(stopBareCron)
registerBareService({
name: 'bare-cron',
description: 'User crontab scheduler (minute-aligned; ~/.crontab)',
logPath: CRON_LOG,
start: startBareCron,
stop: stopBareCron
})