Skip to content

self-update restarts the background service onto the swapped binary (nxf 6j6v.c92y) - #41

Closed
cabcookie wants to merge 3 commits into
mainfrom
fix/c92y-self-update-restarts-the-service
Closed

cabcookie wants to merge 3 commits into
mainfrom
fix/c92y-self-update-restarts-the-service

Conversation

@cabcookie

Copy link
Copy Markdown
Member

Summary

nxf 6j6v.c92y (bug, P1). nxs self-update swapped the binary the background service's alias resolves to, but launchd kept running the process it had started from the old file. Measured on 2026-10-09: the service had been running since 10-07 through two updates, so the fixes of r91p and br25 were inactive. Nothing reported this.

Owner decision (2026-10-09): self-update restarts the service itself, through the same path as nxs sync daemon install, and only for a service whose alias resolves to the binary it just swapped.

What changed

  • nxs_service::launchd::restart_with (new, about 10 lines): calls install_with for a home's instance with its own alias, plist and log directory. That runs the same bootout + bootstrap + retry + read-back that install runs. One difference: it does not re-point the alias. The caller has already checked that the alias names the binary it wants, and re-linking it to current_exe() would be a second decision about which build the service runs (7gz6).

  • self-update, after the swap (crates/cli/src/selfupdate.rs): it restarts the ambient instance only when both of these hold:

    • its plist is installed, and
    • its alias resolves (canonicalised) to <install dir>/nxs.

    The real launchd handle comes only from RealCtl::for_login_session(MustBeTheLoginSessions), and only when there is something to restart. self-update spells no launchctl verb.

    • Alias points at another build: no restart, and no launchd handle is created. The existing service_alias_note warning is unchanged.
    • No installed service: nothing happens. This covers "no alias" and also "alias left behind after uninstall removed the plist". Restarting in that case would re-install a service the user removed.
    • Restart fails, or the launchd handle is refused: the update still succeeds. A note on stderr (in both renderings) says the update succeeded, the service still runs the old build, and you should run nxs sync daemon install.
  • --json / --check: a service_restart object reports one of these:

    • {"instance","status":"would-restart"} with --check when an update is available
    • {"status":"restarted","confirmed":bool}
    • {"status":"failed","error","command":"nxs sync daemon install"}

    The object is absent when there is nothing to restart, the same as service_alias, so the existing receipts stay byte-identical on machines without a service. Human mode prints one matching line.

  • nxs sync daemon status (crates/nxs/src/sync/daemon.rs): new stale_process_note. When the service is running, its heartbeat version differs from this binary's version, and this binary is the alias's target, status adds a note with both versions and the command. It fills the existing program.note field, which is disjoint from alias_note (that one speaks only when the paths differ), so the JSON shape gets no new key. nxs doctor is unchanged; the status note covers the item, so no follow-up item was created.

  • Changelog fragment changes/self-update-restarts-the-service.md (fixed, en + de). No facade: line, because no consumed surface is touched.

Tests

All of these run against a SpyCtl behind the LaunchCtl seam, with a service home, alias and plist in a TempDir. No test constructs RealCtl.

Test Proves
an_update_whose_service_runs_the_swapped_binary_restarts_it_once_through_the_install_path Alias resolves to the swapped binary: one launchd handle, exactly one bootstrap of this plist, preceded by bootout of the label, no kickstart, alias unchanged, Restarted { confirmed: true } (read back from the spy)
an_update_whose_service_runs_another_build_restarts_nothing_and_keeps_the_note Alias points at a target/debug build: None, launchd handle never asked for, zero launchctl calls, alias note still names the command
a_machine_with_no_installed_service_gets_no_restart No alias, and alias without a plist (uninstalled): nothing, no calls
a_failed_restart_names_the_command_and_does_not_fail_the_update Bootstrap refused on every attempt: Failed; receipt still status: updated with service_restart.command = "nxs sync daemon install"; human line goes to stderr and contains the command; a refused handle (home check) gives the same result
check_reports_a_restart_it_would_do_and_receipts_without_one_are_unchanged --check reports would-restart; without a restart the receipt gets no new key
a_running_service_older_than_the_binary_its_alias_names_is_reported / a_stale_process_is_claimed_only_where_it_can_be_known Status note fires only when the service is running, the versions differ, and this binary is the alias's target

Red without the fix, shown by mutation. Each mutation was applied by a script, the filtered tests were run, and the file was restored:

Mutation Goes red
nofix: restart_service_after_swap always returns None (the pre-fix behaviour) …restarts_it_once_through_the_install_path, a_failed_restart_…
noplist: drop the plist.exists() guard a_machine_with_no_installed_service_gets_no_restart
anyalias: restart whatever the alias points at an_update_whose_service_runs_another_build_…
swallow: map a failed restart to Restarted a_failed_restart_names_the_command_and_does_not_fail_the_update
nostale: stale_process_note always returns None a_running_service_older_than_the_binary_its_alias_names_is_reported

Run locally: cargo test -p nexus-flow-cli --lib selfupdate (60 passed), cargo test -p nxs --lib -- stale_process a_running_service_older, cargo check -p nxs-service -p nexus-flow-cli -p nxs --tests (no warnings), cargo fmt, cargo xtask changelog check origin/main. No clippy and no full suite; CI is the gate.

Spec check

None found. release-management.md §7.2 / TB-4 rule out background auto-update of the binary. This change restarts the service only as part of a self-update the user explicitly ran, and it changes no binary. E4-continuous-sync-client.md describes the install path being reused here, and nothing in it contradicts this.

Open decisions for the owner

  1. Ambient instance only. The item description says "every installed instance label". The owner note says "the instance whose program resolves to the binary just swapped". This PR restarts only the ambient instance, which for an installed binary is always production (6j6v.cvpy). That is the one nxs sync daemon install addresses, so the failure command is exactly right for it. A sibling instance (for example nexus-flow-dev) whose alias also points at the installed binary is not restarted. Since cvpy, dev instances are installed from builds, so that case is a leftover from before cvpy. I found no correct one-line command to give for a sibling whose restart fails. Extending the restart to siblings is easy (ServiceHome::siblings() + restart_with) if wanted.
  2. The plist is written by the old binary's code. The restart runs in the process doing the update, which is the pre-update build. So it renders the plist with the old render_plist. The program path is the alias, so the service does come back on the new binary. But if a release changes the plist template (keys, PATH), that change only arrives with the next manual nxs sync daemon install. The alternative is to spawn the new <dir>/nxs sync daemon install as a child process. That would not be provable through the LaunchCtl seam, which the brief requires.
  3. No restart on the up-to-date path. A machine that is already current but whose service is still the old process (the measured machine right now) is not restarted by self-update. It will be restarted by the next update that swaps anything. nxs sync daemon status now names the stale process and the command in the meantime.

🤖 Generated with Claude Code

https://claude.ai/code/session_0184D5PqXGRbwMKDkMkRggdZ

cabcookie and others added 2 commits October 10, 2026 09:19
…ary (nxf 6j6v.c92y)

`nxs self-update` swapped the file the service's alias resolves to, but launchd
kept executing the process it had started from the old file until a restart or
reboot. Fixes stayed inactive in the service and nothing said so.

After the swap, self-update now restarts the ambient instance when its plist is
installed and its alias resolves to the binary just swapped. The restart goes
through nxs_service::launchd::restart_with, which is install_with (the bootout +
bootstrap + read-back `nxs sync daemon install` runs) without re-pointing the
alias. No launchctl verb is spelled in self-update; the real handle is only ever
obtained through RealCtl::for_login_session.

- An alias pointing at another build: no restart, the existing note stays.
- No installed service (no alias, or an alias beside a removed plist): nothing.
- A failed or refused restart is reported with `nxs sync daemon install` and
  never fails the update.
- --json carries a `service_restart` object (would-restart / restarted /
  failed), absent when there is nothing to restart; --check reports
  would-restart.
- `nxs sync daemon status` notes when the running service's version differs
  from the binary its alias names, when this binary is that file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184D5PqXGRbwMKDkMkRggdZ
…nxf 6j6v.c92y)

- Keep the installed PATH: restart_with reads PATH from the installed plist
  and falls back to the caller's only when the plist has none (Integrity #2).
- Say what a failed restart left: untouched / still loaded / STOPPED /
  unknown, read back from launchd after the failure (Integrity #1).
- Skip the restart, and print the command, when this update runs inside a
  process the service started, found by walking the ancestors to the live
  heartbeat pid (Code #2, Integrity #3).
- Restarted carries launchd's state; the line says "launchd holds the new
  job (state: ...)" and points at `nxs sync daemon status` (Integrity #4).
- Restart after a swap whose later step failed, detected by the inode
  changing (Integrity #5).
- Name sibling instances that run the same binary (Code #3); name a stale
  service on the up-to-date path, in --check too (decision 3, Integrity #6).
- ServiceAt is the one seam for the service glue; check_output,
  updated_output and partial_swap_output build the Output that run prints,
  so the wiring and stream routing are tested (Test #1, #2, #3, #5). Line
  enum instead of (bool, String) (Code #4). The restart tests are cfg(unix)
  (Code #5).
- stale_process_note moves to nxs_service::program, takes ServiceState and
  says "different". status builds its note through program_note, tested for
  order and liveness (Code #1, Test #6). Exact verb sequence with the
  gui/<uid> domain (Test #4).
- Changelog names the old-template limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184D5PqXGRbwMKDkMkRggdZ
@cabcookie

Copy link
Copy Markdown
Member Author

Review mitigated in a59b63c (17 fixed, 1 declined). Every new or changed test is red under a targeted mutation (applied by script, filtered tests run, file restored): nofix, noplist, anyalias, stoppedasuntouched, noguard, nowalk, nopost, routing, checkrestarts, nostate, nosiblings, nopartial, nostaleuptodate (selfupdate.rs); updaterpath (launchd.rs, red in both nxs-service and cli tests); unconfirmedspeaks, nostale (program.rs); noteorder (daemon.rs). Declined: Integrity #7 (informational; the canonicalize fallback is safe-unequal, and the TOCTOU is covered by the bootstrap retry). New behaviour: PATH taken from the installed plist; failed restart reports untouched/still-loaded/stopped/unknown; restart skipped when the updater descends from the live service pid; siblings named; stale service named on the up-to-date path; restart after a partial swap; changelog names the old-template limit.

🤖 Generated with Claude Code

https://claude.ai/code/session_0184D5PqXGRbwMKDkMkRggdZ

cabcookie added a commit that referenced this pull request Oct 10, 2026
…swered escalations release, store-free lanes, next filters and paging, per-type ranking, self-update restarts the service (nxf 6j6v.9g8j, 1wep, ys54, t1ym, 15ed, z9jk, c92y) (#38)

Folds the reviewed and mitigated PRs #38 (9g8j), #39 (1wep), #40 (ys54), #41 (c92y), #42 (t1ym), #43 (15ed) and #44 (z9jk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
@cabcookie

Copy link
Copy Markdown
Member Author

Included in #38, which folded this reviewed and mitigated PR together with the rest of the v0.207 batch and is merged.

@cabcookie cabcookie closed this Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant