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);