Windows Service - ptweezy/cronstable GitHub Wiki
Windows service
cronstable service registers the scheduler with the Windows Service
Control Manager, so it starts at boot and keeps running whether or not
anyone is logged on.
See running on Windows for the rest of the platform's behavior, and the CLI reference for every other subcommand.
Quick start
From an elevated prompt (the commands work identically from a pip or pipx
install and from the extracted cronstable.exe of the published
one-directory zip):
cronstable init C:\ProgramData\cronstable
cronstable service install -c C:\ProgramData\cronstable
cronstable service start
init hands C:\ProgramData\cronstable to the Administrators group and
restricts it, so only SYSTEM and administrators can add a job there. When the
directory already exists and another account owns it, or a junction stands at
the path, init refuses and prints the fix: remove the directory or run the
printed icacls commands, then run init again. See who may write the
config directory.
The service applies the same rule to its own configuration. It refuses to
start when Everyone, Users, Authenticated Users, or another any-user group
can add files to the -c path, when an account other than SYSTEM,
Administrators, or TrustedInstaller owns it (unless an OWNER RIGHTS entry
holds that owner to read), or when the path is a junction or symbolic link.
It repeats the check before every reload. The check ignores a write grant to
a single named account. install refuses such a path up front and prints the
same fix.
cronstable service status reports the state and the process ID. sc query cronstable and the Services console (services.msc) see it like any other
service.
Which installs can host a service
| Install shape | Can host a service |
|---|---|
pip install cronstable / pipx |
yes |
| The published zip (a one-directory build) | yes |
| The MSI | yes (registers the service itself) |
| winget (a setup that embeds the MSI) | yes (registers the service itself) |
The published one-file .exe |
no |
The one-file executable cannot host a Windows service, and install
refuses it by name rather than producing something that fails at start.
A one-file build unpacks itself to a temporary directory and runs the program in a child process. The SCM starts and watches the parent bootloader, which never registers with the service dispatcher. The start therefore fails on the SCM's timeout while the real program's own registration is refused with error 1063. Nothing in cronstable can change that, because it is the bootloader that forks.
To run as a service, download the one-directory zip for your architecture
(cronstable-windows-amd64v3.zip, -amd64.zip, -arm64.zip, or
-i686.zip; see running on
Windows), or
install with pip or pipx. The MSI registers the service
during the install with the same settings service install writes, so none
of the preceding commands are needed there.
The schtasks recipe remains the
answer for the one-file executable, and it does start at boot and survive
logoff. What it does not give you is the Services console, recovery actions,
and a real stop control.
Commands
| Command | What it does |
|---|---|
cronstable service install |
Register the service. Needs elevation. |
cronstable service remove |
Stop it if running, then unregister. Needs elevation. |
cronstable service start |
Start it and wait until it reports running. |
cronstable service stop |
Ask it to stop, and wait for the drain. |
cronstable service reload |
Make the running service reload its configuration now. |
cronstable service status |
Print the state, the process ID, and why it last failed. |
cronstable service run |
What the SCM invokes. Not meant to be typed. |
cronstable service run typed by hand prints an explanation and exits 2,
rather than looking unresponsive.
install
| Flag | Default | Meaning |
|---|---|---|
--name NAME |
cronstable |
Service name, for more than one instance on a host. |
-c, --config |
the platform default | Configuration the service reads, saved in the command line as an absolute path. |
--start-type |
auto |
auto, delayed (after the other automatic services) or demand. |
--log-level LEVEL |
INFO |
Saved in the service's command line. |
--log-file PATH |
a logs directory beside the configuration |
The bootstrap log. |
--no-log-file |
off | Do not open a bootstrap log. |
--console |
off | Allocate a console so job termination keeps its graceful step. See --console and job termination. |
--restart-delay SECONDS |
60 |
Delay before Windows restarts after a failure. |
--no-restart |
off | Do not configure recovery actions at all. |
The service is installed to run as LocalSystem. Per-job and per-service identity (a named account, a gMSA, run levels) is not implemented. On Windows, cronstable is single-principal.
The configuration path matters more than usual
A service runs as LocalSystem, whose %APPDATA% points into
C:\Windows\System32\config\systemprofile, not into your profile. So a
per-user configuration that works perfectly when you run cronstable
interactively is not found when the SCM starts the same program.
install therefore refuses when -c was left at the platform default and
that default resolved inside your own user profile. You did not choose that
path, and installing it would produce a service that starts cleanly and
schedules nothing. Name a machine-wide directory instead:
cronstable init C:\ProgramData\cronstable
cronstable service install -c C:\ProgramData\cronstable
If you can write to a per-user configuration directory, you can modify jobs
that the service runs as SYSTEM. install therefore also rejects an explicit
path to that directory. The service applies the same checks from its own
profile under system32. After you restrict write access with the printed
icacls commands, install accepts the path and recommends a machine-wide
directory such as %ProgramData%\cronstable.
Reloading the configuration
The daemon rereads its configuration on the housekeeping pass, within a
minute of a change. cronstable service reload applies it now instead, and
forces a reparse even when file stats are unchanged. That covers what the
stat watch cannot see: a fromFile credential rewritten in place, a
coarse-mtime or network filesystem.
The reload is the same forced reload SIGHUP performs on POSIX, and it
survives a failed parse: the retry continues until a parse succeeds.
The verb is the Service Control Manager's paramchange control, so standard
service tooling sends it too. sc.exe control cronstable paramchange does
exactly what cronstable service reload does. The service stays running
throughout. Only a stop control starts a drain.
A reload needs a running, settled service, and the error message names whichever condition is unmet:
- A service that is starting or stopping takes no controls until it settles.
- A stopped service is not running.
- A service process still running an older cronstable refuses the control until it is restarted on the current binary.
A cronstable console run on Windows has no reload signal and picks changes
up on the housekeeping pass. The reload verb belongs to the service.
Recovery
Unless you pass --no-restart, install configures Windows' own recovery:
restart after --restart-delay seconds for the first two failures, then
stop trying, with the failure count resetting daily. It also sets the flag
that makes recovery apply to a clean exit with a nonzero code, not only to a
crash. Without that flag the recovery actions would be decorative here,
because this host reports its own failures as an orderly stop carrying an
exit code rather than by crashing.
cronstable service status decodes the last failure:
| Reported as | Meaning |
|---|---|
the configuration did not parse |
The -c path is missing or the YAML is invalid. |
the configuration can be written by an account other than SYSTEM and Administrators |
The service refused the -c path. The Application event log names the account and the fix. |
the service log could not be opened |
The bootstrap log path is not writable. |
the scheduler stopped with an error |
The scheduler itself raised. |
A stop you asked for is a clean stop and does not trigger recovery. That
includes POST /shutdown, which drains and stops the service.
The service stays stopped rather than being restarted.
Where a service logs
A service has no console. Its sys.stdout and sys.stderr are not
redirected somewhere unhelpful. They are None, so the default logging
setup writes into nothing and cannot even report that it failed.
service run therefore opens a rotating bootstrap log before it does
anything else, by default at <config directory>\logs\cronstable-service.log
(10 MB, five backups). It records the command line, the resolved
configuration path, the console decision, and the log level. Read it first
when a service fails to start.
If that file cannot be opened, the service refuses to start rather than running mute, because a service with no console and no log cannot be diagnosed at all.
Two refusals come before the bootstrap log opens: a -c path that does not
exist, and one that another account can write. Opening the log first would
have the service write, as SYSTEM, beside a configuration it does not trust.
A junction that another account planted at logs would then decide where
that write lands. The service therefore records these two refusals in the
Application event log as event ID 1020. The event's source is the service
name, and its summary field holds the reason and the fix. See Windows Event
Log for the record's layout.
Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'cronstable'; ID = 1020 } -MaxEvents 1 | Format-List Message
A logging: section in the configuration still takes precedence. The daemon
applies it on the first housekeeping pass, replacing the bootstrap handler.
Configure it as the real log and treat the bootstrap file as the startup
record. See logging configuration and the
rotating-file recipe in running on
Windows.
The missing streams also decide where job output goes. With
captureStdout or
captureStderr set, cronstable echoes each captured line to its own stdout
as well as saving it. A service has nowhere to echo to, so it skips that
copy and logs the fact once rather than once per batch. Reports,
GET /jobs/{name}/logs, the dashboard's live tail, and archiveOutput read
the saved lines, so they still see everything the job wrote.
--console and job termination
By default a service has no console, and that has one visible consequence: the graceful step of stopping a job cannot be delivered.
The daemon normally terminates a job in two steps: a trappable
CTRL_BREAK_EVENT to the job's process group, then, killTimeout seconds
later, a taskkill /F /T of the process tree. The break needs a console
shared between the daemon and the job. Without one, the graceful step is
skipped and the forced stop is immediate, so killTimeout bounds nothing.
That applies to executionTimeout, to concurrencyPolicy: Replace, and to
a cancel from the API.
--console allocates a console for the service so the two-step works. It is
off by default, because a console changes what a job inherits:
- The job is genuinely attached to that console. It can open
CONIN$andCONOUT$,GetConsoleWindowno longer returns NULL, and apauseorset /pin a.cmdcan still block. - Straight after allocating, cronstable points the service's own standard
handles at nothing, so an uncaptured job does not inherit a console
stdinthat blocks instead of reaching end of file. That closes the handle half. It does not undo console membership, which is inherited separately and is the entire reason for allocating one. - A console costs one extra
conhost.exeper service.
Turn it on when killTimeout has to mean something. Leave it off otherwise.
Removing
cronstable service stop
cronstable service remove
remove stops the service first and tolerates its already being stopped. If
anything else holds a handle to the service (the Services console and Task
Manager's Services tab both do), Windows marks it for deletion instead of
deleting it. A reinstall then fails with "the service is pending deletion".
Close those windows and retry. status and install both name that
condition when they hit it.
What the service does not change
Everything else behaves as it does in the foreground: the same
configuration, the same jobs, the same HTTP control API and
dashboard, the same durable state. @reboot jobs fire once
per OS boot as documented.
The scheduler drains running jobs on stop, and the SCM is told the stop is still in progress for as long as that takes. A job that runs for an hour therefore does not make the service look unresponsive.
Resource limits under the service
Resource limits apply under the service: each run of a
job with resourceLimits gets a Job Object of its own. Without --console,
two details differ from a run in the foreground:
- The run has no console to inherit, so Windows starts a conhost.exe inside the run's job. That conhost.exe counts toward the run's memory cap and appears in a monitored run's process tree. The process cap does not count it.
- Stopping the run begins with the immediate tree kill described in
--consoleand job termination, andTerminateJobObjectthen ends any process left in the run's job.
See resource limits on Windows.
Related pages
- Running on Windows for the rest of the platform
- Windows Event Log for publishing outcomes where Windows monitoring looks
- Logging Configuration for the
logging:section - CLI Reference for every subcommand
- Troubleshooting for problem, cause, and fix entries