/** * 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 | null} */ let cronTimeoutId = null /** @type {ReturnType | null} */ let cronIntervalId = null /** @type {ReturnType[]} */ let everyMsIntervalIds = [] /** @type {ReturnType[]} */ let onInactiveTimerIds = [] /** @type {Record | null} */ let cronExecCtx = null /** @type {Set} */ 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} 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} ctx * @returns {Promise} */ 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} 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} 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} 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} */ const cronRunningJobs = new Set() /** * @param {Record} 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 })