Files
multica/server/internal/daemon/server_update_test.go
hvejsel 423d86da04 fix(daemon): prevent runtime update starvation (#6123)
Co-authored-by: Mia <mia@firtal.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 11:55:23 +08:00

240 lines
6.2 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync/atomic"
"testing"
"time"
)
func TestHandleUpdateReportsWhyItWasDeferred(t *testing.T) {
tests := []struct {
name string
arrange func(*Daemon)
wantError string
}{
{
name: "another update owns the daemon",
arrange: func(d *Daemon) {
d.updating.Store(true)
},
wantError: "another runtime update is already in progress on this machine",
},
{
name: "agent task is active",
arrange: func(d *Daemon) {
d.activeTasks.Store(1)
},
wantError: "runtime update deferred because agent work is starting or still active; retry when the machine is idle",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var payload map[string]any
d, reportCalls := updateReportDaemon(t, func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode update report: %v", err)
}
w.WriteHeader(http.StatusOK)
})
d.runUpdateFn = func(string) (string, error) {
t.Fatal("runUpdateFn must not run while the update is deferred")
return "", nil
}
tt.arrange(d)
d.handleUpdate(context.Background(), "runtime-1", &PendingUpdate{
ID: "update-1",
TargetVersion: "v0.4.14",
})
if got := atomic.LoadInt32(reportCalls); got != 1 {
t.Fatalf("update reports = %d, want 1", got)
}
if got := payload["status"]; got != "failed" {
t.Fatalf("status = %v, want failed", got)
}
if got := payload["error"]; got != tt.wantError {
t.Fatalf("error = %v, want %q", got, tt.wantError)
}
})
}
}
func TestHandleUpdateWaitsForEmptyClaimInsteadOfStarving(t *testing.T) {
originalResolveSelfExecutable := resolveSelfExecutable
originalIsBrewInstall := isBrewInstall
resolveSelfExecutable = func() (string, error) {
return "/tmp/multica-next", nil
}
isBrewInstall = func() bool { return false }
t.Cleanup(func() {
resolveSelfExecutable = originalResolveSelfExecutable
isBrewInstall = originalIsBrewInstall
})
d, _ := updateReportDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
d.claimsInFlight = 1
updateStarted := make(chan struct{})
d.runUpdateFn = func(string) (string, error) {
close(updateStarted)
return "upgraded", nil
}
done := make(chan struct{})
go func() {
d.handleUpdate(context.Background(), "runtime-1", &PendingUpdate{
ID: "update-1",
TargetVersion: "v0.4.14",
})
close(done)
}()
waitForServerUpdateBarrier(t, d)
select {
case <-updateStarted:
t.Fatal("update started before the in-flight claim finished")
default:
}
d.exitClaim()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("update did not continue after the empty claim finished")
}
select {
case <-updateStarted:
default:
t.Fatal("update never started after the empty claim finished")
}
}
func TestHandleUpdateReportsBusyWhenClaimBecomesTask(t *testing.T) {
var payload map[string]any
d, _ := updateReportDaemon(t, func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode update report: %v", err)
}
w.WriteHeader(http.StatusOK)
})
d.claimsInFlight = 1
d.runUpdateFn = func(string) (string, error) {
t.Fatal("runUpdateFn must not run after the claim hands off an active task")
return "", nil
}
done := make(chan struct{})
go func() {
d.handleUpdate(context.Background(), "runtime-1", &PendingUpdate{
ID: "update-1",
TargetVersion: "v0.4.14",
})
close(done)
}()
waitForServerUpdateBarrier(t, d)
d.activeTasks.Add(1)
d.exitClaim()
defer d.activeTasks.Add(-1)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("update did not return after the claim handed off a task")
}
if got := payload["status"]; got != "failed" {
t.Fatalf("status = %v, want failed", got)
}
if got := payload["error"]; got != "runtime update deferred because agent work is starting or still active; retry when the machine is idle" {
t.Fatalf("error = %v", got)
}
}
func waitForServerUpdateBarrier(t *testing.T, d *Daemon) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
d.claimMu.Lock()
paused := d.pauseClaims
d.claimMu.Unlock()
if paused {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("server update did not pause new claims")
}
func TestHandleUpdateKeepsBarrierOnlyWhenRestartWasScheduled(t *testing.T) {
tests := []struct {
name string
resolveExecutable func() (string, error)
wantUpdating bool
wantClaimsPaused bool
wantRestartCalls int32
}{
{
name: "restart scheduled",
resolveExecutable: func() (string, error) {
return "/tmp/multica-next", nil
},
wantUpdating: true,
wantClaimsPaused: true,
wantRestartCalls: 1,
},
{
name: "restart could not be scheduled",
resolveExecutable: func() (string, error) {
return "", errors.New("executable unavailable")
},
wantUpdating: false,
wantClaimsPaused: false,
wantRestartCalls: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalResolveSelfExecutable := resolveSelfExecutable
originalIsBrewInstall := isBrewInstall
resolveSelfExecutable = tt.resolveExecutable
isBrewInstall = func() bool { return false }
t.Cleanup(func() {
resolveSelfExecutable = originalResolveSelfExecutable
isBrewInstall = originalIsBrewInstall
})
d, _ := updateReportDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
d.runUpdateFn = func(string) (string, error) {
return "upgraded", nil
}
var restartCalls atomic.Int32
d.cancelFunc = func() {
restartCalls.Add(1)
}
d.handleUpdate(context.Background(), "runtime-1", &PendingUpdate{
ID: "update-1",
TargetVersion: "v0.4.14",
})
if got := d.updating.Load(); got != tt.wantUpdating {
t.Fatalf("updating = %v, want %v", got, tt.wantUpdating)
}
if got := d.pauseClaims; got != tt.wantClaimsPaused {
t.Fatalf("pauseClaims = %v, want %v", got, tt.wantClaimsPaused)
}
if got := restartCalls.Load(); got != tt.wantRestartCalls {
t.Fatalf("restart calls = %d, want %d", got, tt.wantRestartCalls)
}
})
}
}