754 lines
21 KiB
JavaScript
754 lines
21 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()
|
|
/** @type {Map<string, { signature: string, lastLogMs: number }>} */
|
|
const timerParseLogState = new Map()
|
|
const TIMER_PARSE_LOG_INTERVAL_MS = 60 * 60 * 1000
|
|
|
|
/**
|
|
* Log timer parse issues on first-seen, content-change, then rate-limited.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} kind
|
|
* @param {string} timerPath
|
|
* @param {string} message
|
|
*/
|
|
function logTimerParseIssue(ctx, kind, timerPath, message) {
|
|
const signature = `${kind}:${message}`
|
|
const now = Date.now()
|
|
const prev = timerParseLogState.get(timerPath)
|
|
if (
|
|
prev &&
|
|
prev.signature === signature &&
|
|
now - prev.lastLogMs < TIMER_PARSE_LOG_INTERVAL_MS
|
|
) {
|
|
return
|
|
}
|
|
timerParseLogState.set(timerPath, { signature, lastLogMs: now })
|
|
void appendVarLog(ctx, CRON_LOG, kind, message)
|
|
}
|
|
|
|
/**
|
|
* Wall-clock fields for cron matching (uses `TZ` when set).
|
|
* @param {Date} d
|
|
* @param {Record<string, string> | undefined} env
|
|
*/
|
|
export function cronWallClockParts(d, env) {
|
|
const tz = env && String(env.TZ || '').trim()
|
|
if (!tz) {
|
|
return {
|
|
minute: d.getMinutes(),
|
|
hour: d.getHours(),
|
|
date: d.getDate(),
|
|
month: d.getMonth() + 1,
|
|
dow: d.getDay()
|
|
}
|
|
}
|
|
try {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
minute: 'numeric',
|
|
hour: 'numeric',
|
|
day: 'numeric',
|
|
month: 'numeric',
|
|
weekday: 'short',
|
|
hourCycle: 'h23'
|
|
}).formatToParts(d)
|
|
/** @type {Record<string, string>} */
|
|
const map = Object.create(null)
|
|
for (const p of parts) {
|
|
if (p.type !== 'literal') map[p.type] = p.value
|
|
}
|
|
const wd = String(map.weekday || 'Sun').slice(0, 3)
|
|
const dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
|
|
return {
|
|
minute: Number.parseInt(map.minute, 10),
|
|
hour: Number.parseInt(map.hour, 10),
|
|
date: Number.parseInt(map.day, 10),
|
|
month: Number.parseInt(map.month, 10),
|
|
dow: dowMap[wd] ?? 0
|
|
}
|
|
} catch {
|
|
return cronWallClockParts(d, undefined)
|
|
}
|
|
}
|
|
|
|
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()
|
|
timerParseLogState.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
|
|
* @param {Record<string, string> | undefined} [env] optional `TZ` for wall-clock fields
|
|
*/
|
|
export function jobMatchesDate(job, date, env) {
|
|
if (job.minute === '@reboot') return false
|
|
const z = cronWallClockParts(date, env)
|
|
if (
|
|
!fieldMatches(job.minute, z.minute, 0, 59) ||
|
|
!fieldMatches(job.hour, z.hour, 0, 23) ||
|
|
!fieldMatches(job.month, z.month, 1, 12)
|
|
) {
|
|
return false
|
|
}
|
|
const domOk = fieldMatches(job.dom, z.date, 1, 31)
|
|
const dowOk = dowFieldMatches(job.dow, z.dow)
|
|
const domStar = String(job.dom || '').trim() === '*'
|
|
const dowStar = String(job.dow || '').trim() === '*'
|
|
if (domStar && dowStar) return true
|
|
if (domStar) return dowOk
|
|
if (dowStar) return domOk
|
|
return domOk || dowOk
|
|
}
|
|
|
|
/**
|
|
* @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) {
|
|
logTimerParseIssue(
|
|
ctx,
|
|
'timer',
|
|
p,
|
|
`invalid ${p}; expected [Timer] with OnCalendar/EveryMs/OnInactiveSec and ExecLine=...`
|
|
)
|
|
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 logTimerParseIssue(ctx, 'timer', p, `bad schedule ${p}: ${t.onCalendar}`)
|
|
}
|
|
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 = []
|
|
}
|
|
const envMono =
|
|
c.vfs?.env && typeof c.vfs.env === 'object'
|
|
? /** @type {Record<string, string | undefined>} */ (c.vfs.env)
|
|
: {}
|
|
const everyMsMonotonic =
|
|
envMono.BARE_OS_TIMER_EVERY_MS_MONOTONIC === '1' ||
|
|
envMono.BARE_OS_TIMER_EVERY_MS_MONOTONIC === 'true'
|
|
|
|
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)
|
|
}
|
|
})()
|
|
}
|
|
if (everyMsMonotonic) {
|
|
const chain = () => {
|
|
const tid = setTimeout(() => {
|
|
runMs()
|
|
chain()
|
|
}, j.everyMs)
|
|
everyMsIntervalIds.push(tid)
|
|
}
|
|
if (jitterMs > 0) {
|
|
setTimeout(() => {
|
|
runMs()
|
|
chain()
|
|
}, jitterMs)
|
|
} else {
|
|
runMs()
|
|
chain()
|
|
}
|
|
} else {
|
|
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, c.env)) 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); honors TZ for wall-clock fields',
|
|
logPath: CRON_LOG,
|
|
start: startBareCron,
|
|
stop: stopBareCron
|
|
})
|