Repository navigation
The worker waits for a synchronised clock before it consumes - #261
Merged
Merged
Conversation
This desktop dual-boots, and Windows writes local time to the hardware clock. After a Windows session the kernel reads that clock as UTC, so the system comes up exactly two hours fast until chronyd steps it back. On 2026-08-29 the kernel set the clock at 05:46:04, systemd started this unit at 05:46:11, the worker took a job and wrote three events at 05:46:20, and chronyd corrected by -7199.5 seconds at 05:46:23. Twelve seconds of exposure and the worker beat the correction by three. It matters because a job event carries the clock of the machine that emitted it, so a compute node that starts early does not merely mislabel its own work: it writes fiction into the stream both machines read. Those three rows made a healthy run look like it had stalled for 110 minutes, and establishing otherwise took most of a day. An exact detector over the event table, ts running backwards while the sequential id advances, finds two episodes in 798,081 events, 2026-08-11 and 2026-08-29, both on this node. The gate polls NTPSynchronized, the kernel's own adjtimex flag rather than anything chrony-specific, and it never refuses to start. When the budget runs out the worker starts anyway and says so, because a queue with no consumer costs this project more than a wrong timestamp does: that failure has already cost 110 minutes once and announced itself to nobody. Every branch ends with the worker running, including a missing timedatectl, a missing timeout(1), an unreadable /proc/uptime and a budget of garbage. Three second-order choices are load-bearing and each was measured: Elapsed time is read from /proc/uptime and never from the wall clock, because at the instant chronyd steps back by 7,199 seconds a wall-clock deadline concludes it has two hours of budget left, on the one path that must never hang. The budget is forced to base ten and capped at 600. Bash reads a leading zero as octal, so 0120 is eighty and 09 is not a number at all, and the syntax check accepts both; and 1200 in the environment file buys twenty minutes of a unit sitting in active (running), absent from --failed, silent in the log, consuming nothing. The give-up marker is scoped per queue. This node runs several instances of the template at once, and an unscoped marker lets the first instance to give up disarm the gate for every other one on a boot where the clock is still wrong. The unit loses its After= and Wants=network-online.target. Both were already inert: systemctl --user show network-online.target reports LoadState=not-found, because a user manager has no such target, which is why this worker could start before the network was up. They are removed rather than commented, and no ordering replaces them, because an inert declaration is worse than no declaration. Note that systemctl --user show <unit> -p After lists ordering names for units that do not exist, so grepping it proves nothing about whether a gate fires. Verified by running the gate where it must fire rather than by checking that it is declared: nine controls over the real file, including a stub clock that never synchronises (holds the full budget, warns, starts), one that recovers mid-wait, the octal and cap paths, and two queues sharing a boot where the second must still gate after the first gives up.
The README uses `systemctl --user show time-sync.target -p LoadState` as the proof that a user manager has no such target. That check is sound, but only because it is asked of a target, and a reader will reasonably carry it to other unit types where it means nothing. Measured on this node: an invented `.service` and an invented `.target` both report `not-found`, while an invented `.slice` reports `loaded`, because slices are implicit units systemd synthesises on demand rather than loads from a file. So `LoadState` cannot show a slice absent, and on another machine in this project it reported `not-found` for a slice that `list-units --all` shows loaded and active. No information in either direction. This is the mirror of the trap already documented one line above: `-p After` overstates by listing units that do not exist, `LoadState` understates or overstates on slices. Both report what was asked for rather than what will happen, which is why the existence check belongs on `list-units --all`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This desktop dual-boots, and Windows writes local time to the hardware clock. After a Windows session the kernel reads that clock as UTC, so the system comes up exactly two hours fast until chronyd steps it back. On 2026-08-29:
Twelve seconds of exposure, and the worker beat the correction by three. It matters because a job event carries the clock of the machine that emitted it, so a compute node that starts early does not merely mislabel its own work: it writes fiction into the stream both machines read. Those three rows made a healthy run look like it had stalled for 110 minutes, and establishing otherwise took most of a day. An exact detector over the event table,
tsrunning backwards while the sequentialidadvances, finds two episodes in 798,081 events, on 2026-08-11 and 2026-08-29, both on this node.What it does
The wrapper polls
NTPSynchronized, the kernel's own adjtimex flag rather than anything chrony-specific, for up to 120 seconds before it execs the worker.It never refuses to start. When the budget runs out the worker starts anyway and says so, because a queue with no consumer costs this project more than a wrong timestamp does: that failure has already cost 110 minutes once and announced itself to nobody. Every branch ends with the worker running, including a missing
timedatectl, a missingtimeout(1), an unreadable/proc/uptimeand a budget of garbage.Three second-order choices, each measured
Elapsed time comes from
/proc/uptime, never from the wall clock. At the instant chronyd steps back by 7,199 seconds a wall-clock deadline concludes it has two hours of budget left, on the one path that must never hang. Bash's own$SECONDShas the same defect.The budget is forced to base ten and capped at 600. Bash reads a leading zero as octal, so
0120is eighty and09is not a number at all, and the syntax check accepts both. The second one is the dangerous half: the arithmetic error lands inside a condition,set -edoes not fire, and the gate is silently disarmed while the file still says there is a gate. And1200in the environment file is a plausible "give NTP plenty of time" that buys twenty minutes of a unit sitting inactive (running), absent from--failed, silent in the log, consuming nothing.The give-up marker is scoped per queue. This node runs several instances of the template at once: on 2026-08-29 systemd started
@protea.evaluationsand@protea.predictions.batchin the same millisecond. An unscoped marker lets the first instance to give up disarm the gate for every other one on a boot where the clock is still wrong.The unit loses two inert lines
After=network-online.targetandWants=network-online.targetwere already inert, because a user manager has no such target. That is why this worker could start before the network was up. They are removed rather than commented, and no ordering replaces them, because an inert declaration is worse than no declaration: a test user unit ordered after two non-existent targets starts with rc=0 andResult=success.Two traps in checking this, pointing opposite ways.
systemctl --user show <unit> -p Afterlists ordering names for units that do not exist, so it overstates. AndLoadStateanswers only for unit types that need a unit file: an invented.serviceand.targetreportnot-found, an invented.slicereportsloaded. Existence belongs onlist-units --all.How it is verified
By running the gate where it must fire, not by checking that it is declared. Nine controls against the real file:
clock=ok, no extra lineclock=unsyncedclock synchronised after 2s,clock=ok09timedatectlclock=unchecked, starts, no markertimeout(1)systemd-analyze verify --userandbash -nare clean.pytestis 615 passed, 1 failed. The failure istests/test_restore_drill_pyshim.py::test_bash_test_passes, and it is not from this branch: it fails identically onorigin/main, checked in a throwaway worktree. Reported rather than described as green.What this does not close
The warnings and the
chronyc trackingdump go to the node's local log. No server-side surface sees them. A node whose clock is wrong still cannot announce it to the server, and that is a separate piece.