D217 — On Windows the native service is a per-user Scheduled Task, driven by PowerShell¶
Decision. internal/service gains a third platform: cartographer service
install|uninstall|start|stop|restart|status and service sync-timer work on
Windows through a per-user Scheduled Task in the \Cartographer\ folder
(Serve and Sync), registered with the PowerShell ScheduledTasks cmdlets from
a task XML written to %LOCALAPPDATA%\cartographer\tasks\. serve and sync
gain --log-file so the task's argv stays flat, and serve additionally listens
on a named event, which is the only graceful stop this platform can deliver. This
amends D73, which named launchd and systemd as the two implementations of the
native local service.
Why. Everything that reaches the server on this machine goes through this
package: connect offers to install the service, status and doctor report it,
upgrade-repair restarts it, kb rename restarts it. All of it answered
service: unsupported platform "windows", so a Windows client could talk to a
remote server and nothing else. The cost is a third set of platform commands to
keep in step with two others, and one genuinely new mechanism — the named event —
that has no counterpart to copy from.
Alternatives rejected.
- A real Windows service (SCM), the closest analogue of launchd/systemd. It has
actual supervision (
sc failure), and it is per-machine and needs administrator rights. That contradicts the premise of the package, whose paths all derive from one user's home and whose install must not prompt for elevation. A task registered for the current user needs none. (Corrected: that holds only when the task's logon trigger names the user. The first version leftUserIdout, which makes the trigger fire at any user's logon and needs administrator rights — verified on Windows 11 with a standard account, which gotAccess deniedregistering the serve task while the time-triggered sync task registered fine. The trigger now names the installing user.) schtasks.exeinstead of the cmdlets. Its/Queryoutput prints a localised state — an Italian Windows saysIn esecuzione— and this package already decided, in the comment abovelaunchdJobLoaded, that liveness is judged on exit codes because the text is localised.Get-ScheduledTaskreturns a state whose string form is locale-invariant, and even that is compared inside PowerShell so only an exit code crosses back.pwshinstead ofpowershell.exe. PowerShell 7 is an optional install. A service manager that works only where someone installed a newer shell is not a service manager.- Wrapping the action in
cmd.exe /c "… >> log 2>&1"instead of adding--log-file. It works, and it turns<Arguments>into a nested quoted command line thatEffectiveConfigPaththen has to parse to recover--config. That function exists to fail with a contextual error before anything is signalled (D121); a flat argv is the version of it that can be trusted. - Deriving "installed" from a scheduler query.
StatusandSyncTimerStatusdefine installed asos.Statof the definition on disk, andEffectiveConfigPathreads that file. Asking the scheduler instead would fork the meaning of the field across platforms and leaveEffectiveConfigPathwith nothing to read. - Reporting
Runningfrom the task'sState. A task distinguishesReadyfromRunning, which is more than launchd reports — andRunningmeans "the scheduler knows the job" on every platform. Whether it is serving is what/healthanswers, andHealthy/HealthSkipReasonalready carry that (D174). Stop-ScheduledTaskas the graceful stop forReplace. It is a kill. The in-flight requests and the debounced pushes that D76 WP4's drain flushes would be lost on every upgrade, silently.- A PATH in the task definition, mirroring D156. The format cannot express one:
an
Execaction hasCommand,ArgumentsandWorkingDirectory, and no environment block. It also does not need one — D156 exists because a launchd job inherits a minimal PATH that excludes Homebrew, while a task registered with an interactive token inherits the user's environment, where the machine and user PATH from the registry already contains the winget, Scoop and Chocolatey shims. This is a deliberate deviation from the plan's WP3, recorded here rather than worked around in a wrapper.
Consequences.
--log-fileis additive and means two slightly different things, on purpose. Forserveit redirects the standard logger only: stdout carries the MCP protocol in stdio transport, and redirecting it would move the protocol into the log. Forsyncit redirects stdout and stderr as well, because a sync reports itself withfmt.Printand has no protocol on stdout. Both append and neither rotates — launchd does not rotate either, and a policy invented here would be a second source of truth for something no other platform has.- The graceful stop is a named event in the
Local\namespace (defaults.WindowsShutdownEventName), created byserveand set by the Manager. Reachable by another process of the same user in the same session and by nobody else, which is the same reachability as sending SIGTERM on unix — not a new exposure. If the event cannot be created,servelogs and starts anyway; if it cannot be opened,Replacefails loudly rather than claiming a drain it did not perform. signalGracefulrestarts the task explicitly, and that line must not be removed. A Scheduled Task is not a supervisor:RestartOnFailurereacts to a failure and a logon trigger fires at logon, so nothing brings back a process that drained and exited cleanly. Without the relaunch,Replacewould poll/healthagainst a port nobody is listening on for its whole timeout.- Four Task Scheduler defaults are overridden and each is pinned by a test:
the 3-day
ExecutionTimeLimit, the two battery settings, andStopOnIdleEnd— every one of them would kill a long-running server, and the battery pair only on a laptop, which is the machine least likely to be the one it was tested on. - The task XML is UTF-8 and its declaration names no encoding.
schtasks /Create /XMLdemands UTF-16LE with a BOM;Register-ScheduledTask -Xmltakes the document as a string, so the file can stay a plain UTF-8 document thatEffectiveConfigPathreads with an ordinary read. (Corrected by #328: the first version declaredencoding="UTF-8", and a PowerShell string is UTF-16 in memory, so Task Scheduler refused the declaration that contradicted its buffer and the task was never registered. The declaration now names no encoding.) - The
<Arguments>splitter is quote-aware, unlike the systemd one.extractUnitConfigPathcan usestrings.Fieldsbecause a unit'sExecStartis a unix command line; hereC:\Program Files\cartographer\server.yamlis an ordinary path, andstrings.Fieldswould report"C:\Programas the configured config path. os.Getuidreturns -1 on Windows and no Windows branch may reference it — the tests never stub it, so a branch that builtgui/-1/com.cartographer.servewould pass every one of them.noGUIDomainasserts no recorded command carries agui/target.TestUnsupportedPlatformnow usesplan9. It was the canary for thedefaultcase and it namedwindows, which this decision makes supported; the guarantee it protects — an unhandled GOOS refuses rather than half-works — is unchanged.- The
getenvseam is now part of the test contract of this package. A test that stubsuserHomeDirand notgetenvresolves the Windows paths through the real%LOCALAPPDATA%, which on thetest-windowsrunner means writing task definitions into the runner's actual profile.withTestHomeneutralises it; every test that does its own setup must too. - The schema validity of the two XML documents is not verified by CI. No test
registers a task — the runner would need one, and
make gatemust stay side-effect-free — so the element order follows what Task Scheduler's own export writes, which it re-imports. A future edit that reordersSettingson aesthetic grounds can therefore break registration without failing a single test; the first realservice installon Windows is the check. - A new config is written under
%APPDATA%, and an existing<home>\.configone is still read. A machine that ran a pre-Windows build, or that has a hand-written config in the unix location, keeps working:ConfigPathreturns that file while the roaming one is absent,installthen installs a task pointing at it, and nothing is moved or copied — a migration that silently relocated an operator's config would be a worse answer than reading it where it is.