9.2 Evaluation and Arming
A timer is a trigger, not a service type. A service with a
timer:<schedule> trigger is an ordinary Simple or Oneshot service that
peinit starts on a schedule.
9.2.1 Arming #
At boot, once the service graph is loaded, and whenever timer configuration changes, peinit computes the next firing time for every active trigger and arms a timerfd for it. Each trigger gets its own descriptor and its own computation.
A disabled service gets neither a registration nor a firing.
A schedule that fails to parse, or whose next occurrence cannot be computed, fails that trigger. Every other timer arms normally, and what did not arm is reported to the console. This matches how graph validation already treats an invalid schedule (§7.4), so the outcome no longer depends on which of the two caught it.
The next-occurrence search looks ten years ahead and then gives up. A
schedule can parse and still match nothing — *-02-30, or a fixed year
already past — and the horizon turns that into a prompt error against
the one service rather than a very long walk. Ten years clears the
sparsest schedule that is genuinely meaningful: *-02-29 skips a
century year not divisible by 400, so it can run eight years dry.
9.2.2 Firing #
handle_timer(service, trigger):
// 1. Decide what the firing means, from the service's state.
match (service.type, service.state):
(Oneshot, Active | Starting):
service.pending_timer = true // at most one
(Simple, Active | Starting):
record the firing; no action
(_, Inactive | Completed | Failed):
create_operation(Start, service, source = Timer)
// 2. Record when it fired.
write the last-run timestamp to the registry // asynchronously
// 3. Re-arm.
next = next_occurrence(trigger.schedule, now) + random(0, TimerJitter)
arm an absolute CLOCK_REALTIME timerfd for next
Every other state — Backoff, Stopping, Reloading, Abandoned, Skipped — records the firing and does nothing.
The last-run write happens in a forked child so that the event loop never waits on the registry. The parent returns immediately, and the child is reaped as an untracked orphan; a failed write is visible only as that child's exit status.
9.2.3 Oneshot pending runs #
A Oneshot that fires while it is already running sets a flag rather than queueing an operation. When it next reaches Inactive or Completed, peinit immediately creates a start operation and clears the flag.
Multiple firings during one run collapse into a single pending run. There is no queue, and the flag is per service rather than per trigger — a service with three timers that all fire during one long run still gets exactly one catch-up.
9.2.4 Multiple triggers #
Triggers on one service are independent: each has its own timerfd, its own next-firing computation, and its own last-run history. Only the Oneshot pending flag is shared.