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$ and CONOUT$, GetConsoleWindow no longer returns NULL, and a pause or set /p in a .cmd can still block.
  • Straight after allocating, cronstable points the service's own standard handles at nothing, so an uncaptured job does not inherit a console stdin that 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.exe per 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 --console and job termination, and TerminateJobObject then ends any process left in the run's job.

See resource limits on Windows.

Related pages