Run satpulsed as a Windows service - #407
Conversation
daemon.Cmd owned flag parsing, config loading, logger construction, signal handling, and os.Exit, which tied the daemon to a console foreground process. The Windows service mode needs a different logger sink and a different cancellation source (the SCM control channel, not signals), so those concerns belong to the caller. Replace Cmd with Run(ctx, cfg, lg), which runs the daemon until ctx is cancelled and returns the settled error; a plain context cancellation is the normal stop path and returns nil, while a non-Canceled cancellation cause (such as the scan worker reporting lost serial input) is promoted to the returned error exactly as before. Move flag parsing into cmd/satpulsed, along with the mandatory-config check, which now happens outside the parser so future run modes can decide it for themselves. Export ExitCode, ExitUsage, ExitConfig, and ConfigFileEnvVar for use by cmd/satpulsed. Behaviour on the foreground path is unchanged. This is step 1 of plan/windows-svc.md (#338).
satpulsed built and ran in the foreground on Windows, but there was no way to run it as an SCM-managed background process that starts at boot and stops cleanly on a service stop. Add Windows-only service support to cmd/satpulsed, dispatched before the foreground path: - --install registers the service (start type automatic), baking --win-svc, the config file, and --log-file into the registered command as absolute paths, since the SCM launches services from a different working directory. With --install-dir the executable is first copied there and the copy registered, so the running service does not lock the development binary; what install created is recorded under the service registry Parameters key. - --uninstall stops the service if needed, unregisters it and the Event Log source, and removes only what install recorded. It requires no config file, so a broken or deleted config does not prevent removal. - --win-svc runs the svc.Run handler, mapping SCM Stop/Shutdown onto the context cancellation that daemon.Run already treats as a clean stop. Config loading and the --log-file logger are set up inside Execute, so a bad config is reported to the SCM as StartPending -> Stopped with an Event Log entry instead of a silent pre-service exit. Failures return a service-specific exit code, since the SCM would misread the sysexits-style codes as Win32 error numbers. The daemon slog stream goes to --log-file in service mode; the Windows Event Log carries only the service-lifecycle events and the terminal error, written by the wrapper. A !windows counterpart keeps the platform hooks no-ops elsewhere. Fixes #338
The Windows service support it describes is implemented.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 38f6c752b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if err := os.Remove(copiedExe); err != nil && !os.IsNotExist(err) { | ||
| return err |
There was a problem hiding this comment.
Avoid deleting the currently running installed executable
When --install-dir was used and an administrator later invokes --uninstall using that installed copy, copiedExe refers to the currently running executable. Windows does not permit removing a running executable, so this returns an error after the service and Event Log source have already been deleted, leaving the copied binary and directory behind; once the service registry key disappears, a retry also loses the ownership metadata needed for cleanup. Handle self-uninstallation explicitly rather than removing the active image synchronously.
Useful? React with 👍 / 👎.
| copiedExe = filepath.Join(dir, "satpulsed.exe") | ||
| if err := copyFile(exe, copiedExe); err != nil { | ||
| return err |
There was a problem hiding this comment.
Defer copying files until installation preflight succeeds
With --install-dir, the executable and possibly its directory are created before connecting to the SCM or checking whether the service already exists. If any later step fails, these artifacts are left without recorded ownership, and a repeated install targeting an existing stopped service can overwrite its registered binary before returning service satpulsed already exists. Check the service first and roll back the copy and directory on subsequent installation failures.
Useful? React with 👍 / 👎.
The service support conflated registering the service with managing the executable's placement. --uninstall deleted the --install-dir copy, which broke when the installed copy itself ran the uninstall: Windows cannot delete a running image, so the command failed after the service and Event Log source were already gone, and the ownership metadata recorded in the registry vanished with the service key. The copy also happened before the SCM preflight, so a failed install left stray files behind. Separate the concerns. --register and --unregister (renamed from --install/--uninstall, since they manage only the SCM registration and, unlike uninstall, carry no expectation of file removal) never delete files, so the self-deletion problem disappears structurally and the registry Parameters bookkeeping goes away. --install-dir becomes --copy: an explicit convenience that copies the executable and registers the copy. Unregister prints the registered executable's path when it is not the running one, so the user knows what to remove by hand. Registration now connects to the SCM and checks for an existing service before copying, and removes the copy if a later step fails, so a failed registration leaves nothing behind.
The service flags were redesigned during implementation: --register and --unregister (renamed from --install/--uninstall) manage only the SCM registration and never delete files, and --install-dir became --copy. Update the archived plan to describe the implemented design, including the reasoning for separating registration from file placement.
Lets satpulsed run as a Windows service: an SCM-managed background process that starts at boot and stops cleanly on a service stop. This is the last piece of the Windows port on top of the compile port, serial I/O, and CI work that already landed. The design is
plan/windows-svc.md(archived here).The first commit is a behaviour-preserving refactor:
daemon.Cmdbecomesdaemon.Run(ctx, cfg, lg), with flag parsing, config loading, logger construction, signal handling, and process exit moved up intocmd/satpulsed. The service path needs a different logger sink (no console) and a different cancellation source (the SCM control channel rather than signals), which is exactly what the caller should own. A plain context cancellation is the normal stop path and returns nil; a non-Canceledcancellation cause (such as the scan worker reporting lost serial input) is promoted to the returned error as before.The rest adds the Windows-only service wrapper, dispatched before the foreground path.
--registerand--unregistermanage only the SCM registration; they never delete files, so unregistering cannot trip over Windows' inability to delete a running image, and no install-state bookkeeping is needed.--registerregisters the service (start type automatic) and its Event Log source, baking--win-svc, the config file, and--log-fileinto the registered command as absolute paths, since the SCM launches services from a different working directory. By default the running executable is registered in place, which suits an unpacked release zip. With--copy <dir>the executable is first copied there and the copy registered, so a running service does not lock the development binary; registration checks for an existing service before copying and removes the copy if a later step fails.--unregisterstops the service if needed and unregisters it and the Event Log source. It deletes no files: when the registered executable is not the one running, its path is printed so the user can remove it. It requires no config file, so a broken or deleted config does not prevent unregistration.--win-svcruns thesvc.Runhandler, mapping SCM Stop/Shutdown onto the context cancellation thatdaemon.Runtreats as a clean stop. Config loading and the--log-filelogger are set up insideExecute, so a bad config is reported to the SCM as StartPending -> Stopped with an Event Log entry instead of a silent pre-service exit. Failures are reported with a service-specific exit code, since the SCM would misread the sysexits-style codes as Win32 error numbers.Logging is split between two sinks: the daemon's slog stream goes to the
--log-filein service mode, while the Windows Event Log carries only the service-lifecycle events and the terminal error, written by the wrapper; the daemon package stays Windows-unaware. On other platforms the new hooks are no-ops and behaviour is unchanged.Closes #338.