From 319d1eb71fe2ee3135bc3bdf86fb04e9e7a0917c Mon Sep 17 00:00:00 2001 From: "S. Ahmed" <198355839+DRanger666@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:26:26 +0600 Subject: [PATCH 1/5] fix: coordinate Codex daemon lifecycle with bounded helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe a responding daemon through the supported version command and restart only on an explicit request. Bound output collection and process exit, including helpers that close their pipes before exiting; terminate and reap only our child on timeout. Use the selected CODEX_HOME and prefer its managed executable. Keep absent and indeterminate states distinct, accepting the supported lifecycle detection/restart race. Motivated by codejunkienick’s PR #163 (https://github.com/Loongphy/codex-auth/pull/163); implements its daemon coordination idea with an explicit contract and independent code. No persistent configuration or registry schema changes. --- build.zig | 1 + src/workflows/codex_daemon.zig | 197 ++++++++++++++++++++++++++++++++ src/workflows/root.zig | 1 + tests/support/codex_daemon.zig | 30 +++++ tests/workflows_daemon_test.zig | 93 +++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 src/workflows/codex_daemon.zig create mode 100644 tests/support/codex_daemon.zig create mode 100644 tests/workflows_daemon_test.zig diff --git a/build.zig b/build.zig index c21e2695..49730dc7 100644 --- a/build.zig +++ b/build.zig @@ -94,6 +94,7 @@ pub fn build(b: *std.Build) void { "tests/tui_session_test.zig", "tests/tui_table_test.zig", "tests/workflows_core_test.zig", + "tests/workflows_daemon_test.zig", "tests/workflows_live_test.zig", }; diff --git a/src/workflows/codex_daemon.zig b/src/workflows/codex_daemon.zig new file mode 100644 index 00000000..c490f347 --- /dev/null +++ b/src/workflows/codex_daemon.zig @@ -0,0 +1,197 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const runtime = @import("../core/runtime.zig"); + +pub const restart_timeout_ms = 120_000; +const probe_timeout_ms = 5_000; +const output_limit = 16 * 1024; + +pub const Outcome = enum { not_running, running, unknown, unsupported, restarted, restart_failed, timed_out }; + +/// Coordinates the supported Codex lifecycle commands for one selected home. +pub const Client = struct { + allocator: std.mem.Allocator, + codex_home: []const u8, + executable: []u8, + + pub fn init(allocator: std.mem.Allocator, codex_home: []const u8) !Client { + return .{ + .allocator = allocator, + .codex_home = codex_home, + .executable = try resolveExecutable(allocator, codex_home), + }; + } + + pub fn deinit(self: *Client) void { + self.allocator.free(self.executable); + } + + /// A socket path alone never establishes responsiveness. A missing endpoint + /// is a best-effort absence check; errors and stale endpoints stay unknown. + pub fn probe(self: *const Client) !Outcome { + if (!supported()) return .unsupported; + const socket = try std.fs.path.join(self.allocator, &.{ self.codex_home, "app-server-control", "app-server-control.sock" }); + defer self.allocator.free(socket); + std.Io.Dir.cwd().access(runtime.io(), socket, .{}) catch |err| switch (err) { + error.FileNotFound => return .not_running, + else => return .unknown, + }; + const result = self.runLifecycle("version", probe_timeout_ms) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return .unknown, + }; + defer self.allocator.free(result.stdout); + if (!result.succeeded) return .unknown; + var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, result.stdout, .{}) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return .unknown, + }; + defer parsed.deinit(); + if (parsed.value != .object) return .unknown; + const status = parsed.value.object.get("status") orelse return .unknown; + const version = parsed.value.object.get("appServerVersion") orelse return .unknown; + if (status != .string or version != .string or version.string.len == 0) return .unknown; + return if (std.mem.eql(u8, status.string, "running")) .running else .unknown; + } + + /// Explicit requests intentionally include unchanged-file recovery. The + /// caller supplies an operational budget, not a persisted/user setting. + pub fn restart(self: *const Client, timeout_ms: u64) !Outcome { + const state = try self.probe(); + if (state != .running) return state; + // Codex can start a daemon if it disappears after this probe. Accept + // that lifecycle race rather than adding our own process management. + const result = self.runLifecycle("restart", timeout_ms) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return .restart_failed, + }; + defer self.allocator.free(result.stdout); + if (result.timed_out) return .timed_out; + return if (result.succeeded) .restarted else .restart_failed; + } + + const Result = struct { stdout: []u8, succeeded: bool, timed_out: bool }; + + fn runLifecycle(self: *const Client, command: []const u8, timeout_ms: u64) !Result { + const io = runtime.io(); + const deadline = std.Io.Clock.Timestamp.now(io, .awake).addDuration(.{ + .clock = .awake, + .raw = .fromMilliseconds(@intCast(timeout_ms)), + }); + var env = try runtime.currentEnviron().createMap(self.allocator); + defer env.deinit(); + try env.put("CODEX_HOME", self.codex_home); + var child = try std.process.spawn(io, .{ + .argv = &.{ self.executable, "app-server", "daemon", command }, + .environ_map = &env, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .pipe, + .create_no_window = true, + }); + // Child.kill terminates and reaps only this helper, and is idempotent. + defer child.kill(io); + var buffer: std.Io.File.MultiReader.Buffer(2) = undefined; + var reader: std.Io.File.MultiReader = undefined; + reader.init(self.allocator, io, buffer.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer reader.deinit(); + while (reader.fill(64, .{ .deadline = deadline })) |_| { + if (reader.reader(0).buffered().len > output_limit or reader.reader(1).buffered().len > output_limit) + return error.StreamTooLong; + } else |err| switch (err) { + error.EndOfStream => {}, + error.Timeout => return .{ .stdout = try self.allocator.dupe(u8, ""), .succeeded = false, .timed_out = true }, + else => return err, + } + try reader.checkAnyError(); + const term = waitForExit(&child, deadline) catch |err| switch (err) { + error.Timeout => return .{ .stdout = try self.allocator.dupe(u8, ""), .succeeded = false, .timed_out = true }, + else => return err, + }; + return .{ + .stdout = try reader.toOwnedSlice(0), + .succeeded = switch (term) { + .exited => |code| code == 0, + else => false, + }, + .timed_out = false, + }; + } +}; + +fn supported() bool { + return builtin.os.tag == .linux or builtin.os.tag == .macos or builtin.os.tag == .windows; +} + +fn resolveExecutable(allocator: std.mem.Allocator, home: []const u8) ![]u8 { + const name = if (builtin.os.tag == .windows) "codex.exe" else "codex"; + const layouts = [_][]const []const u8{ + &.{ "packages", "app-server-daemon", "current", "bin" }, + &.{ "packages", "standalone", "current", "bin" }, + &.{ "packages", "standalone", "current" }, + }; + for (layouts) |layout| { + const dir = try std.fs.path.join(allocator, layout); + defer allocator.free(dir); + const path = try std.fs.path.join(allocator, &.{ home, dir, name }); + if (isFile(path)) return path; + allocator.free(path); + } + var env = try runtime.currentEnviron().createMap(allocator); + defer env.deinit(); + var paths = std.mem.splitScalar(u8, env.get("PATH") orelse "", if (builtin.os.tag == .windows) ';' else ':'); + while (paths.next()) |dir| { + const path = try std.fs.path.join(allocator, &.{ dir, name }); + if (isFile(path)) { + defer allocator.free(path); + return try runtime.realPathFileAbsoluteAlloc(allocator, path); + } + allocator.free(path); + } + return try allocator.dupe(u8, name); +} + +fn isFile(path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(runtime.io(), path, .{}) catch return false; + return stat.kind == .file; +} + +// Output EOF is not proof of exit. Poll without blocking so a helper that +// closes its pipes but stays alive is still covered by the same deadline. +fn waitForExit(child: *std.process.Child, deadline: std.Io.Clock.Timestamp) !std.process.Child.Term { + const io = runtime.io(); + while (true) { + if (builtin.os.tag == .windows) { + const windows = std.os.windows; + const zero: windows.LARGE_INTEGER = 0; + switch (windows.ntdll.NtWaitForSingleObject(child.id.?, .FALSE, &zero)) { + .WAIT_0 => return try child.wait(io), + .TIMEOUT => {}, + else => return error.ChildWaitFailed, + } + } else { + var raw_status: c_int = 0; + const pid = std.c.waitpid(child.id.?, &raw_status, std.c.W.NOHANG); + switch (std.posix.errno(pid)) { + .SUCCESS => if (pid != 0) { + const status: u32 = @bitCast(raw_status); + // waitpid has reaped our helper. Close its remaining pipes + // and relinquish ownership so deferred kill cannot reuse PID. + if (child.stdout) |file| file.close(io); + if (child.stderr) |file| file.close(io); + child.stdout = null; + child.stderr = null; + child.id = null; + if (std.c.W.IFEXITED(status)) return .{ .exited = std.c.W.EXITSTATUS(status) }; + if (std.c.W.IFSIGNALED(status)) return .{ .signal = std.c.W.TERMSIG(status) }; + return .{ .unknown = status }; + }, + .INTR => {}, + else => return error.ChildWaitFailed, + } + } + if (std.Io.Clock.Timestamp.now(io, .awake).compare(.gte, deadline)) return error.Timeout; + const next = std.Io.Clock.Timestamp.now(io, .awake).addDuration(.{ .clock = .awake, .raw = .fromMilliseconds(10) }); + try (if (next.compare(.lt, deadline)) next else deadline).wait(io); + } +} diff --git a/src/workflows/root.zig b/src/workflows/root.zig index bf6bc6cd..d5cd7adf 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -28,6 +28,7 @@ const workflow_env = @import("env.zig"); const targets = @import("targets.zig"); const usage_refresh = @import("usage.zig"); pub const results = @import("results.zig"); +pub const codex_daemon = @import("codex_daemon.zig"); pub const nowMilliseconds = workflow_env.nowMilliseconds; pub const nowSeconds = workflow_env.nowSeconds; diff --git a/tests/support/codex_daemon.zig b/tests/support/codex_daemon.zig new file mode 100644 index 00000000..c4495c8a --- /dev/null +++ b/tests/support/codex_daemon.zig @@ -0,0 +1,30 @@ +const std = @import("std"); +const fs = @import("codex_auth").core.compat_fs; + +/// A strict lifecycle fake: only version and restart are accepted. Restart +/// records the selected home and verifies persistence before its side effect. +pub fn install(dir: fs.Dir, body: []const u8) !void { + try dir.makePath("app-server-control"); + try dir.writeFile(.{ .sub_path = "app-server-control/app-server-control.sock", .data = "fake endpoint" }); + try dir.makePath("packages/app-server-daemon/current/bin"); + const script = try std.mem.concat(std.testing.allocator, u8, &.{ + "#!/bin/sh\nset -eu\n" ++ + "[ \"$#\" = 3 ] && [ \"$1\" = app-server ] && [ \"$2\" = daemon ] || exit 90\n" ++ + "printf '%s\\n' \"$3\" >> \"$CODEX_HOME/lifecycle-calls\"\n", + body, + }); + defer std.testing.allocator.free(script); + try dir.writeFile(.{ .sub_path = "packages/app-server-daemon/current/bin/codex", .data = script }); + var file = try dir.openFile("packages/app-server-daemon/current/bin/codex", .{ .mode = .read_write }); + defer file.close(); + try file.chmod(0o700); +} + +pub const running = "if [ \"$3\" = version ]; then printf '%s\\n' '{\"status\":\"running\",\"appServerVersion\":\"0.160.0\",\"backend\":\"pid\"}'; exit 0; fi\n"; +pub const succeed = running ++ "[ \"$3\" = restart ] || exit 91\nprintf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\n"; + +pub fn read(dir: fs.Dir, path: []const u8) ![]u8 { + var file = try dir.openFile(path, .{}); + defer file.close(); + return try file.readToEndAlloc(std.testing.allocator, 16 * 1024); +} diff --git a/tests/workflows_daemon_test.zig b/tests/workflows_daemon_test.zig new file mode 100644 index 00000000..044704b4 --- /dev/null +++ b/tests/workflows_daemon_test.zig @@ -0,0 +1,93 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const daemon = @import("codex_auth").workflows.codex_daemon; +const fs = @import("codex_auth").core.compat_fs; +const runtime = @import("codex_auth").core.runtime; +const fake = @import("support/codex_daemon.zig"); + +test "Scenario: Given a missing daemon endpoint then lifecycle commands are not invoked" { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.not_running, try client.restart(100)); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("lifecycle-calls", .{})); +} + +test "Scenario: Given a responsive daemon then explicit restart forwards the selected home exactly once" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.succeed); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.restarted, try client.restart(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nrestart\n", calls); + const forwarded = try fake.read(tmp.dir, "restarted-home"); + defer std.testing.allocator.free(forwarded); + try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); +} + +test "Scenario: Given failed or malformed daemon probes then explicit restart remains indeterminate" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + for ([_][]const u8{ "exit 1\n", "printf '%s\\n' '{}'\n", "printf '%s\\n' '{\"status\":\"notRunning\"}'\n" }) |body| { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, body); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.unknown, try client.restart(100)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\n", calls); + } +} + +test "Scenario: Given a restart failure then retry makes another explicit restart attempt" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.running ++ "exit 42\n"); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.restart_failed, try client.restart(1_000)); + try std.testing.expectEqual(daemon.Outcome.restart_failed, try client.restart(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nrestart\nversion\nrestart\n", calls); +} + +test "Scenario: Given a restart helper that stalls with open or closed pipes then timeout kills and reaps only the helper" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + for ([_][]const u8{ "", "exec 1>&- 2>&-\n" }) |close_pipes| { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const body = try std.mem.concat(std.testing.allocator, u8, &.{ fake.running, "printf '%s' \"$$\" > \"$CODEX_HOME/helper-pid\"\n", close_pipes, "while :; do :; done\n" }); + defer std.testing.allocator.free(body); + try fake.install(tmp.dir, body); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + const started = std.Io.Timestamp.now(runtime.io(), .awake); + try std.testing.expectEqual(daemon.Outcome.timed_out, try client.restart(200)); + try std.testing.expect(started.untilNow(runtime.io(), .awake).toMilliseconds() < 3_000); + const pid_bytes = try fake.read(tmp.dir, "helper-pid"); + defer std.testing.allocator.free(pid_bytes); + const pid = try std.fmt.parseInt(std.posix.pid_t, pid_bytes, 10); + try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, .CONT)); + var status: c_int = 0; + try std.testing.expectEqual(@as(std.posix.pid_t, -1), std.c.waitpid(pid, &status, std.c.W.NOHANG)); + try std.testing.expectEqual(std.posix.E.CHILD, std.posix.errno(-1)); + } +} From dabade5d237a3ba4a2f4fcb3844d6294b4517511 Mon Sep 17 00:00:00 2001 From: "S. Ahmed" <198355839+DRanger666@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:40:05 +0600 Subject: [PATCH 2/5] feat(switch): add explicit daemon restart and stale-auth recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --restart-daemon to one-shot query, picker, previous-account and JSON paths at their shared activation boundary. Persist account and registry state before daemon coordination; explicit requests include unchanged-file recovery and retry. Preserve the existing JSON stdout document and put lifecycle diagnostics and quoted home-specific recovery commands on stderr. Requested restart failures return the existing operational exit code 1. Reject the option with --live and retain other activation behavior. Cover selected-home propagation, persistence ordering, unchanged selection, previous and multi-match routes, failed selection, absent and indeterminate daemons, JSON failures and retry. Native suite: 446 passed, 5 skipped; required isolated list smoke and Windows/macOS cross-builds passed. Related: #162 and codejunkienick’s #163. This opts into disruptive restarts without adding persistent config or changing the registry schema. --- docs/commands/switch.md | 26 +++++ src/cli/commands/switch.zig | 12 ++ src/cli/help.zig | 6 + src/cli/output.zig | 57 ++++++++++ src/cli/types.zig | 1 + src/workflows/preflight.zig | 1 + src/workflows/switch.zig | 63 +++++++---- tests/cli_behavior_test.zig | 52 +++++++++ tests/cli_integration_test.zig | 193 ++++++++++++++++++++++++++++++++- 9 files changed, 390 insertions(+), 21 deletions(-) diff --git a/docs/commands/switch.md b/docs/commands/switch.md index 23e2d6e8..b1a74116 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -8,6 +8,7 @@ codex-auth switch [--api|--skip-api] codex-auth switch --live [--api|--skip-api] codex-auth switch codex-auth switch --json +codex-auth switch [|-] --restart-daemon ``` ## Previous Switch @@ -65,3 +66,28 @@ When switching succeeds: 5. The success message uses the same identity label as singleton rows, for example `Switched to me(test@example.com)`. The previous-account pointer is internal CLI state and is not included in JSON responses. + +## Applying a Switch to a Running Daemon + +Modern Codex clients can share a background app-server daemon that caches authentication. Updating `auth.json` alone may leave that daemon using the previous account, including when another terminal client connects. + +One-shot switches detect a responsive daemon and print a restart hint on stderr. Restarting is opt-in: + +```shell +codex-auth switch personal --restart-daemon +codex-auth switch - --restart-daemon +codex-auth switch personal --json --restart-daemon +``` + +`--restart-daemon` requests a restart after account activation and registry persistence succeed. It also restarts when the selected credentials are already on disk, so it can repair stale daemon authentication or retry a failed restart. It can interrupt all attached sessions. Reconnect, verify the account, and resume work as needed. + +- The selected `CODEX_HOME` is passed to both lifecycle commands. The home’s managed executable is preferred, with supported legacy layouts and a PATH fallback. +- Responsiveness is checked with `codex app-server daemon version`. A missing control endpoint is treated as best-effort absence: no daemon is started. A stale endpoint or other probe error leaves the state unconfirmed; an explicit request then fails without invoking restart. +- The responsiveness check occurs immediately before restart. If the daemon disappears afterward, Codex’s supported restart command can start one; this race is accepted. +- The restart helper has a fixed 120-second timeout. Failure or timeout preserves the selected files, reports an unconfirmed daemon account state, and returns exit code 1. Only the helper process is terminated and reaped on timeout; daemon shutdown/startup may already have begun. No automatic retry or credential rollback occurs. +- Diagnostics and manual status/recovery commands are printed on stderr, with the selected home and executable. A successful version response proves responsiveness, not account identity. +- In JSON mode, stdout remains the existing switch-selection document. It can accompany exit code 1 when the subsequent requested restart fails. +- `--restart-daemon` is rejected with `--live`. Live switching, login, import, and remove retain their existing activation behavior and may also require manual daemon coordination. +- Older installations without a daemon endpoint remain a successful no-op. Platforms outside Linux, macOS, and Windows receive an honest unsupported-coordination hint for explicit requests. + +No daemon-restart preference is persisted and the registry schema is unchanged. diff --git a/src/cli/commands/switch.zig b/src/cli/commands/switch.zig index 794d894d..3ddb58e5 100644 --- a/src/cli/commands/switch.zig +++ b/src/cli/commands/switch.zig @@ -11,6 +11,14 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa var opts: types.SwitchOptions = .{}; for (args) |raw_arg| { const arg = std.mem.sliceTo(raw_arg, 0); + if (std.mem.eql(u8, arg, "--restart-daemon")) { + if (opts.restart_daemon) { + freeTarget(allocator, opts.target); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "duplicate `--restart-daemon` for `switch`.", .{}); + } + opts.restart_daemon = true; + continue; + } if (std.mem.eql(u8, arg, "--live")) { if (opts.live) { freeTarget(allocator, opts.target); @@ -68,6 +76,10 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa else .{ .query = try allocator.dupe(u8, arg) }; } + if (opts.live and opts.restart_daemon) { + freeTarget(allocator, opts.target); + return common.usageErrorResultWithJson(allocator, .switch_account, json_requested, "`--restart-daemon` cannot be combined with `--live`.", .{}); + } if (opts.live and opts.json) { freeTarget(allocator, opts.target); return common.usageErrorResultWithJson(allocator, .switch_account, true, "`--live` cannot be combined with `--json`.", .{}); diff --git a/src/cli/help.zig b/src/cli/help.zig index 4196e514..2f2041f8 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -49,6 +49,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "switch [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "switch "); try writeCommandDetail(out, use_color, "switch --json"); + try writeCommandDetail(out, use_color, "switch --restart-daemon"); try writeCommandSummary(out, use_color, "remove", "Remove one or more accounts"); try writeCommandDetail(out, use_color, "remove [--live] [--api|--skip-api]"); try writeCommandDetail(out, use_color, "remove ..."); @@ -214,6 +215,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch \n"); try out.writeAll(" codex-auth switch --json\n"); + try out.writeAll(" codex-auth switch [|-] --restart-daemon\n"); }, .remove_account => { try out.writeAll(" codex-auth remove [--live] [--api|--skip-api]\n"); @@ -287,6 +289,9 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --api Load usage and account data from APIs.\n"); try out.writeAll(" --skip-api Load usage and account data from local data only (may be inaccurate).\n"); try out.writeAll(" --json Emit one machine-readable JSON document for non-interactive switch.\n"); + try out.writeAll(" --restart-daemon\n"); + try out.writeAll(" Restart a responsive daemon after switching, including unchanged-file recovery.\n"); + try out.writeAll(" Can interrupt attached sessions; cannot be combined with --live.\n"); try out.writeAll(" \n"); try out.writeAll(" Switch directly when the target resolves to one account.\n"); try out.writeAll(" - Switch to the previous active account.\n"); @@ -365,6 +370,7 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth switch --api\n"); try out.writeAll(" codex-auth switch --skip-api\n"); try out.writeAll(" codex-auth switch personal\n"); + try out.writeAll(" codex-auth switch personal --restart-daemon\n"); try out.writeAll(" codex-auth switch john@example.com\n"); try out.writeAll(" codex-auth switch 02\n"); try out.writeAll(" codex-auth switch work\n"); diff --git a/src/cli/output.zig b/src/cli/output.zig index df5054cd..2d0abce0 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -1,4 +1,6 @@ const std = @import("std"); +const builtin = @import("builtin"); +const daemon = @import("../workflows/codex_daemon.zig"); const display_rows = @import("../tui/display.zig"); const registry = @import("../registry/root.zig"); const io_util = @import("../core/io_util.zig"); @@ -10,6 +12,61 @@ const io = @import("io.zig"); const UsageError = types.UsageError; +pub fn printCodexDaemonDiagnostic(home: []const u8, executable: []const u8, outcome: daemon.Outcome, requested: bool) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + try writeCodexDaemonDiagnosticTo(stderr.out(), home, executable, outcome, requested); + try stderr.out().flush(); +} + +pub fn writeCodexDaemonDiagnosticTo(out: *std.Io.Writer, home: []const u8, executable: []const u8, outcome: daemon.Outcome, requested: bool) !void { + switch (outcome) { + .not_running => { + if (requested) try out.writeAll("Account files switched successfully; no running Codex daemon was found, so no restart was attempted.\n"); + return; + }, + .restarted => { + try out.writeAll("Account files switched successfully; Codex daemon restart completed. Reconnect and verify the selected account.\n"); + return; + }, + .running => try out.writeAll("A running Codex daemon may still use cached credentials. Use `switch --restart-daemon` to apply the selection or repair stale authentication. Restarting can interrupt attached sessions.\n"), + .unknown => try out.writeAll(if (requested) + "Account files switched successfully; daemon responsiveness could not be confirmed. The requested restart was not attempted.\n" + else + "Account files switched successfully; daemon responsiveness could not be confirmed. Its account state is unconfirmed.\n"), + .restart_failed => try out.writeAll("Account files switched successfully; requested daemon restart failed. The daemon may be stopping or starting; its account state is unconfirmed.\n"), + .timed_out => try out.writeAll("Account files switched successfully; daemon restart did not complete within 120 seconds. The daemon may still be stopping or starting; its account state is unconfirmed.\n"), + .unsupported => { + if (!requested) return; + try out.writeAll("Account files switched successfully; automatic daemon coordination is unsupported on this platform. Check which lifecycle commands your Codex installation supports.\n"); + }, + } + try out.writeAll("Check daemon status before retrying (a version response does not verify account identity):\n "); + try writeDaemonCommandTo(out, home, executable, "version"); + try out.writeAll("\nIf a restart is needed:\n "); + try writeDaemonCommandTo(out, home, executable, "restart"); + try out.writeAll("\n"); +} + +fn writeDaemonCommandTo(out: *std.Io.Writer, home: []const u8, executable: []const u8, command: []const u8) !void { + try out.writeAll(if (builtin.os.tag == .windows) "$env:CODEX_HOME = " else "CODEX_HOME="); + try writeShellArgumentTo(out, home); + try out.writeAll(if (builtin.os.tag == .windows) "; & " else " "); + try writeShellArgumentTo(out, executable); + try out.print(" app-server daemon {s}", .{command}); +} + +fn writeShellArgumentTo(out: *std.Io.Writer, arg: []const u8) !void { + try out.writeAll("'"); + var parts = std.mem.splitScalar(u8, arg, '\''); + try out.writeAll(parts.first()); + while (parts.next()) |part| { + try out.writeAll(if (builtin.os.tag == .windows) "''" else "'\\''"); + try out.writeAll(part); + } + try out.writeAll("'"); +} + fn importReportStyle(outcome: registry.ImportOutcome) []const u8 { return switch (outcome) { .imported, .updated => style.ansi.green, diff --git a/src/cli/types.zig b/src/cli/types.zig index e65792db..f49613e8 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -35,6 +35,7 @@ pub const SwitchOptions = struct { live: bool = false, api_mode: ApiMode = .default, json: bool = false, + restart_daemon: bool = false, }; pub const RemoveOptions = struct { selectors: [][]const u8, diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index 6ef58c61..9e935bec 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -24,6 +24,7 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.PreviousAccountUnavailable or err == error.RegistryError or err == error.CodexLoginFailed or + err == error.CodexDaemonRestartFailed or err == error.ListLiveRequiresTty or err == error.TuiOutputUnavailable or err == error.CurlRequired or diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 4ecd29a1..798f4974 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -5,6 +5,7 @@ const live_flow = @import("live.zig"); const preflight = @import("preflight.zig"); const query_mod = @import("query.zig"); const results = @import("results.zig"); +const daemon = @import("codex_daemon.zig"); const ensureLiveTty = preflight.ensureLiveTty; const resolveSwitchQueryLocally = query_mod.resolveSwitchQueryLocally; @@ -56,9 +57,7 @@ pub fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: return err; }; if (selected_account_key == null) return; - try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, &loaded.display.reg); - try cli.output.printSwitchedAccount(allocator, &loaded.display.reg, selected_account_key.?); + try activateSelection(allocator, codex_home, &loaded.display.reg, selected_account_key.?, opts); return; } @@ -114,7 +113,7 @@ fn handleSwitchQuery( opts: cli.types.SwitchOptions, query: []const u8, ) !void { - if (opts.json) return handleSwitchQueryJson(allocator, codex_home, query); + if (opts.json) return handleSwitchQueryJson(allocator, codex_home, query, opts); var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); @@ -147,9 +146,7 @@ fn handleSwitchQuery( }, }; if (selected_account_key == null) return; - try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, ®); - try cli.output.printSwitchedAccount(allocator, ®, selected_account_key.?); + try activateSelection(allocator, codex_home, ®, selected_account_key.?, opts); return; } @@ -187,15 +184,14 @@ fn handleSwitchPrevious( } } - try registry.activateAccountByKey(allocator, codex_home, ®, previous_account_key); - try registry.saveRegistry(allocator, codex_home, ®); - try cli.output.printSwitchedAccount(allocator, ®, previous_account_key); + try activateSelection(allocator, codex_home, ®, previous_account_key, opts); } fn handleSwitchQueryJson( allocator: std.mem.Allocator, codex_home: []const u8, query: []const u8, + opts: cli.types.SwitchOptions, ) !void { var reg = registry.loadRegistry(allocator, codex_home) catch |err| return printJsonWorkflowError(err); defer reg.deinit(allocator); @@ -224,16 +220,45 @@ fn handleSwitchQueryJson( }, }; - registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key) catch |err| return printJsonMutationError(err); - registry.saveRegistry(allocator, codex_home, ®) catch |err| return printJsonMutationError(err); + try activateSelection(allocator, codex_home, ®, selected_account_key, opts); +} - var result = results.buildSwitchResult( - allocator, - ®, - selected_account_key, - ) catch |err| return printJsonWorkflowError(err); - defer result.deinit(allocator); - try cli.json_output.printSwitchResult(&result); +// One-shot picker, query, previous, and JSON routes share this boundary. Live, +// login, import, and remove retain their existing activation behavior. +fn activateSelection( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + account_key: []const u8, + opts: cli.types.SwitchOptions, +) !void { + registry.activateAccountByKey(allocator, codex_home, reg, account_key) catch |err| { + return if (opts.json) printJsonMutationError(err) else err; + }; + registry.saveRegistry(allocator, codex_home, reg) catch |err| { + return if (opts.json) printJsonMutationError(err) else err; + }; + if (opts.json) { + var result = results.buildSwitchResult(allocator, reg, account_key) catch |err| return printJsonWorkflowError(err); + defer result.deinit(allocator); + try cli.json_output.printSwitchResult(&result); + } else { + try cli.output.printSwitchedAccount(allocator, reg, account_key); + } + var client = daemon.Client.init(allocator, codex_home) catch |err| { + if (err == error.OutOfMemory) return err; + try cli.output.printCodexDaemonDiagnostic(codex_home, "codex", .unknown, opts.restart_daemon); + if (opts.restart_daemon) return error.CodexDaemonRestartFailed; + return; + }; + defer client.deinit(); + const outcome = if (opts.restart_daemon) + try client.restart(daemon.restart_timeout_ms) + else + try client.probe(); + try cli.output.printCodexDaemonDiagnostic(codex_home, client.executable, outcome, opts.restart_daemon); + if (opts.restart_daemon and (outcome == .unknown or outcome == .restart_failed or outcome == .timed_out)) + return error.CodexDaemonRestartFailed; } fn printJsonWorkflowError(err: anyerror) anyerror { diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index bd3541b1..2453f76a 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const cli = @import("codex_auth").cli; const fs = @import("codex_auth").core.compat_fs; const registry = @import("codex_auth").registry; @@ -510,6 +511,57 @@ test "Scenario: Given switch command help when rendering then target forms and m try std.testing.expect(std.mem.indexOf(u8, help, "Options:\n --live Open the live switch UI.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Switch directly when the target resolves to one account.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "If a target is ambiguous") == null); + try std.testing.expect(std.mem.indexOf(u8, help, "--restart-daemon") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "unchanged-file recovery") != null); +} + +test "Scenario: Given explicit restart on one-shot switch forms then parser preserves the request" { + const gpa = std.testing.allocator; + const cases = [_][]const [:0]const u8{ + &.{ "codex-auth", "switch", "beta", "--restart-daemon" }, + &.{ "codex-auth", "switch", "--restart-daemon", "beta", "--json" }, + &.{ "codex-auth", "switch", "-", "--restart-daemon" }, + &.{ "codex-auth", "switch", "--skip-api", "--restart-daemon" }, + &.{ "codex-auth", "switch", "--api", "--restart-daemon" }, + }; + for (cases) |args| { + var result = try cli.commands.parseArgs(gpa, args); + defer cli.commands.freeParseResult(gpa, &result); + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| try std.testing.expect(opts.restart_daemon), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } + } +} + +test "Scenario: Given live or duplicate explicit restart then parser rejects the combination" { + const gpa = std.testing.allocator; + const cases = [_]struct { args: []const [:0]const u8, message: []const u8 }{ + .{ .args = &.{ "codex-auth", "switch", "--live", "--restart-daemon" }, .message = "`--restart-daemon` cannot be combined with `--live`." }, + .{ .args = &.{ "codex-auth", "switch", "beta", "--restart-daemon", "--restart-daemon", "--json" }, .message = "duplicate `--restart-daemon`" }, + }; + for (cases) |case| { + var result = try cli.commands.parseArgs(gpa, case.args); + defer cli.commands.freeParseResult(gpa, &result); + try expectUsageError(result, .switch_account, case.message); + } +} + +test "Scenario: Given daemon timeout then diagnostic distinguishes selected files and quotes recovery commands" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + try cli.output.writeCodexDaemonDiagnosticTo(&aw.writer, "/tmp/home's space", "/tmp/bin's space/codex", .timed_out, true); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "Account files switched successfully") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "120 seconds") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "account state is unconfirmed") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "app-server daemon version") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "app-server daemon restart") != null); + const quoted_home = if (builtin.os.tag == .windows) "'/tmp/home''s space'" else "'/tmp/home'\\''s space'"; + try std.testing.expect(std.mem.indexOf(u8, aw.written(), quoted_home) != null); } test "Scenario: Given remove command help when rendering then options explain live API all and target forms" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 8338d25a..84bb727c 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -4,6 +4,7 @@ const fs = @import("codex_auth").core.compat_fs; const builtin = @import("builtin"); const registry = @import("codex_auth").registry; const fixtures = @import("support/fixtures.zig"); +const fake_daemon = @import("support/codex_daemon.zig"); const cli_integration_install_prefix_env = "CODEX_AUTH_CLI_INTEGRATION_INSTALL_PREFIX"; const cli_integration_project_root_env = "CODEX_AUTH_CLI_INTEGRATION_PROJECT_ROOT"; @@ -751,6 +752,181 @@ fn seedRegistryWithAccounts( try registry.saveRegistry(allocator, codex_home, ®); } +fn seedDaemonSwitchFixture(allocator: std.mem.Allocator, home_root: []const u8, unchanged: bool) ![]u8 { + try seedRegistryWithAccounts(allocator, home_root, if (unchanged) "beta@example.com" else "alpha@example.com", &.{ + .{ .email = "alpha@example.com", .alias = "alpha" }, + .{ .email = "beta@example.com", .alias = "beta" }, + }); + const home = try codexHomeAlloc(allocator, home_root); + errdefer allocator.free(home); + for ([_][]const u8{ "alpha@example.com", "beta@example.com" }) |email| { + const key = try fixtures.accountKeyForEmailAlloc(allocator, email); + defer allocator.free(key); + const path = try registry.accountAuthPath(allocator, home, key); + defer allocator.free(path); + const data = try fixtures.authJsonWithEmailPlan(allocator, email, "plus"); + defer allocator.free(data); + try fs.cwd().writeFile(.{ .sub_path = path, .data = data }); + if (std.mem.eql(u8, email, if (unchanged) "beta@example.com" else "alpha@example.com")) { + const active = try registry.activeAuthPath(allocator, home); + defer allocator.free(active); + try fs.cwd().writeFile(.{ .sub_path = active, .data = data }); + } + } + return home; +} + +test "Scenario: Given daemon-aware switching then explicit recovery preserves selection JSON and restart failures remain visible" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + const cases = [_]struct { unchanged: bool = false, requested: bool = true, json: bool = false, fail: bool = false, previous: bool = false, attempts: usize = 1 }{ + .{}, + .{ .json = true }, + .{ .unchanged = true }, + .{ .previous = true }, + .{ .unchanged = true, .requested = false }, + .{ .requested = false }, + .{ .fail = true }, + .{ .fail = true, .json = true, .attempts = 2 }, + }; + for (cases) |case| { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root); + const home = try seedDaemonSwitchFixture(gpa, root, case.unchanged); + defer gpa.free(home); + try tmp.dir.makePath("shell-home"); + const shell_home = try tmp.dir.realpathAlloc(gpa, "shell-home"); + defer gpa.free(shell_home); + var home_dir = try tmp.dir.openDir(".codex", .{}); + defer home_dir.close(); + const beta_key = try fixtures.accountKeyForEmailAlloc(gpa, "beta@example.com"); + defer gpa.free(beta_key); + if (case.previous) { + var reg = try registry.loadRegistry(gpa, home); + defer reg.deinit(gpa); + reg.previous_active_account_key = try gpa.dupe(u8, beta_key); + try registry.saveRegistry(gpa, home, ®); + } + const body = try std.fmt.allocPrint( + gpa, + "{s}" ++ + "[ \"$3\" = restart ] || exit 91\n" ++ + "grep -F 'access-beta@example.com' \"$CODEX_HOME/auth.json\" >/dev/null || exit 92\n" ++ + "grep -F '\"active_account_key\": \"{s}\"' \"$CODEX_HOME/accounts/registry.json\" >/dev/null || exit 93\n" ++ + "printf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\nexit {d}\n", + .{ fake_daemon.running, beta_key, @as(u8, if (case.fail) 42 else 0) }, + ); + defer gpa.free(body); + try fake_daemon.install(home_dir, body); + for (0..case.attempts) |_| { + const args: []const []const u8 = if (!case.requested) &.{ "switch", "beta" } else if (case.previous) &.{ "switch", "-", "--restart-daemon" } else if (case.json) &.{ "switch", "beta", "--json", "--restart-daemon" } else &.{ "switch", "beta", "--restart-daemon" }; + const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, shell_home, home, args); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + switch (result.term) { + .exited => |code| try std.testing.expectEqual(@as(u8, if (case.fail) 1 else 0), code), + else => return error.TestUnexpectedResult, + } + if (case.fail) try std.testing.expect(std.mem.indexOf(u8, result.stderr, "requested daemon restart failed") != null); + if (!case.requested) try std.testing.expect(std.mem.indexOf(u8, result.stderr, "--restart-daemon") != null); + if (case.json) { + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + const doc = parsed.value.object; + try std.testing.expectEqual(@as(usize, 3), doc.count()); + try std.testing.expectEqualStrings("switch", doc.get("command").?.string); + try std.testing.expectEqualStrings("beta@example.com", doc.get("switched_to").?.object.get("email").?.string); + } + var reg = try registry.loadRegistry(gpa, home); + defer reg.deinit(gpa); + try std.testing.expectEqualStrings(beta_key, reg.active_account_key.?); + const actual_auth = try fake_daemon.read(home_dir, "auth.json"); + defer gpa.free(actual_auth); + const beta_auth = try fixtures.authJsonWithEmailPlan(gpa, "beta@example.com", "plus"); + defer gpa.free(beta_auth); + try std.testing.expectEqualStrings(beta_auth, actual_auth); + try std.testing.expectEqual(@as(u32, 4), reg.schema_version); + } + const calls = try fake_daemon.read(home_dir, "lifecycle-calls"); + defer gpa.free(calls); + const expected = if (!case.requested) "version\n" else if (case.attempts == 2) "version\nrestart\nversion\nrestart\n" else "version\nrestart\n"; + try std.testing.expectEqualStrings(expected, calls); + if (case.requested) { + const forwarded = try fake_daemon.read(home_dir, "restarted-home"); + defer gpa.free(forwarded); + try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); + } + } +} + +test "Scenario: Given absent or indeterminate daemons then switching never starts one" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + for ([_]bool{ false, true }) |stale| { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root); + const home = try seedDaemonSwitchFixture(gpa, root, false); + defer gpa.free(home); + var dir = try tmp.dir.openDir(".codex", .{}); + defer dir.close(); + if (stale) try fake_daemon.install(dir, "exit 1\n"); + const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, root, home, &.{ "switch", "beta", "--restart-daemon", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + switch (result.term) { + .exited => |code| try std.testing.expectEqual(@as(u8, if (stale) 1 else 0), code), + else => return error.TestUnexpectedResult, + } + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("switch", parsed.value.object.get("command").?.string); + if (stale) { + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "restart was not attempted") != null); + const calls = try fake_daemon.read(dir, "lifecycle-calls"); + defer gpa.free(calls); + try std.testing.expectEqualStrings("version\n", calls); + } else { + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "no running Codex daemon") != null); + try std.testing.expectError(error.FileNotFound, dir.openFile("lifecycle-calls", .{})); + } + } +} + +test "Scenario: Given failed account selection then daemon lifecycle commands are not invoked" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root); + const home = try seedDaemonSwitchFixture(gpa, root, false); + defer gpa.free(home); + var dir = try tmp.dir.openDir(".codex", .{}); + defer dir.close(); + try fake_daemon.install(dir, fake_daemon.succeed); + const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, root, home, &.{ "switch", "missing", "--restart-daemon", "--json" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + try expectFailure(result); + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, result.stdout, .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("account_not_found", parsed.value.object.get("error").?.object.get("code").?.string); + try std.testing.expectError(error.FileNotFound, dir.openFile("lifecycle-calls", .{})); +} + fn makeUsageSnapshot(primary_used_percent: f64, secondary_used_percent: f64) registry.RateLimitSnapshot { return .{ .primary = .{ @@ -2677,12 +2853,18 @@ test "Scenario: Given switch query with multiple matches when running switch the const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); defer gpa.free(empty_path); + if (builtin.os.tag != .windows) { + var daemon_dir = try tmp.dir.openDir(".codex", .{}); + defer daemon_dir.close(); + try fake_daemon.install(daemon_dir, fake_daemon.succeed); + } + const result = try runCliWithIsolatedHomeAndPathAndStdin( gpa, project_root, home_root, empty_path, - &[_][]const u8{ "switch", "team" }, + if (builtin.os.tag == .windows) &[_][]const u8{ "switch", "team" } else &[_][]const u8{ "switch", "team", "--restart-daemon" }, "2\n", ); defer gpa.free(result.stdout); @@ -2694,7 +2876,7 @@ test "Scenario: Given switch query with multiple matches when running switch the try std.testing.expect(std.mem.indexOf(u8, result.stdout, "beta@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "solo@example.com") == null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Switched to team-b(beta@example.com)") != null); - try std.testing.expectEqualStrings("", result.stderr); + try std.testing.expectEqualStrings(if (builtin.os.tag == .windows) "" else "Account files switched successfully; Codex daemon restart completed. Reconnect and verify the selected account.\n", result.stderr); const auth_after = try fixtures.readFileAlloc(gpa, active_auth_path); defer gpa.free(auth_after); @@ -2704,6 +2886,13 @@ test "Scenario: Given switch query with multiple matches when running switch the defer loaded.deinit(gpa); try std.testing.expect(loaded.active_account_key != null); try std.testing.expect(std.mem.eql(u8, loaded.active_account_key.?, beta_key)); + if (builtin.os.tag != .windows) { + const calls_path = try fs.path.join(gpa, &.{ codex_home, "lifecycle-calls" }); + defer gpa.free(calls_path); + const calls = try fixtures.readFileAlloc(gpa, calls_path); + defer gpa.free(calls); + try std.testing.expectEqualStrings("version\nrestart\n", calls); + } } test "Scenario: Given switch query with no matches when running switch then it explains accepted target forms" { From 5baaa35d0bc7d059ed84bde3621ab04cbb0bf85a Mon Sep 17 00:00:00 2001 From: "S. Ahmed" <198355839+DRanger666@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:53:14 +0600 Subject: [PATCH 3/5] fix(daemon): resolve relative PATH entries safely Use the cwd-relative canonicalization API for PATH candidates, which can be relative. The absolute-only API asserts on a valid relative PATH entry when managed Codex layouts are unavailable. Add an isolated CLI regression that removes the managed executable, finds the lifecycle helper through a relative PATH directory, and verifies restart and selected-home propagation. Linux suite: 447 passed, 5 skipped. --- src/workflows/codex_daemon.zig | 2 +- tests/cli_integration_test.zig | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/workflows/codex_daemon.zig b/src/workflows/codex_daemon.zig index c490f347..7b179137 100644 --- a/src/workflows/codex_daemon.zig +++ b/src/workflows/codex_daemon.zig @@ -144,7 +144,7 @@ fn resolveExecutable(allocator: std.mem.Allocator, home: []const u8) ![]u8 { const path = try std.fs.path.join(allocator, &.{ dir, name }); if (isFile(path)) { defer allocator.free(path); - return try runtime.realPathFileAbsoluteAlloc(allocator, path); + return try runtime.realPathFileAlloc(allocator, std.Io.Dir.cwd(), path); } allocator.free(path); } diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 84bb727c..edef61be 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -864,6 +864,45 @@ test "Scenario: Given daemon-aware switching then explicit recovery preserves se } } +test "Scenario: Given no managed executable then daemon restart resolves a relative PATH entry" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + const exe = try builtCliPathAlloc(gpa, project_root); + defer gpa.free(exe); + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root); + const home = try seedDaemonSwitchFixture(gpa, root, false); + defer gpa.free(home); + var dir = try tmp.dir.openDir(".codex", .{}); + defer dir.close(); + try fake_daemon.install(dir, fake_daemon.succeed); + try tmp.dir.makePath("fallback"); + try tmp.dir.rename(".codex/packages/app-server-daemon/current/bin/codex", "fallback/codex"); + var env = try getEnvMap(gpa); + defer env.deinit(); + try env.put("HOME", root); + try env.put("USERPROFILE", root); + try env.put("CODEX_HOME", home); + try env.put("PATH", "fallback"); + try env.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); + const result = try runCapture(gpa, root, &env, &.{ exe, "switch", "beta", "--restart-daemon" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); + const calls = try fake_daemon.read(dir, "lifecycle-calls"); + defer gpa.free(calls); + try std.testing.expectEqualStrings("version\nrestart\n", calls); + const forwarded = try fake_daemon.read(dir, "restarted-home"); + defer gpa.free(forwarded); + try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); +} + test "Scenario: Given absent or indeterminate daemons then switching never starts one" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; From 0836af1c647fb94547ebfaea81e854f0b096143f Mon Sep 17 00:00:00 2001 From: "S. Ahmed" <198355839+DRanger666@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:01:04 +0600 Subject: [PATCH 4/5] fix(daemon): skip non-executable Unix candidates Check executable access for Unix managed and PATH candidates so a non-executable regular file cannot mask a later usable Codex binary. Retain the regular native executable check on Windows. Extend the relative-PATH regression to ./fallback and empty PATH entries, and add an isolated CLI test with a non-executable decoy before the valid helper. No new resolver abstraction. Linux suite: 448 passed, 5 skipped; required isolated list smoke passed. --- src/workflows/codex_daemon.zig | 11 +++--- tests/cli_integration_test.zig | 63 +++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/workflows/codex_daemon.zig b/src/workflows/codex_daemon.zig index 7b179137..bacd15b0 100644 --- a/src/workflows/codex_daemon.zig +++ b/src/workflows/codex_daemon.zig @@ -134,7 +134,7 @@ fn resolveExecutable(allocator: std.mem.Allocator, home: []const u8) ![]u8 { const dir = try std.fs.path.join(allocator, layout); defer allocator.free(dir); const path = try std.fs.path.join(allocator, &.{ home, dir, name }); - if (isFile(path)) return path; + if (isExecutable(path)) return path; allocator.free(path); } var env = try runtime.currentEnviron().createMap(allocator); @@ -142,7 +142,7 @@ fn resolveExecutable(allocator: std.mem.Allocator, home: []const u8) ![]u8 { var paths = std.mem.splitScalar(u8, env.get("PATH") orelse "", if (builtin.os.tag == .windows) ';' else ':'); while (paths.next()) |dir| { const path = try std.fs.path.join(allocator, &.{ dir, name }); - if (isFile(path)) { + if (isExecutable(path)) { defer allocator.free(path); return try runtime.realPathFileAlloc(allocator, std.Io.Dir.cwd(), path); } @@ -151,9 +151,12 @@ fn resolveExecutable(allocator: std.mem.Allocator, home: []const u8) ![]u8 { return try allocator.dupe(u8, name); } -fn isFile(path: []const u8) bool { +fn isExecutable(path: []const u8) bool { const stat = std.Io.Dir.cwd().statFile(runtime.io(), path, .{}) catch return false; - return stat.kind == .file; + if (stat.kind != .file) return false; + if (builtin.os.tag != .windows) + std.Io.Dir.cwd().access(runtime.io(), path, .{ .execute = true }) catch return false; + return true; } // Output EOF is not proof of exit. Poll without blocking so a helper that diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index edef61be..e14fa34d 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -864,7 +864,7 @@ test "Scenario: Given daemon-aware switching then explicit recovery preserves se } } -test "Scenario: Given no managed executable then daemon restart resolves a relative PATH entry" { +test "Scenario: Given no managed executable then daemon restart resolves relative and empty PATH entries" { if (builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); @@ -872,6 +872,45 @@ test "Scenario: Given no managed executable then daemon restart resolves a relat try buildCliBinary(gpa, project_root); const exe = try builtCliPathAlloc(gpa, project_root); defer gpa.free(exe); + for ([_][]const u8{ "./fallback", "" }) |path| { + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root); + const home = try seedDaemonSwitchFixture(gpa, root, false); + defer gpa.free(home); + var dir = try tmp.dir.openDir(".codex", .{}); + defer dir.close(); + try fake_daemon.install(dir, fake_daemon.succeed); + try tmp.dir.makePath("fallback"); + try tmp.dir.rename(".codex/packages/app-server-daemon/current/bin/codex", if (path.len == 0) "codex" else "fallback/codex"); + var env = try getEnvMap(gpa); + defer env.deinit(); + try env.put("HOME", root); + try env.put("USERPROFILE", root); + try env.put("CODEX_HOME", home); + try env.put("PATH", path); + try env.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); + const result = try runCapture(gpa, root, &env, &.{ exe, "switch", "beta", "--restart-daemon" }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); + const calls = try fake_daemon.read(dir, "lifecycle-calls"); + defer gpa.free(calls); + try std.testing.expectEqualStrings("version\nrestart\n", calls); + const forwarded = try fake_daemon.read(dir, "restarted-home"); + defer gpa.free(forwarded); + try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); + } +} + +test "Scenario: Given a non-executable codex first in PATH then daemon restart uses the next executable" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); const root = try tmp.dir.realpathAlloc(gpa, "."); @@ -883,14 +922,20 @@ test "Scenario: Given no managed executable then daemon restart resolves a relat try fake_daemon.install(dir, fake_daemon.succeed); try tmp.dir.makePath("fallback"); try tmp.dir.rename(".codex/packages/app-server-daemon/current/bin/codex", "fallback/codex"); - var env = try getEnvMap(gpa); - defer env.deinit(); - try env.put("HOME", root); - try env.put("USERPROFILE", root); - try env.put("CODEX_HOME", home); - try env.put("PATH", "fallback"); - try env.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); - const result = try runCapture(gpa, root, &env, &.{ exe, "switch", "beta", "--restart-daemon" }); + try tmp.dir.makePath("decoy"); + try tmp.dir.writeFile(.{ .sub_path = "decoy/codex", .data = "not executable" }); + { + var file = try tmp.dir.openFile("decoy/codex", .{ .mode = .read_write }); + defer file.close(); + try file.chmod(0o600); + } + const decoy = try tmp.dir.realpathAlloc(gpa, "decoy"); + defer gpa.free(decoy); + const fallback = try tmp.dir.realpathAlloc(gpa, "fallback"); + defer gpa.free(fallback); + const path = try std.fmt.allocPrint(gpa, "{s}:{s}", .{ decoy, fallback }); + defer gpa.free(path); + const result = try runCliWithIsolatedHomeAndCodexHomeAndPath(gpa, project_root, root, home, path, &.{ "switch", "beta", "--restart-daemon" }); defer gpa.free(result.stdout); defer gpa.free(result.stderr); try expectSuccess(result); From afded660c31b29b83203e63a2708895d9a424f57 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 13:37:20 +0800 Subject: [PATCH 5/5] fix: prefer non-disruptive auth reload before daemon restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier --restart-daemon coordination, per the maintainer review: - ensure() probes the daemon, then attempts 'app-server daemon reload-auth' (codex daemons) with a 10s budget before falling back to the full restart (upstream daemons without reload support) - reload outcomes: .reloaded (credentials applied in place, no restart) and .deferred (daemon busy with a running turn — deferred, exit 0, rerun later); reload unavailability or failure falls through to the existing restart path - diagnostics for both new outcomes; docs updated - fake lifecycle script gains reload-auth variants; client + integration expectations updated for the version → reload-auth → restart sequence [skip ci] --- docs/commands/switch.md | 2 +- src/cli/output.zig | 8 ++++++ src/workflows/codex_daemon.zig | 28 +++++++++++++++++- src/workflows/switch.zig | 2 +- tests/cli_integration_test.zig | 8 +++--- tests/support/codex_daemon.zig | 3 ++ tests/workflows_daemon_test.zig | 50 +++++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 7 deletions(-) diff --git a/docs/commands/switch.md b/docs/commands/switch.md index b1a74116..678ff59d 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -79,7 +79,7 @@ codex-auth switch - --restart-daemon codex-auth switch personal --json --restart-daemon ``` -`--restart-daemon` requests a restart after account activation and registry persistence succeed. It also restarts when the selected credentials are already on disk, so it can repair stale daemon authentication or retry a failed restart. It can interrupt all attached sessions. Reconnect, verify the account, and resume work as needed. +`--restart-daemon` applies the selected credentials to the running daemon after account activation and registry persistence succeed. Codex daemons reload auth in place without interrupting attached sessions; upstream daemons without reload support are restarted instead, which can interrupt all attached sessions. It also covers stale daemon authentication and retries after a failed attempt. Reconnect, verify the account, and resume work as needed. - The selected `CODEX_HOME` is passed to both lifecycle commands. The home’s managed executable is preferred, with supported legacy layouts and a PATH fallback. - Responsiveness is checked with `codex app-server daemon version`. A missing control endpoint is treated as best-effort absence: no daemon is started. A stale endpoint or other probe error leaves the state unconfirmed; an explicit request then fails without invoking restart. diff --git a/src/cli/output.zig b/src/cli/output.zig index 2d0abce0..c2a4f999 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -29,6 +29,14 @@ pub fn writeCodexDaemonDiagnosticTo(out: *std.Io.Writer, home: []const u8, execu try out.writeAll("Account files switched successfully; Codex daemon restart completed. Reconnect and verify the selected account.\n"); return; }, + .reloaded => { + try out.writeAll("Account files switched successfully; the running daemon reloaded the new credentials without a restart.\n"); + return; + }, + .deferred => { + try out.writeAll("Account files switched successfully; the daemon deferred the reload until the running turn finishes. Rerun `switch --restart-daemon` afterwards.\n"); + return; + }, .running => try out.writeAll("A running Codex daemon may still use cached credentials. Use `switch --restart-daemon` to apply the selection or repair stale authentication. Restarting can interrupt attached sessions.\n"), .unknown => try out.writeAll(if (requested) "Account files switched successfully; daemon responsiveness could not be confirmed. The requested restart was not attempted.\n" diff --git a/src/workflows/codex_daemon.zig b/src/workflows/codex_daemon.zig index bacd15b0..9200fe84 100644 --- a/src/workflows/codex_daemon.zig +++ b/src/workflows/codex_daemon.zig @@ -3,10 +3,11 @@ const builtin = @import("builtin"); const runtime = @import("../core/runtime.zig"); pub const restart_timeout_ms = 120_000; +pub const reload_timeout_ms = 10_000; const probe_timeout_ms = 5_000; const output_limit = 16 * 1024; -pub const Outcome = enum { not_running, running, unknown, unsupported, restarted, restart_failed, timed_out }; +pub const Outcome = enum { not_running, running, unknown, unsupported, reloaded, deferred, restarted, restart_failed, timed_out }; /// Coordinates the supported Codex lifecycle commands for one selected home. pub const Client = struct { @@ -70,6 +71,31 @@ pub const Client = struct { return if (result.succeeded) .restarted else .restart_failed; } + /// Two-tier coordination: prefer the non-disruptive reload when the running + /// daemon supports it (codex daemons), and fall back to a full restart for + /// upstream daemons without reload support. Busy daemons defer instead of + /// interrupting a running turn. + pub fn ensure(self: *const Client, timeout_ms: u64) !Outcome { + const state = try self.probe(); + if (state != .running) return state; + if (try self.tryReload()) |outcome| return outcome; + return try self.restart(timeout_ms); + } + + /// Returns the reload outcome when the daemon accepted it (or deferred it); + /// null means reload is unavailable or failed and the caller should fall + /// back to a restart. + fn tryReload(self: *const Client) !?Outcome { + const result = self.runLifecycle("reload-auth", reload_timeout_ms) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return null, + }; + defer self.allocator.free(result.stdout); + if (result.timed_out or !result.succeeded) return null; + if (std.mem.indexOf(u8, result.stdout, "skipped the auth reload") != null) return .deferred; + return .reloaded; + } + const Result = struct { stdout: []u8, succeeded: bool, timed_out: bool }; fn runLifecycle(self: *const Client, command: []const u8, timeout_ms: u64) !Result { diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 798f4974..b2a4ae68 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -253,7 +253,7 @@ fn activateSelection( }; defer client.deinit(); const outcome = if (opts.restart_daemon) - try client.restart(daemon.restart_timeout_ms) + try client.ensure(daemon.restart_timeout_ms) else try client.probe(); try cli.output.printCodexDaemonDiagnostic(codex_home, client.executable, outcome, opts.restart_daemon); diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index e14fa34d..75e2b6f8 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -854,7 +854,7 @@ test "Scenario: Given daemon-aware switching then explicit recovery preserves se } const calls = try fake_daemon.read(home_dir, "lifecycle-calls"); defer gpa.free(calls); - const expected = if (!case.requested) "version\n" else if (case.attempts == 2) "version\nrestart\nversion\nrestart\n" else "version\nrestart\n"; + const expected = if (!case.requested) "version\n" else if (case.attempts == 2) "version\nreload-auth\nversion\nrestart\nversion\nreload-auth\nversion\nrestart\n" else "version\nreload-auth\nversion\nrestart\n"; try std.testing.expectEqualStrings(expected, calls); if (case.requested) { const forwarded = try fake_daemon.read(home_dir, "restarted-home"); @@ -898,7 +898,7 @@ test "Scenario: Given no managed executable then daemon restart resolves relativ try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); const calls = try fake_daemon.read(dir, "lifecycle-calls"); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); const forwarded = try fake_daemon.read(dir, "restarted-home"); defer gpa.free(forwarded); try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); @@ -942,7 +942,7 @@ test "Scenario: Given a non-executable codex first in PATH then daemon restart u try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); const calls = try fake_daemon.read(dir, "lifecycle-calls"); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); const forwarded = try fake_daemon.read(dir, "restarted-home"); defer gpa.free(forwarded); try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); @@ -2975,7 +2975,7 @@ test "Scenario: Given switch query with multiple matches when running switch the defer gpa.free(calls_path); const calls = try fixtures.readFileAlloc(gpa, calls_path); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); } } diff --git a/tests/support/codex_daemon.zig b/tests/support/codex_daemon.zig index c4495c8a..501b85d5 100644 --- a/tests/support/codex_daemon.zig +++ b/tests/support/codex_daemon.zig @@ -22,6 +22,9 @@ pub fn install(dir: fs.Dir, body: []const u8) !void { pub const running = "if [ \"$3\" = version ]; then printf '%s\\n' '{\"status\":\"running\",\"appServerVersion\":\"0.160.0\",\"backend\":\"pid\"}'; exit 0; fi\n"; pub const succeed = running ++ "[ \"$3\" = restart ] || exit 91\nprintf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\n"; +pub const reload_changed = running ++ "[ \"$3\" = reload-auth ] || exit 91\nprintf '%s\\n' 'Daemon reloaded auth; the account changed.'\n"; +pub const reload_busy = running ++ "[ \"$3\" = reload-auth ] || exit 91\nprintf '%s\\n' 'Daemon skipped the auth reload because a turn is running.'\n"; +pub const reload_unsupported_succeed = running ++ "if [ \"$3\" = reload-auth ]; then exit 92; fi\n" ++ "[ \"$3\" = restart ] || exit 91\nprintf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\n"; pub fn read(dir: fs.Dir, path: []const u8) ![]u8 { var file = try dir.openFile(path, .{}); diff --git a/tests/workflows_daemon_test.zig b/tests/workflows_daemon_test.zig index 044704b4..112fe6ec 100644 --- a/tests/workflows_daemon_test.zig +++ b/tests/workflows_daemon_test.zig @@ -51,6 +51,56 @@ test "Scenario: Given failed or malformed daemon probes then explicit restart re } } +test "Scenario: Given a codex daemon that reloads auth then ensure reloads without restarting" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_changed); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.reloaded, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\n", calls); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("restarted-home", .{})); +} + +test "Scenario: Given an upstream daemon without reload support then ensure falls back to restart" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_unsupported_succeed); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.restarted, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); + const forwarded = try fake.read(tmp.dir, "restarted-home"); + defer std.testing.allocator.free(forwarded); + try std.testing.expect(forwarded.len > 0); +} + +test "Scenario: Given a busy daemon then the reload defers without restarting" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_busy); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.deferred, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\n", calls); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("restarted-home", .{})); +} + test "Scenario: Given a restart failure then retry makes another explicit restart attempt" { if (builtin.os.tag == .windows) return error.SkipZigTest; var tmp = fs.tmpDir(.{});