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:

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.

TestRule
directoryFiles at the top level are ignored. Only directories can be crons.
not stagedNames starting with _ or with . are skipped. This is how a draft version stays local until it is ready.
has both filesThe 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 parseThe 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
A run matches when every field equals the UTC clock, with * 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>
The handler is spawned with the matched time on the command line.
ArgumentValue
cronThe cron's directory name.
runThe name of the run that matched.
yearThe matched UTC year.
monthThe matched UTC month, 1 to 12.
dayThe matched UTC day of the month, 1 to 31.
hourThe matched UTC hour, 0 to 23.
minuteThe matched UTC minute, 0 to 59.
secondThe 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
One day-file per calendar day, in UTC.
{"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"}
One finished run, as a single JSON line.

Each record carries these fields:

FieldMeaning
tStart time in unix seconds. It is also the day-file bucket key.
atStart time as an ISO-8601 string.
cronThe cron's directory name.
runThe name of the run that matched.
scheduleThe run's five fields as text, month day hour minute second, for example * * 3 0 0.
yearThe matched UTC year. The month through second fields hold the rest of the matched time.
maxConcurrentThe cron's concurrency ceiling, how many of its handlers may overlap.
msWall-clock duration of the run in milliseconds.
codeThe process exit code, or null when the run was signalled.
signalThe kill signal, if any, for example SIGKILL from the max-runtime guard.
killedTrue when the run was stopped for overrunning the max runtime.
okTrue when the run finished cleanly, meaning exit code 0 and not killed.
skipsHow many of this cron's matches were skipped for capacity and drained onto this run, the fires it interrupted.
outThe captured stdout, its tail, bounded. Omitted when the run printed nothing.
errThe 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.