Files
multica/server/internal/daemon/auto_update_test.go
Bohan Jiang fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

Co-authored-by: multica-agent <github@multica.ai>

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:10:22 +08:00

259 lines
7.9 KiB
Go

package daemon
import (
"context"
"errors"
"log/slog"
"sync/atomic"
"testing"
"github.com/multica-ai/multica/server/internal/cli"
)
// newAutoUpdateTestDaemon returns a Daemon stripped to just the pieces
// tryAutoUpdate touches, plus a sentinel cancelFunc the test can assert on to
// detect that triggerRestart fired. The caller is expected to install its own
// runUpdateFn before calling tryAutoUpdate when it wants to exercise the
// upgrade-success path.
func newAutoUpdateTestDaemon(t *testing.T, currentVersion string) (*Daemon, *atomic.Int32) {
t.Helper()
var restartCalls atomic.Int32
d := &Daemon{
cfg: Config{CLIVersion: currentVersion, AutoUpdateEnabled: true},
logger: slog.Default(),
cancelFunc: func() {
restartCalls.Add(1)
},
}
d.runUpdateFn = func(string) (string, error) {
t.Fatalf("runUpdateFn called unexpectedly")
return "", nil
}
return d, &restartCalls
}
func withStubRelease(t *testing.T, release *cli.GitHubRelease, err error) {
t.Helper()
prev := fetchLatestRelease
fetchLatestRelease = func() (*cli.GitHubRelease, error) { return release, err }
t.Cleanup(func() { fetchLatestRelease = prev })
}
func TestTryAutoUpdate_SkipsWhenUpdating(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
d.updating.Store(true)
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart called while another update was in progress")
}
}
func TestTryAutoUpdate_SkipsWhenTasksRunning(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
d.activeTasks.Store(1)
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired with active tasks; auto-update must defer")
}
if d.updating.Load() {
t.Fatalf("updating flag should not have been claimed while tasks were running")
}
}
// TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier covers the race the
// review flagged: cheap pre-fetch idle check passes (activeTasks == 0), then
// during the release fetch a poller decides to claim and bumps
// claimsInFlight. trySetClaimBarrier must observe that and defer rather than
// proceed into runUpdate (which would lead to a triggerRestart cancelling
// the just-claimed task mid-run).
func TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.claimsInFlight = 1 // poller is mid-ClaimTask while activeTasks is still 0
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired despite a claim being in flight at the barrier")
}
if d.updating.Load() {
t.Fatalf("updating flag must be released after a deferred upgrade so the next tick can retry")
}
if d.pauseClaims {
t.Fatalf("pauseClaims must be cleared after a deferred upgrade")
}
}
// TestTryAutoUpdate_HoldsBarrierAcrossRestart asserts the success path leaves
// pauseClaims set: process exit is imminent and clearing the barrier would
// open a window for a poller to claim a task that the imminent restart is
// about to cancel.
func TestTryAutoUpdate_HoldsBarrierAcrossRestart(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.runUpdateFn = func(string) (string, error) { return "upgraded", nil }
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 1 {
t.Fatalf("triggerRestart fired %d times, want 1", restartCalls.Load())
}
if !d.pauseClaims {
t.Fatalf("pauseClaims must remain set across the restart kick; got cleared")
}
}
// TestTryAutoUpdate_ReleasesBarrierOnUpgradeFailure asserts the failure path
// clears pauseClaims so the daemon can keep claiming tasks normally and
// retry the upgrade on the next tick.
func TestTryAutoUpdate_ReleasesBarrierOnUpgradeFailure(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.runUpdateFn = func(string) (string, error) {
return "brew network error", errors.New("brew upgrade failed")
}
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired despite upgrade failure")
}
if d.pauseClaims {
t.Fatalf("pauseClaims must be cleared after a failed upgrade so pollers resume claiming")
}
}
// TestTryEnterClaim_RespectsBarrier asserts the poller-side helper returns
// false while pauseClaims is held and that pairs of enter/exit balance the
// counter so a later barrier set sees idle.
func TestTryEnterClaim_RespectsBarrier(t *testing.T) {
d := &Daemon{}
if !d.tryEnterClaim() {
t.Fatal("tryEnterClaim should succeed when barrier is unset")
}
d.exitClaim()
if d.claimsInFlight != 0 {
t.Fatalf("claimsInFlight not balanced: %d", d.claimsInFlight)
}
if !d.trySetClaimBarrier() {
t.Fatal("trySetClaimBarrier should succeed when idle")
}
if d.tryEnterClaim() {
t.Fatal("tryEnterClaim must refuse while barrier is held")
}
d.releaseClaimBarrier()
if !d.tryEnterClaim() {
t.Fatal("tryEnterClaim should succeed after barrier release")
}
d.exitClaim()
}
func TestTryAutoUpdate_SkipsWhenFetchFails(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, nil, errors.New("network down"))
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired despite fetch failure")
}
}
func TestTryAutoUpdate_SkipsWhenNotNewer(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.13"}, nil)
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired even though latest == current")
}
}
func TestTryAutoUpdate_RunsUpgradeAndRestartsOnNewer(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
var upgradedTo string
d.runUpdateFn = func(target string) (string, error) {
upgradedTo = target
return "upgraded", nil
}
d.tryAutoUpdate(context.Background())
if upgradedTo != "v0.1.14" {
t.Fatalf("runUpdateFn called with %q, want v0.1.14", upgradedTo)
}
if restartCalls.Load() != 1 {
t.Fatalf("triggerRestart fired %d times, want 1", restartCalls.Load())
}
if !d.updating.Load() {
t.Fatalf("updating flag should remain set across the restart kick; got cleared")
}
}
func TestTryAutoUpdate_DoesNotRestartOnUpgradeFailure(t *testing.T) {
d, restartCalls := newAutoUpdateTestDaemon(t, "v0.1.13")
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
d.runUpdateFn = func(string) (string, error) {
return "brew: network error", errors.New("brew upgrade failed")
}
d.tryAutoUpdate(context.Background())
if restartCalls.Load() != 0 {
t.Fatalf("triggerRestart fired despite upgrade failure")
}
if d.updating.Load() {
t.Fatalf("updating flag must be released after a failed upgrade so the next tick can retry")
}
}
func TestAutoUpdateLoop_EarlyExits(t *testing.T) {
tests := []struct {
name string
cfg Config
}{
{
name: "disabled by config",
cfg: Config{AutoUpdateEnabled: false, CLIVersion: "v0.1.13"},
},
{
name: "managed by desktop",
cfg: Config{AutoUpdateEnabled: true, CLIVersion: "v0.1.13", LaunchedBy: "desktop"},
},
{
name: "dev build",
cfg: Config{AutoUpdateEnabled: true, CLIVersion: "v0.1.13-235-gabcdef0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &Daemon{cfg: tt.cfg, logger: slog.Default()}
d.runUpdateFn = func(string) (string, error) {
t.Fatalf("runUpdateFn called from an early-exit code path")
return "", nil
}
withStubRelease(t, &cli.GitHubRelease{TagName: "v0.1.14"}, nil)
done := make(chan struct{})
go func() {
d.autoUpdateLoop(context.Background())
close(done)
}()
<-done
})
}
}