Files
multica/server/internal/cli/stable_exe_test.go
53b1baba0a feat(cli): add opt-in daemon autostart (MUL-7731) (#8864)
* fix(test): isolate cmd/multica suite home resolution on Windows

os.UserHomeDir reads USERPROFILE, not HOME, so every test that redirected
only HOME kept resolving ~/.multica against the real home: mkProfiles
stopped isolating at all, and a full-suite run wrote SaveCLIConfig
fixtures over a real default-profile config.json.

Redirect HOME and USERPROFILE process-wide in TestMain, and make mkProfiles
redirect both so per-test isolation holds on Windows too. On unix nothing
changes: USERPROFILE is unused there.

Co-authored-by: multica-agent <[email protected]>

* feat(cli): register daemon boot autostart on every platform

multica daemon start now registers the profile's daemon to come back at
login/boot so a reboot no longer takes the runtime offline: an HKCU Run
key on Windows, a launchd LaunchAgent on macOS, and a systemd user unit
(or XDG autostart fallback without systemd) on Linux. New
'daemon autostart enable|disable|status' manages the registration;
--no-autostart opts a single start out, and daemons spawned by a manager
(MULTICA_LAUNCHED_BY, i.e. the Desktop app's own start-at-login pref) are
never registered.

The registered command runs 'daemon start --foreground' per profile and
snapshots the shell's PATH so login sessions keep finding agent CLIs.
EnsureHiddenConsole now hides a console that exists only for the daemon
itself — a Run-key launch would otherwise park a visible console window
for the daemon's whole lifetime.

Co-authored-by: multica-agent <[email protected]>

* refactor(cli): make boot autostart registration opt-in per review

Registration now happens only through 'multica daemon autostart enable':
daemon start (and setup / restart / --foreground) merely print the enable
hint when nothing is registered and silently refresh an entry Multica
owns — marked by a comment line in a unit/.desktop file or a ManagedBy
key in a plist (the Run value name doubles as the marker on Windows).
Refresh skips anything without the marker and skips entirely under an
external systemd unit (INVOCATION_ID with a cgroup that is not ours), so
a hand-written unit like the one in #7091 is never overwritten.
--no-autostart is gone (there is nothing to opt out of), loginctl
enable-linger is no longer run automatically (enable prints the command
instead), and 'autostart enable' now spells out that only PATH carries
into the login session while shell-exported variables do not.

Also fixes the binary-update restart handoff under our own systemd unit:
spawning a successor and exiting 0 lets systemd's cgroup cleanup kill it
while Restart=on-failure never fires, leaving the runtime offline after
the first auto-update. The daemon now exits with
daemonSystemdHandoffExitStatus (42) instead; the generated unit carries
RestartForceExitStatus for it, and linux tests cover the unit-ownership
detection behind both the handoff and the refresh gate.

Review items also addressed: gofmt applied to cmd_daemon_test.go and
proc_windows.go, the Desktop wording corrected to its app-start daemon
preference (it has no OS login item), and CLI_AND_DAEMON.md plus the
daemon start / autostart help text follow the opt-in flow.

Co-authored-by: multica-agent <[email protected]>

* fix(test): redirect USERPROFILE alongside HOME in cmd/multica tests

Every t.Setenv("HOME", ...) in this package now goes through
redirectTestHome, which sets both home environment variables. Production
resolves ~/.multica via os.UserHomeDir — USERPROFILE on Windows — so a
HOME-only redirect split the fixture's write path from the code's read
path: on Windows the fixture landed where the code never looked (or,
before TestMain's scratch home, in the real ~/.multica). CI on ubuntu is
unaffected; the Windows host suite stops depending on whatever happens
to be in the real profile directory.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): record autostart with the stable brew path, shared with the restart target

The exit-42 systemd handoff restarts whatever ExecStart recorded, and
autostartSpecFor recorded os.Executable() verbatim — on brew installs
(Homebrew on Linux too, reached through install.sh) that is the versioned
keg path, which `brew upgrade` deletes when it cleans the old keg:
systemd then fails ExecStart with 203/EXEC and gives up after
StartLimitBurst, leaving the runtime offline until someone starts it by
hand; a LaunchAgent would point at a missing file at the next login.

Fix per review: both consumers now resolve through one shared helper,
cli.StableExecutablePath, with identical detection order — the path's own
known-Cellar shape first, then `brew --prefix` — so the two cannot drift.
The daemon's restartTargetBinary keeps its seam-based per-process cache
but delegates the mapping; autostartSpecFor runs
cli.StableSelfExecutable before recording. cli.IsBrewInstall shares the
same detection core.

Tests pin the keg -> <prefix>/bin/multica mapping at the helper level for
all three known prefixes, and end to end through autostartSpecFor feeding
a brew keg path, as requested in the review.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): key autostart ownership on file presence, not enablement

Review round 3: on Linux Enabled only means "linked in
default.target.wants", so the enable/disable guard (Enabled && !Managed)
missed a hand-written unit at our path that was unlinked — or linked
under another target such as graphical-session.target — and disable
deleted it; separately, the XDG sweeper deleted an unmarked
multica-daemon.desktop outright from enable, disable, and the silent
daemon start refresh.

- autostartState gains Present ("a file/value exists at our path");
  refuseUnmanagedAutostart and the status note key off Present, while the
  enabled/disabled verdict still keys off Enabled.
- platformRemoveAutostart re-checks the marker on the unit BEFORE
  systemctl disable (refusing must not unlink the user's unit as a side
  effect) and refuses to delete an unmarked file; removeXdgAutostartFile
  removes only marked files, which covers all three of its call sites
  (enable's stale sweep, the daemon start refresh, disable).
- sync no longer hints enable at a foreign unlinked file, and never
  heals an owned-but-unlinked entry — refresh must not re-enable what the
  user manually disabled — it hints instead.
- linux tests cover unlinked / other-target-linked / default-linked
  unmarked units on the read side, the removal backstop, a managed unit
  coexisting with an unmarked .desktop, and the mark-checked XDG
  sweeper; shared guard/status/sync tests move to the presence contract.

Co-authored-by: multica-agent <[email protected]>

* fix(cli): keep the running service's restart policy when disabling autostart

Review round 4 (P2): on Linux, `autostart disable` deleted the managed
unit file after unlinking it. The running daemon kept going, but systemd
lost the unit — LoadState=not-found, Restart=no — so the next
binary-update handoff (exit 42) exited into a service that could no
longer restart it and the runtime stayed offline until a manual start.

Disable now removes the enablement only: unlink every wants entry
(systemctl --user disable, with a glob fallback for a missing user bus)
and KEEP the marked unit file, so the supervised process retains its
restart policy while nothing starts at the next login. Re-enabling
re-links the same file. A second disable is a no-op again (change is
keyed off links actually present, in any target). The macOS equivalent
is fixed in the same spirit: the plist is deleted WITHOUT launchctl
bootout, which used to terminate the daemon launchd was supervising —
disable means "don't start at the next login", not "kill this session".

enable/disable now read the state once and share it with the ownership
guard; enable's wording keys off the pre-state, because re-linking a
kept unit often changes no file content and "already enabled" would
describe the state the user just left. The daemon-start hint now fires
only when nothing is registered at all — never at a disabled-or-foreign
file the user chose or owns — and the refresh still never re-links.

Regression coverage for running service → disable → handoff:
TestDisableKeepsRunningServiceRecoverable asserts the unit file survives
with RestartForceExitStatus intact, no wants link remains, the handoff
still detects our own unit (exit-42 path), a subsequent sync does not
re-enable, and a second disable reports no change. Docs note the
keep-the-file semantics on both Linux and macOS.

Co-authored-by: multica-agent <[email protected]>

* test(cli): update the coexistence test to the keep-the-unit disable contract

CI caught the round-4 test asserting the pre-round-5 behavior: it wrote a
marked unit WITHOUT a wants link and expected platformRemoveAutostart to
delete it. Disable now keeps the marked unit (unlinking its enablement)
so a running service retains its restart policy — so the test links the
unit (giving disable real work, changed=true), asserts the unit survives
unlinked, and keeps its original point: the hand-written .desktop beside
it is never touched.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: ZhangRui <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-10-12 00:17:42 -04:00

96 lines
3.5 KiB
Go

package cli
import (
"os"
"path/filepath"
"testing"
)
// TestStableExecutablePath pins the mapping both the restart target and
// autostart registration share. The brew branch is the one that matters:
// the keg path os.Executable() reports is deleted by the next
// `brew upgrade`, so anything recorded against it (a systemd ExecStart, a
// LaunchAgent) breaks after the first upgrade.
func TestStableExecutablePath(t *testing.T) {
t.Parallel()
keg := "/home/linuxbrew/.linuxbrew/Cellar/multica/0.4.33/bin/multica"
t.Run("brew install maps to the stable prefix path", func(t *testing.T) {
got := StableExecutablePath(keg, "/home/linuxbrew/.linuxbrew", true)
// filepath.Join semantics: identical strings on unix (where brew
// exists), OS-native separators wherever the test runs.
if want := filepath.Join("/home/linuxbrew/.linuxbrew", "bin", "multica"); got != want {
t.Errorf("StableExecutablePath = %q, want %q", got, want)
}
})
t.Run("brew install with unresolved prefix passes through", func(t *testing.T) {
// The caller decides how to warn; silently inventing a prefix would
// be worse than recording what we actually ran.
if got := StableExecutablePath(keg, "", true); got != keg {
t.Errorf("StableExecutablePath = %q, want the input unchanged", got)
}
})
t.Run("non-brew install resolves symlinks", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "multica")
if err := os.WriteFile(file, []byte("x"), 0o755); err != nil {
t.Fatalf("write fixture: %v", err)
}
want, err := filepath.EvalSymlinks(file)
if err != nil {
t.Fatalf("EvalSymlinks fixture: %v", err)
}
if got := StableExecutablePath(file, "", false); got != want {
t.Errorf("StableExecutablePath = %q, want %q", got, want)
}
})
t.Run("non-brew install keeps an unresolvable path", func(t *testing.T) {
gone := filepath.Join(t.TempDir(), "gone", "multica")
if got := StableExecutablePath(gone, "", false); got != gone {
t.Errorf("StableExecutablePath = %q, want the input unchanged", got)
}
})
}
// TestStableSelfExecutableResolvesBrewKegPaths feeds brew-style keg paths —
// the exact shape os.Executable() reports under Homebrew — through the
// one-stop resolver and asserts the stable prefix path comes back. Detection
// has to work from the path alone (offline known-Cellar match), because the
// point of the test is the same situation as a CI host or a login entry
// without brew on PATH: the keg path is the only evidence available.
func TestStableSelfExecutableResolvesBrewKegPaths(t *testing.T) {
t.Parallel()
cases := []struct {
keg string
prefix string
}{
{"/opt/homebrew/Cellar/multica/0.4.33/bin/multica", "/opt/homebrew"},
{"/home/linuxbrew/.linuxbrew/Cellar/multica/0.4.33/bin/multica", "/home/linuxbrew/.linuxbrew"},
{"/usr/local/Cellar/multica/0.4.33/bin/multica", "/usr/local"},
}
for _, c := range cases {
want := filepath.Join(c.prefix, "bin", "multica")
if got := StableSelfExecutable(c.keg); got != want {
t.Errorf("StableSelfExecutable(%q) = %q, want %q", c.keg, got, want)
}
}
t.Run("non-brew path passes through", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "multica")
if err := os.WriteFile(file, []byte("x"), 0o755); err != nil {
t.Fatalf("write fixture: %v", err)
}
want, err := filepath.EvalSymlinks(file)
if err != nil {
t.Fatalf("EvalSymlinks fixture: %v", err)
}
if got := StableSelfExecutable(file); got != want {
t.Errorf("StableSelfExecutable = %q, want %q", got, want)
}
})
}