Runner
The runner is Cronhoster's scheduling engine. It is a long-lived Node service that runs on the server,
separate from the API. It watches the synced content root, discovers each cron from the two files in its
directory, reads the UTC clock once a second, and runs a cron's handler the moment one of its runs matches,
inside this one dedicated application. The box's own system cron is never involved, so timing, overlap and
logging behave the same way on your laptop and on the server. The engine is one file,
cronhoster-runner/runner.js. It shares the schedule grammar and the matcher with the local
scripts through its own cronhoster-runner/schedule.js (the local scripts carry a copy).
What the runner does
The runner has four jobs of its own, and it does them for every cron at once:
- Discover. Scan the top level of the content root and decide which directories are crons, reading each one's runs from its settings file.
- Tick. Once a second, read the UTC clock and test every run of every cron against it.
- Fire. When a run's five time fields all match, spawn the cron's handler as a child process, subject to the cron's concurrency ceiling.
- Record. When the handler finishes, write one structured run record for the Cron Logs tool.
It runs the scan once at startup and again, debounced, whenever the content root changes, so a freshly synced cron starts scheduling itself without a restart.
Discovering crons
Discovery looks only at the top level of the content root. A top-level entry becomes a cron when it passes every test below. An entry that fails any test is simply left unscheduled, so a stray file or a broken cron stops itself rather than raising an error.
| Test | Rule |
|---|---|
directory | Files at the top level are ignored. Only directories can be crons. |
not staged | Names starting with _ or with . are skipped. This is how a draft version stays local until it is ready. |
has both files | The directory must hold a cronhoster-cron-handler.js and a cronhoster-cron-settings.json. With either missing there is nothing to run, so the directory is skipped. |
settings parse | The settings must parse under the grammar in cronhoster-runner/schedule.js, the same module the Cron Logs browser reads (as its own copy). A settings file that fails to parse leaves the cron unscheduled, journalled once on the change. |
After each scan the runner rebuilds its whole cron set from the current directories. A filesystem watch on the content root triggers a rescan, debounced by about 1.5 seconds so that a burst of synced files settles into a single rebuild. The journal stays quiet across a rescan and names only what changed: a cron added, removed, or newly broken.
The tick loop and matching
A cron's settings name one or more runs. Each run gives five time fields, month, day,
hour, minute and second, and each field is either a number or *. The settings also give the
cron's concurrency ceiling. The Settings reference is the full grammar.
The runner reads the UTC clock once a second. A short over-sampled loop wakes several
times a second so a second boundary is never lost to timer drift, but it processes each whole second
exactly once. A run fires when all five of its fields match the current UTC time: a
number matches only its own value, and * matches anything. Every run of every cron is tested
each second, so a cron with several runs can fire more than one at the same instant.
month day hour minute second
* * 3 0 0 once a day, at 03:00:00 UTC
* * * * 30 once a minute, on the 30th second
* * * 0 0 once an hour, on the hour
* matching any value.All times are UTC. A second the process was too busy to reach is simply missed and never backfilled, so a cron that could not run during an outage resumes on its next matching second rather than firing a backlog.
Firing a run
To fire a run the runner spawns node cronhoster-cron-handler.js with the working directory
set to the cron's own directory, so the handler resolves the libraries bundled alongside it. The matched
time reaches the handler on the command line, so a handler reads its context from
process.argv with no special import:
node cronhoster-cron-handler.js <cron> <run> <year> <month> <day> <hour> <minute> <second>
| Argument | Value |
|---|---|
cron | The cron's directory name. |
run | The name of the run that matched. |
year | The matched UTC year. |
month | The matched UTC month, 1 to 12. |
day | The matched UTC day of the month, 1 to 31. |
hour | The matched UTC hour, 0 to 23. |
minute | The matched UTC minute, 0 to 59. |
second | The matched UTC second, 0 to 59. |
The handler's stdout and stderr are captured as the run proceeds. The tail of each stream is kept, bounded
to a fixed size, and written into the run record itself, so a chatty run can never grow the disk without
limit. A max-runtime guard of six hours protects the service: a handler that overruns is stopped with
SIGKILL and recorded as killed.
The concurrency ceiling
Concurrency is keyed per cron, by its directory. The runner keeps a live count for each cron and holds it
below the cron's cronhoster-cron-setting-maxConcurrent ceiling. By default the ceiling is
one, so at most one handler for a cron runs at a time. If a run matches while the cron is
already at its ceiling, that match is skipped. It is never queued on top of the running
one, and the missed time is never made up later.
A cron whose runs are safe to overlap raises the ceiling in its settings. With
cronhoster-cron-setting-maxConcurrent set to three, the cron runs up to three handlers at
once, and a match is skipped only when all three slots are full.
The runner counts the matches skipped while a cron is at capacity. The finishing run drains that count
onto its own record as skips, so every skipped match is recorded exactly once and the Cron
Logs tool can show how many fires a busy cron swallowed.
Run records
When a run finishes the runner appends one JSON line to a dated day-file under
cronhoster-runs/, through cronhoster-runner/run-recorder.js. The dated files
are the rotation: today's file grows, and a past
day's file is frozen the moment the date rolls over. Files older than the retention window of 14 days are
pruned. These records are the data source for the Cron Logs
tool, which mirrors them to the local scripts.
/opt/localhoster/cronhoster/cronhoster-runs/
runs-2026-07-24.log today, still growing
runs-2026-07-23.log frozen once its day rolled over
runs-2026-07-22.log pruned after 14 days
{"t":1784516400,"at":"2026-07-24T03:00:00.000Z","cron":"backup","run":"nightly","schedule":"* * 3 0 0","year":2026,"month":7,"day":24,"hour":3,"minute":0,"second":0,"maxConcurrent":1,"ms":812,"code":0,"signal":null,"killed":false,"ok":true,"skips":0,"out":"backed up 3 databases\n"}
Each record carries these fields:
| Field | Meaning |
|---|---|
t | Start time in unix seconds. It is also the day-file bucket key. |
at | Start time as an ISO-8601 string. |
cron | The cron's directory name. |
run | The name of the run that matched. |
schedule | The run's five fields as text, month day hour minute second, for example * * 3 0 0. |
year | The matched UTC year. The month through second fields hold the rest of the matched time. |
maxConcurrent | The cron's concurrency ceiling, how many of its handlers may overlap. |
ms | Wall-clock duration of the run in milliseconds. |
code | The process exit code, or null when the run was signalled. |
signal | The kill signal, if any, for example SIGKILL from the max-runtime guard. |
killed | True when the run was stopped for overrunning the max runtime. |
ok | True when the run finished cleanly, meaning exit code 0 and not killed. |
skips | How many of this cron's matches were skipped for capacity and drained onto this run, the fires it interrupted. |
out | The captured stdout, its tail, bounded. Omitted when the run printed nothing. |
err | The captured stderr, its tail, bounded. Omitted when the run printed nothing. |
The reader in the local scripts does all the derived work, so the record shape can gain fields without redeploying the server.
Version by rename
A cron carries everything it needs and shares nothing with other crons, so a cron is effectively
immutable. To change one you publish a new directory under a new name rather than editing a running cron.
Stage the new version as _<name> so it stays local while you test it, then drop the
underscore to make it live and remove the old directory.
The sync mirrors this. A directory is uploaded once, when it first appears, and is never re-synced, so the runner never sees a live cron's files change beneath it. There is no shared-file versioning to reason about.