Skip to content

Keep the Windows daemon alive and let Reconnect restart it - #670

Merged
coneilen merged 1 commit into
mainfrom
coneilen-fix-windows-daemon-auto-recovery
Oct 9, 2026
Merged

coneilen merged 1 commit into
mainfrom
coneilen-fix-windows-daemon-auto-recovery

Conversation

@coneilen

@coneilen coneilen commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

On the installed beta18 candidate, once graphcoded stopped nothing restarted it: the scheduled task had only a logon trigger, and Ctrl+R Reconnect only redialed the pipe, so the shell and CLI stayed unavailable (errno 121) until Start-ScheduledTask or a re-logon. This keeps the daemon alive the way launchd KeepAlive does on macOS and lets Reconnect start it.

Changes

  • Installer task (Tools/windows/PackageRuntime.ps1): the task XML moves into New-DaemonTaskXml and gains a one-minute repeating trigger with MultipleInstancesPolicy IgnoreNew (a tick is a no-op while the daemon runs and relaunches it within a minute once it has stopped, whatever its exit code), and DisallowStartIfOnBatteries/StopIfGoingOnBatteries false.
  • Task Scheduler's RestartOnFailure is deliberately not used. I measured it on this machine: a task whose action exits non-zero, with a crash-style NTSTATUS code, or whose process is killed is not restarted (one run in 150 s with an interval of one minute); it only fires when the task cannot launch.
  • Explicit stop is not fought: Stop-InstalledDaemon and Remove-DaemonTask disable the task before /End, so Upgrade and Uninstall are not undone by the next tick. A refused Uninstall now re-registers the task, since stopping disabled it. Uninstall still ends and deletes the task; the Refuse Windows uninstall while GraphCode runs and make removal all-or-nothing #662 uninstall contracts pass unchanged.
  • Shell (DaemonSupervisor.zig, DaemonClient.zig, App.zig): Supervisor.recover runs on startup and on Ctrl+R. If the endpoint is missing and no daemon holds or is acquiring the lifetime lock, it starts the registered task with schtasks /Run (no elevation, no admin) and falls back to a shell-owned daemon when no task is registered. The task name is derived in Zig exactly as Get-TaskIdentity derives it. The status line reports the outcome. A single attempt per Reconnect; the existing client backoff handles the reconnection itself.
  • The CLI is not changed: with the task keeping the daemon alive it recovers on its own, and a one-shot CLI starting a daemon would be a second launch path to keep in step. Say so if you want it.
  • PACKAGING.md documents the task policy and the recovery path.

Test plan

Limits: CI and this session cannot prove recovery of a live Dev Box (installed candidate, real logon session, real kill). The scheduler behaviour is proven on the real Windows scheduler with a harmless action in place of graphcoded; the shell recovery is proven by the decision table, the real schtasks.exe helpers against an unregistered task, and the endpoint probe, not by a live daemon being killed and restarted. The live shell/daemon walkthroughs (DaemonHandoff.Live, tray, windows-shell) were not run locally. I ran everything under a scratch USERPROFILE, GRAPHCODE_SUPPORT_DIR, LOCALAPPDATA, APPDATA and TEMP with unique task names, and never touched the installed task or daemon.

RED: pwsh -NoProfile -File Tools\windows\Tests\Packaging.Scheduler.Tests.ps1 (new assertions, old PackageRuntime.ps1) -> The term 'New-DaemonTaskXml' is not recognized as a name of a cmdlet (missing function, a not-found failure and not a behavioural one; the behavioural finding is the measured one-run-in-150-s result above)
GREEN: pwsh -NoProfile -File Tools\windows\Tests\Packaging.Scheduler.Tests.ps1 -> Daemon task restarts after its process ends: PASS; Daemon task stays stopped after an explicit stop and is removable: PASS (exit 0, 2m01s); zig test src\DaemonSupervisor.zig (pinned 0.15.2, winghostty include) -> All 8 tests passed (RED for it was the compile error: use of undeclared identifier 'TaskState' and 'Recovery'); zig test src\DaemonClient.zig --test-filter "daemon task name" -> All 1 tests passed
REGRESSION: zig test src\App.zig with the WindowsShell.Tests.ps1 arguments -> All 905 tests passed; Tools\windows\Tests Packaging.Uninstall, Rollback, Signing, ScriptSigning, Standalone (18 cases), Release, PreviewCore (37) and SourceCustody (4/4) plus TrayDaemon.Tests.ps1 -> all exit 0

Checklist

  • I have read the Contributing Guidelines
  • I have signed off my commits (git commit -s) per the DCO
  • Tests pass locally (make test) (macOS target; not run, this change is Windows-only)
  • Code follows the existing style (make check) (macOS target; not run, this change is Windows-only)
  • I added the test/contract before the implementation and observed the intended RED failure

The installed daemon task had only a logon trigger, so a stopped graphcoded was never
restarted and Ctrl+R could not bring it back. Add a one-minute repeating trigger (IgnoreNew,
the launchd KeepAlive equivalent) and remove the battery limits; Stop-InstalledDaemon now
disables the task before ending it so upgrade/uninstall are not fought, and a refused
uninstall re-registers it. Reconnect starts the registered task when the daemon endpoint is
missing, falling back to a shell-owned daemon when no task exists.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Colin Neilens <coneilen@microsoft.com>
@coneilen
coneilen merged commit a603e05 into main Oct 9, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant