From 3d8e9dedd8a06937220662faa606f3692ef090b5 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 8 Oct 2026 23:36:36 -0700 Subject: [PATCH] Keep the Windows daemon alive and let Reconnect restart it 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 --- Tools/windows/PACKAGING.md | 18 ++- Tools/windows/PackageRuntime.ps1 | 43 +++-- .../Tests/Packaging.Scheduler.Tests.ps1 | 63 ++++++++ graphcode-windows/src/App.zig | 22 ++- graphcode-windows/src/DaemonClient.zig | 55 +++++++ graphcode-windows/src/DaemonSupervisor.zig | 151 ++++++++++++++++++ 6 files changed, 340 insertions(+), 12 deletions(-) diff --git a/Tools/windows/PACKAGING.md b/Tools/windows/PACKAGING.md index b2948799..f29a85b1 100644 --- a/Tools/windows/PACKAGING.md +++ b/Tools/windows/PACKAGING.md @@ -128,6 +128,18 @@ it, setup verifies/installs its own directory. `-InstallRoot` supports custom locations and must also be supplied when managing that custom installation. `-NoScheduledTask` retains the explicit development/portable mode. +The daemon task has a logon trigger and a one-minute repeating trigger with +`MultipleInstancesPolicy` `IgnoreNew`, the Windows counterpart of launchd +`KeepAlive`: a tick is a no-op while the daemon runs and relaunches it within a +minute once it has stopped, and battery limits never refuse or kill it. (Task +Scheduler's restart-on-failure setting is deliberately not used; it fires only +when a task cannot launch, never when the launched process exits.) Upgrade and +Uninstall disable the task before ending it, so neither is fought by the +trigger; a refused Uninstall re-registers it. In the shell, `Ctrl+R` Reconnect +also starts the registered task (`schtasks /Run`, no elevation) when the daemon +endpoint is missing and no daemon is starting, and falls back to launching a +shell-owned daemon when no task is registered. The status line reports the outcome. + Standalone provenance checks use the selected package's declared provider pins, so an older setup can verify another release whose pins changed. Signed-package catalog verification authenticates those declarations. Repository commands @@ -299,7 +311,11 @@ hash mismatch; no certificate is added to trust stores. This contract requires the SDK on the build/CI host, not the installation target, and is not proof of production publisher trust. `Packaging.Scheduler.Tests.ps1` exercises an owned idle task through native stop/delete and verifies the actual missing-task -HRESULT without suppressing account or permission errors. +HRESULT without suppressing account or permission errors. It also registers the +generated daemon task under an owned name with its action swapped for a harmless +command, and proves on the real scheduler that the repeating trigger relaunches +an action that has exited and that `Stop-InstalledDaemon` disables the task so an +explicit stop is not undone (this part waits about two minutes). ## Publishing a Windows release diff --git a/Tools/windows/PackageRuntime.ps1 b/Tools/windows/PackageRuntime.ps1 index 70e614a6..7763988a 100644 --- a/Tools/windows/PackageRuntime.ps1 +++ b/Tools/windows/PackageRuntime.ps1 @@ -269,10 +269,16 @@ function Get-InstalledDaemons { ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected) }) } +function Disable-DaemonTask([string] $name) { + # The repeating trigger would otherwise restart a daemon that an explicit stop just ended. + $result = Invoke-PackageCommand "schtasks.exe" @("/Change", "/TN", $name, "/DISABLE") + Require ($result.ExitCode -eq 0) "scheduled-task disable failed: $($result.Output)" +} function Stop-InstalledDaemon { $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } $identity = Get-TaskIdentity $support if (Test-DaemonTask $identity.name) { + Disable-DaemonTask $identity.name $result = Invoke-PackageCommand "schtasks.exe" @("/End", "/TN", $identity.name) Require ($result.ExitCode -eq 0) "scheduled-task stop failed: $($result.Output)" } @@ -293,31 +299,44 @@ function Remove-DaemonTask { $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } $identity = Get-TaskIdentity $support if (-not (Test-DaemonTask $identity.name)) { return } + Disable-DaemonTask $identity.name $result = Invoke-PackageCommand "schtasks.exe" @("/End", "/TN", $identity.name) Require ($result.ExitCode -eq 0) "scheduled-task stop failed: $($result.Output)" $result = Invoke-PackageCommand "schtasks.exe" @("/Delete", "/TN", $identity.name, "/F") Require ($result.ExitCode -eq 0) "scheduled-task removal failed: $($result.Output)" } -function Start-DaemonTask { - $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } - $identity = Get-TaskIdentity $support - New-Item -ItemType Directory -Force $support | Out-Null - $xmlPath = Join-Path (Split-Path $InstallRoot -Parent) "GraphCode-daemon-task.xml" - $taskName = Xml-Escape $identity.name +function New-DaemonTaskXml([hashtable] $identity, [string] $support) { $sid = Xml-Escape $identity.sid $command = Xml-Escape (Join-Path $env:SystemRoot "System32\cmd.exe") $arguments = Xml-Escape "/d /s /c `"set `"GRAPHCODE_SUPPORT_DIR=$support`"`&`&`"$InstallRoot\bin\graphcoded.exe`"`"" $workingDirectory = Xml-Escape (Join-Path $InstallRoot "bin") - $xml = @" + # launchd runs the macOS daemon with KeepAlive; the Windows equivalent is a one-minute + # repeating trigger. MultipleInstancesPolicy IgnoreNew makes every tick a no-op while the + # daemon runs and restarts it, whatever its exit code, once it has stopped. Task Scheduler's + # own RestartOnFailure is not used: it fires only when a task cannot launch, never when a + # launched process exits. Uninstall and upgrade disable the task before ending it, so an + # explicit stop is not undone by the next tick. Battery limits would otherwise refuse or kill + # the daemon on laptops. + return @" GraphCode daemon for $sid - true$sid + + true$sid + PT1Mfalse2000-01-01T00:00:00true + $sidInteractiveTokenLeastPrivilege - IgnoreNewtruePT0S + IgnoreNewfalsefalsetruePT0S $command$arguments$workingDirectory "@ +} +function Start-DaemonTask { + $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } + $identity = Get-TaskIdentity $support + New-Item -ItemType Directory -Force $support | Out-Null + $xmlPath = Join-Path (Split-Path $InstallRoot -Parent) "GraphCode-daemon-task.xml" + $xml = New-DaemonTaskXml $identity $support [IO.File]::WriteAllText($xmlPath, $xml, [Text.Encoding]::Unicode) $result = Invoke-PackageCommand "schtasks.exe" @("/Create", "/TN", $identity.name, "/XML", $xmlPath, "/F") Require ($result.ExitCode -eq 0) "scheduled-task registration failed: $($result.Output)" @@ -497,8 +516,11 @@ function Uninstall-Package { $installed = Test-Path -LiteralPath $InstallRoot if ($installed) { Assert-InstallRootIdle $daemonManaged } $daemonWasRunning = $false + $daemonTaskExisted = $false if ($daemonManaged) { $daemonWasRunning = @(Get-InstalledDaemons).Count -gt 0 + $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } + $daemonTaskExisted = Test-DaemonTask (Get-TaskIdentity $support).name Stop-InstalledDaemon } $bin = Join-Path $InstallRoot "bin" @@ -551,7 +573,8 @@ function Uninstall-Package { $rollbackErrors.Add("restoring shortcuts from '$shortcutBackup': $($_.Exception.Message)") } } - if ($daemonWasRunning -and $restored) { + # Stopping disabled the task, so a refused uninstall must re-register it, not just restart. + if (($daemonWasRunning -or $daemonTaskExisted) -and $restored) { try { Start-DaemonTask } catch { $rollbackErrors.Add("restarting the daemon: $($_.Exception.Message)") } diff --git a/Tools/windows/Tests/Packaging.Scheduler.Tests.ps1 b/Tools/windows/Tests/Packaging.Scheduler.Tests.ps1 index 15f7348d..36d481e9 100644 --- a/Tools/windows/Tests/Packaging.Scheduler.Tests.ps1 +++ b/Tools/windows/Tests/Packaging.Scheduler.Tests.ps1 @@ -51,6 +51,69 @@ try { Remove-DaemonTask if (Test-DaemonTask $identity.name) { throw "Idle task was not removed" } Write-Output "Native missing-task HRESULT 0x80070002 and idle-task stop/delete: PASS" + + # The installed task definition must keep the daemon alive the way launchd KeepAlive does: + # restart it once it has stopped, never refuse or kill it on battery, and let an explicit + # stop stick. + [xml] $taskXml = New-DaemonTaskXml $identity $fixture + $ns = New-Object Xml.XmlNamespaceManager($taskXml.NameTable) + $ns.AddNamespace("t", "http://schemas.microsoft.com/windows/2004/02/mit/task") + function Get-TaskSetting([string] $name) { $taskXml.SelectSingleNode("//t:Settings/t:$name", $ns).InnerText } + $expectedSettings = [ordered]@{ + MultipleInstancesPolicy = "IgnoreNew" + DisallowStartIfOnBatteries = "false" + StopIfGoingOnBatteries = "false" + StartWhenAvailable = "true" + ExecutionTimeLimit = "PT0S" + } + foreach ($key in $expectedSettings.Keys) { + if ((Get-TaskSetting $key) -ne $expectedSettings[$key]) { + throw "Task setting $key is '$(Get-TaskSetting $key)', expected '$($expectedSettings[$key])'" + } + } + $triggers = @($taskXml.SelectNodes("//t:Triggers/*", $ns) | ForEach-Object { $_.LocalName }) + if (($triggers -join ",") -ne "LogonTrigger,TimeTrigger") { throw "Task triggers are '$($triggers -join ',')'" } + $repetition = $taskXml.SelectSingleNode("//t:TimeTrigger/t:Repetition", $ns) + if ($repetition.Interval -ne "PT1M" -or $repetition.SelectSingleNode("t:Duration", $ns)) { + throw "Daemon task must repeat every minute indefinitely" + } + + # Prove the behaviour on the real scheduler with a harmless action in place of the daemon. + New-Item -ItemType Directory -Force $fixture | Out-Null + $marker = Join-Path $fixture "runs.txt" + $taskXml.SelectSingleNode("//t:Exec/t:Command", $ns).InnerText = Join-Path $env:SystemRoot "System32\cmd.exe" + $taskXml.SelectSingleNode("//t:Exec/t:Arguments", $ns).InnerText = "/d /c echo run>>`"$marker`"" + $taskXml.SelectSingleNode("//t:Exec/t:WorkingDirectory", $ns).InnerText = $fixture + $xmlPath = Join-Path $fixture "daemon-task.xml" + [IO.File]::WriteAllText($xmlPath, $taskXml.OuterXml, [Text.Encoding]::Unicode) + $created = Invoke-PackageCommand "schtasks.exe" @("/Create", "/TN", $identity.name, "/XML", $xmlPath, "/F") + if ($created.ExitCode -ne 0) { throw "Daemon task definition was rejected by the scheduler: $($created.Output)" } + function Get-RunCount { if (Test-Path -LiteralPath $marker) { @(Get-Content -LiteralPath $marker).Count } else { 0 } } + function Wait-RunCount([int] $count, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + while ((Get-RunCount) -lt $count -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 500 } + return (Get-RunCount) -ge $count + } + $started = Invoke-PackageCommand "schtasks.exe" @("/Run", "/TN", $identity.name) + if ($started.ExitCode -ne 0) { throw "Starting the daemon task failed: $($started.Output)" } + if (-not (Wait-RunCount 1 30)) { throw "The daemon task did not run its action" } + # The action has already exited; only the repeating trigger can run it again. + if (-not (Wait-RunCount 2 150)) { throw "A stopped daemon task was not restarted by the repeating trigger" } + Write-Output "Daemon task restarts after its process ends: PASS" + + Stop-InstalledDaemon + $runsAtStop = Get-RunCount + Start-Sleep -Seconds 70 + if ((Get-RunCount) -ne $runsAtStop) { throw "The daemon task restarted after an explicit stop" } + $live = $folder.GetTask($identity.name.Split("\")[-1]) + try { + if ($live.Enabled) { throw "An explicitly stopped daemon task is still enabled" } + } finally { + [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($live) + } + Remove-DaemonTask + if (Test-DaemonTask $identity.name) { throw "Daemon task was not removed" } + Write-Output "Daemon task stays stopped after an explicit stop and is removable: PASS" } finally { try { if (Test-DaemonTask $identity.name) { diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index f4c39447..47106c6d 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -1562,9 +1562,11 @@ pub const App = struct { // secret; connection retries replace it with the real endpoint. const endpoint = self.client.currentDaemonStartupEndpoint(self.allocator) catch &.{}; const lock_name = self.client.currentDaemonLockName(self.allocator) catch &.{}; + const task_name = self.client.currentDaemonTaskName(self.allocator) catch &.{}; defer if (endpoint.len != 0) self.allocator.free(endpoint); defer if (lock_name.len != 0) self.allocator.free(lock_name); - if (endpoint.len != 0 and lock_name.len != 0) self.daemon.start(endpoint, lock_name); + defer if (task_name.len != 0) self.allocator.free(task_name); + if (endpoint.len != 0 and lock_name.len != 0) _ = self.daemon.recover(endpoint, lock_name, task_name); if (daemon_supervisor_test_hook) { const state: usize = if (self.daemon.owned) 1 else if (self.daemon.status().len == 0) 2 else 3; _ = c.SetPropW( @@ -5961,9 +5963,27 @@ pub const App = struct { ); } + /// Reconnect also brings a stopped daemon back: a user who presses it after the daemon + /// ended expects the connection to return, as it does under launchd on macOS. + fn recoverDaemon(self: *App) void { + const endpoint = self.client.currentDaemonStartupEndpoint(self.allocator) catch return; + defer self.allocator.free(endpoint); + const lock_name = self.client.currentDaemonLockName(self.allocator) catch return; + defer self.allocator.free(lock_name); + const task_name = self.client.currentDaemonTaskName(self.allocator) catch &.{}; + defer if (task_name.len != 0) self.allocator.free(task_name); + switch (self.daemon.recover(endpoint, lock_name, task_name)) { + .not_needed => {}, + .task_started => self.setStatus("Started the GraphCode daemon task; reconnecting"), + .spawned => self.setStatus("Restarted the GraphCode daemon; reconnecting"), + .failed => self.setStatus(self.daemon.status()), + } + } + fn handleAction(self: *App, action: InputRouter.Action) void { switch (action) { .reconnect => { + self.recoverDaemon(); self.client.reconnect(); }, .open_folder => self.openFolder(), diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig index 00c8d53a..389d926c 100644 --- a/graphcode-windows/src/DaemonClient.zig +++ b/graphcode-windows/src/DaemonClient.zig @@ -369,6 +369,11 @@ pub const DaemonClient = struct { return daemonLockName(allocator); } + pub fn currentDaemonTaskName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + _ = self; + return daemonTaskName(allocator); + } + fn validateSupportDirectory(allocator: std.mem.Allocator, support_directory: []const u8) !void { const normalized = try normalizedSupportPath(allocator, support_directory); defer std.heap.page_allocator.free(normalized); @@ -1844,6 +1849,56 @@ pub fn daemonLockName(allocator: std.mem.Allocator) ![]u8 { return daemonLockNameFor(allocator, support); } +/// Name of the per-user scheduled task that Tools\windows\PackageRuntime.ps1 +/// (`Get-TaskIdentity`) registers for the daemon that owns `support_directory`: +/// `GraphCode\graphcoded-` plus the first 32 hex digits of SHA-256 over +/// `|`. +pub fn daemonTaskNameFor(allocator: std.mem.Allocator, support_directory: []const u8) ![]u8 { + const normalized = try normalizedSupportPath(allocator, support_directory); + defer allocator.free(normalized); + for (normalized) |*byte| { + if (byte.* == '/') byte.* = '\\'; + } + const trimmed = std.mem.trimRight(u8, normalized, "\\"); + const sid = try currentSID(allocator); + defer allocator.free(sid); + const identity = try std.fmt.allocPrint(allocator, "{s}|{s}", .{ sid, trimmed }); + defer allocator.free(identity); + const hash = try sha256Hex(allocator, identity); + defer allocator.free(hash); + return std.fmt.allocPrint(allocator, "GraphCode\\graphcoded-{s}", .{hash[0..32]}); +} + +pub fn daemonTaskName(allocator: std.mem.Allocator) ![]u8 { + const support = try supportDirectory(allocator); + defer allocator.free(support); + return daemonTaskNameFor(allocator, support); +} + +test "daemon task name follows the installer's identity for any spelling of the support path" { + const allocator = std.testing.allocator; + const sid = try currentSID(allocator); + defer allocator.free(sid); + const identity = try std.fmt.allocPrint(allocator, "{s}|c:\\fixture\\.graphcode", .{sid}); + defer allocator.free(identity); + const hash = try sha256Hex(allocator, identity); + defer allocator.free(hash); + const expected = try std.fmt.allocPrint(allocator, "GraphCode\\graphcoded-{s}", .{hash[0..32]}); + defer allocator.free(expected); + for ([_][]const u8{ + "C:\\Fixture\\.graphcode", + "C:\\Fixture\\.graphcode\\", + "c:/fixture/./.graphcode", + }) |spelling| { + const name = try daemonTaskNameFor(allocator, spelling); + defer allocator.free(name); + try std.testing.expectEqualStrings(expected, name); + } + const other = try daemonTaskNameFor(allocator, "C:\\Fixture\\.graphcode-other"); + defer allocator.free(other); + try std.testing.expect(!std.mem.eql(u8, expected, other)); +} + fn sha256Hex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); diff --git a/graphcode-windows/src/DaemonSupervisor.zig b/graphcode-windows/src/DaemonSupervisor.zig index 54760ef7..f3cd7542 100644 --- a/graphcode-windows/src/DaemonSupervisor.zig +++ b/graphcode-windows/src/DaemonSupervisor.zig @@ -2,9 +2,24 @@ const std = @import("std"); const c = @import("Win32.zig").c; pub const Probe = enum { available, busy, missing, unknown }; +pub const Recovery = enum { not_needed, task_started, spawned, failed }; +const TaskState = enum { registered, absent }; +const RecoveryAction = enum { none, run_task, spawn_owned, unreachable_state }; const startup_reservation_timeout_ms: i64 = 5_000; +const task_command_timeout_ms: u32 = 10_000; const ReservationWait = enum { acquired, missing, timed_out }; +/// A stopped daemon is only restarted when nothing is listening and no daemon holds, or is +/// acquiring, the lifetime lock; the installed scheduled task is preferred because it lets the +/// daemon outlive the shell, as launchd does on macOS. +fn recoveryAction(probe: Probe, lock_exists: bool, task: TaskState) RecoveryAction { + return switch (probe) { + .available, .busy => .none, + .unknown => .unreachable_state, + .missing => if (lock_exists) .none else if (task == .registered) .run_task else .spawn_owned, + }; +} + pub const Supervisor = struct { allocator: std.mem.Allocator, process: c.HANDLE = null, @@ -15,6 +30,50 @@ pub const Supervisor = struct { owned: bool = false, failure: []u8 = &.{}, + /// Starts the daemon when it is not running: through the installed scheduled task when one + /// is registered (no elevation needed), otherwise as a child of this shell. Bounded by the + /// task commands' own timeout, so it never retries on its own; a later Reconnect retries. + pub fn recover( + self: *Supervisor, + endpoint: []const u8, + lock_name: []const u8, + task_name: []const u8, + ) Recovery { + self.setFailure(""); + const probe = probeEndpoint(endpoint); + const lock_exists = daemonLockExists(lock_name); + const task: TaskState = if (probe == .missing and !lock_exists and task_name.len != 0) + queryScheduledTask(self.allocator, task_name) + else + .absent; + switch (recoveryAction(probe, lock_exists, task)) { + .none => return .not_needed, + .unreachable_state => { + self.setFailure("Unable to determine daemon endpoint state"); + return .failed; + }, + .run_task => { + if (runScheduledTask(self.allocator, task_name)) return .task_started; + self.setFailure("GraphCode daemon task could not be started"); + return .failed; + }, + .spawn_owned => { + self.releaseExitedChild(); + self.start(endpoint, lock_name); + return if (self.status().len == 0) .spawned else .failed; + }, + } + } + + // A previous child that already exited still owns handles that start() would overwrite. + fn releaseExitedChild(self: *Supervisor) void { + if (self.process == null or c.WaitForSingleObject(self.process, 0) == c.WAIT_TIMEOUT) return; + self.closeProcess(); + self.closeShutdownEvent(); + self.closeStartupEvent(); + self.closeStartupHandoffEvent(); + } + pub fn start(self: *Supervisor, endpoint: []const u8, lock_name: []const u8) void { const acquired = self.acquireStartupReservationBounded(lock_name) catch { self.setFailure("Unable to reserve daemon startup"); @@ -323,6 +382,44 @@ pub const Supervisor = struct { } }; +/// Runs `schtasks.exe /TN ` hidden and returns whether it exited with code 0. +/// The task name is derived from a hash, but is still refused if it could break the command line. +fn schtasksSucceeds(allocator: std.mem.Allocator, verb: []const u8, task_name: []const u8) bool { + if (task_name.len == 0 or std.mem.indexOfAny(u8, task_name, "\"\r\n") != null) return false; + const system_root = std.process.getEnvVarOwned(allocator, "SystemRoot") catch + allocator.dupe(u8, "C:\\Windows") catch return false; + defer allocator.free(system_root); + const exe = std.fs.path.join(allocator, &.{ system_root, "System32", "schtasks.exe" }) catch return false; + defer allocator.free(exe); + const command = std.fmt.allocPrint(allocator, "\"{s}\" {s} /TN \"{s}\"", .{ exe, verb, task_name }) catch return false; + defer allocator.free(command); + const wide_exe = utf16(allocator, exe) catch return false; + defer allocator.free(wide_exe); + const wide_command = utf16(allocator, command) catch return false; + defer allocator.free(wide_command); + var startup: c.STARTUPINFOW = std.mem.zeroes(c.STARTUPINFOW); + startup.cb = @sizeOf(c.STARTUPINFOW); + var info: c.PROCESS_INFORMATION = undefined; + if (c.CreateProcessW(wide_exe.ptr, wide_command.ptr, null, null, 0, c.CREATE_NO_WINDOW, null, null, &startup, &info) == 0) return false; + defer _ = c.CloseHandle(info.hProcess); + _ = c.CloseHandle(info.hThread); + if (c.WaitForSingleObject(info.hProcess, task_command_timeout_ms) != c.WAIT_OBJECT_0) { + _ = c.TerminateProcess(info.hProcess, 1); + return false; + } + var code: c.DWORD = 1; + if (c.GetExitCodeProcess(info.hProcess, &code) == 0) return false; + return code == 0; +} + +fn queryScheduledTask(allocator: std.mem.Allocator, task_name: []const u8) TaskState { + return if (schtasksSucceeds(allocator, "/Query", task_name)) .registered else .absent; +} + +fn runScheduledTask(allocator: std.mem.Allocator, task_name: []const u8) bool { + return schtasksSucceeds(allocator, "/Run", task_name); +} + fn probeEndpoint(endpoint: []const u8) Probe { const wide = utf16(std.heap.page_allocator, endpoint) catch return .unknown; defer std.heap.page_allocator.free(wide); @@ -362,6 +459,60 @@ test "busy endpoint is never treated as missing" { try std.testing.expect(@intFromEnum(Probe.busy) != @intFromEnum(Probe.missing)); } +test "recovery only acts on a missing endpoint with no daemon starting" { + const probes = [_]Probe{ .available, .busy, .missing, .unknown }; + for (probes) |probe| { + for ([_]bool{ false, true }) |lock| { + for ([_]TaskState{ .registered, .absent }) |task| { + const action = recoveryAction(probe, lock, task); + if (probe == .missing and !lock) { + try std.testing.expectEqual( + if (task == .registered) RecoveryAction.run_task else RecoveryAction.spawn_owned, + action, + ); + } else if (probe == .unknown) { + try std.testing.expectEqual(RecoveryAction.unreachable_state, action); + } else { + try std.testing.expectEqual(RecoveryAction.none, action); + } + } + } + } +} + +test "scheduled task helpers report an unregistered task as absent and unrunnable" { + const name = try std.fmt.allocPrint( + std.testing.allocator, + "GraphCode\\graphcoded-unit-test-missing-{d}", + .{std.time.nanoTimestamp()}, + ); + defer std.testing.allocator.free(name); + try std.testing.expectEqual(TaskState.absent, queryScheduledTask(std.testing.allocator, name)); + try std.testing.expect(!runScheduledTask(std.testing.allocator, name)); +} + +test "recovery leaves a reachable daemon alone" { + const suffix = std.time.nanoTimestamp(); + const endpoint = try std.fmt.allocPrint( + std.testing.allocator, + "\\\\.\\pipe\\graphcode-supervisor-recover-{d}", + .{suffix}, + ); + defer std.testing.allocator.free(endpoint); + const wide = try utf16(std.testing.allocator, endpoint); + defer std.testing.allocator.free(wide); + const server = c.CreateNamedPipeW(wide.ptr, c.PIPE_ACCESS_DUPLEX, c.PIPE_TYPE_BYTE, 1, 512, 512, 0, null); + try std.testing.expect(server != c.INVALID_HANDLE_VALUE); + defer _ = c.CloseHandle(server); + var supervisor = Supervisor{ .allocator = std.testing.allocator }; + try std.testing.expectEqual( + Recovery.not_needed, + supervisor.recover(endpoint, "Local\\graphcode-supervisor-recover-lock", "GraphCode\\graphcoded-never-run"), + ); + try std.testing.expectEqual(@as(usize, 0), supervisor.status().len); + try std.testing.expect(!supervisor.owned); +} + test "daemon supervisor preserves Unicode sibling paths" { const path = try siblingDaemon(std.testing.allocator); defer std.testing.allocator.free(path);