Files
bare-operating-system/packages/bare-os-booter/lib/bare-cron.js
T
Raven Scott d794c87b32
CI / test (push) Has been cancelled
update
2026-04-03 03:07:51 -04:00

251 lines
6.6 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'
/** @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
}
/**
* @param {string} line
* @returns {{ minute: string, hour: string, dom: string, month: string, dow: string, command: string } | null}
*/
export function parseCronLine(line) {
const t = line.trim()
if (!t || t.startsWith('#')) return null
const parts = t.split(/\s+/)
if (parts.length < 6) return null
const [minute, hour, dom, month, dow, ...rest] = parts
const command = rest.join(' ')
if (!command) return null
return { minute, hour, dom, month, dow, command }
}
/**
* @param {{ minute: string, hour: string, dom: string, month: string, dow: string }} job
* @param {Date} date
*/
export function jobMatchesDate(job, date) {
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
*/
function startBareCron(ctx) {
/** @type {Set<number>} */
const running = new Set()
let timeoutId = null
let intervalId = null
async function tick() {
let text
try {
text = await readUserCrontabText(ctx)
} catch {
return
}
if (!text.trim()) return
const lines = text.split(/\r?\n/)
/** @type {{ lineIndex: number, minute: string, hour: string, dom: string, month: string, dow: string, command: string }[]} */
const jobs = []
for (let i = 0; i < lines.length; i++) {
const job = parseCronLine(lines[i])
if (job) jobs.push({ lineIndex: i, ...job })
}
const now = new Date()
for (const job of jobs) {
if (!jobMatchesDate(job, now)) continue
const key = job.lineIndex
if (running.has(key)) continue
running.add(key)
void (async () => {
try {
const execLine = ctx.execLine
if (typeof execLine !== 'function') return
await execLine(job.command)
} catch (e) {
const msg = e?.message || String(e)
try {
ctx.console?.error?.(`[bare-cron] ${msg}`)
} catch {
/* ignore */
}
} finally {
running.delete(key)
}
})()
}
}
registerBareInitdDisposer(() => {
if (timeoutId != null) clearTimeout(timeoutId)
if (intervalId != null) clearInterval(intervalId)
timeoutId = null
intervalId = null
})
const delay = msToNextMinuteBoundary()
timeoutId = setTimeout(() => {
timeoutId = null
void tick()
intervalId = setInterval(() => {
void tick()
}, 60000)
}, delay)
}
registerBareService({
name: 'bare-cron',
start: startBareCron
})