Files
multica/server/internal/daemon/health_test.go
Bohan Jiang 632fdde700 fix(cli): keep Windows daemon alive after terminal closes + unblock multica update (#1420)
* fix(cli): detach daemon from parent console on Windows

CREATE_NEW_PROCESS_GROUP alone leaves the daemon attached to the
parent console, so closing the launching cmd/PowerShell window fires
CTRL_CLOSE_EVENT down the inherited console and takes the daemon
with it. Add DETACHED_PROCESS so the child has no console at all;
stdout/stderr are already redirected to the log file before spawn.

* fix(cli): make `multica update` work while the binary is running on Windows

On Windows, a running .exe is opened without FILE_SHARE_WRITE, so the
previous os.Rename(tmp, exe) always failed with "Access is denied" —
every `multica update` on Windows hit this, because the CLI is
updating its own running binary.

Windows does allow renaming the running .exe (just not overwriting
it), so the new Windows-only replaceBinary moves the running binary
to `.old` first, installs the new one, and restores the original if
installation fails. A best-effort CleanupStaleUpdateArtifacts runs
at CLI/daemon startup to reclaim the leftover `.old` file once the
old process has exited.

Unix keeps the plain rename-over semantics (the old inode stays valid
for the running process).

* fix(cli): stop daemon via HTTP /shutdown instead of console ctrl events

With DETACHED_PROCESS the Windows daemon shares no console with the
stop caller, so `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid)`
silently never reaches it — the old code would report "stop sent"
while the daemon kept running. Replace the platform-specific
stopDaemonProcess with a cross-platform POST to the daemon's HTTP
/shutdown endpoint, which cancels the same top-level context the
self-restart path already uses. Fall back to `process.Kill()` if
the HTTP call fails.

Also drops the now-unused stopDaemonProcess / CTRL_BREAK_EVENT
wiring, adds handler tests, and updates the DETACHED_PROCESS comment.
2026-04-21 13:03:48 +08:00

145 lines
4.0 KiB
Go

package daemon
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{
CLIVersion: "v9.9.9",
DaemonID: "daemon-test",
DeviceName: "dev",
ServerBaseURL: "http://localhost:8080",
},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
d.activeTasks.Store(3)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
d.healthHandler(time.Now()).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
// Decode into a raw map so the test locks in the exact wire-level JSON
// keys — the desktop TS client depends on snake_case (cli_version,
// active_task_count), so a silent struct-tag rename must fail here.
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw response: %v", err)
}
if got, want := raw["cli_version"], "v9.9.9"; got != want {
t.Errorf("cli_version key: got %v, want %q", got, want)
}
// JSON numbers decode to float64 through map[string]any.
if got, want := raw["active_task_count"], float64(3); got != want {
t.Errorf("active_task_count key: got %v, want %v", got, want)
}
if got, want := raw["status"], "running"; got != want {
t.Errorf("status key: got %v, want %q", got, want)
}
// Also round-trip into the typed struct as a separate check that the
// field values match, independent of key naming.
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode typed response: %v", err)
}
if resp.CLIVersion != "v9.9.9" {
t.Errorf("CLIVersion: got %q, want %q", resp.CLIVersion, "v9.9.9")
}
if resp.ActiveTaskCount != 3 {
t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount)
}
}
func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
t.Parallel()
d := &Daemon{
cfg: Config{CLIVersion: "v1.0.0"},
workspaces: map[string]*workspaceState{},
logger: slog.Default(),
}
handler := d.healthHandler(time.Now())
// Simulate the pollLoop increment/decrement protocol.
d.activeTasks.Add(1)
d.activeTasks.Add(1)
assertActiveTaskCount(t, handler, 2)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 1)
d.activeTasks.Add(-1)
assertActiveTaskCount(t, handler, 0)
}
func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
d := &Daemon{cancelFunc: cancel}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/shutdown", nil)
d.shutdownHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("daemon context was not cancelled after POST /shutdown")
}
}
func TestShutdownHandlerRejectsNonPost(t *testing.T) {
t.Parallel()
cancelled := false
d := &Daemon{cancelFunc: func() { cancelled = true }}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/shutdown", nil)
d.shutdownHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", rec.Code)
}
// Give the handler's deferred cancel goroutine a moment to fire
// in case a bug causes it to run anyway.
time.Sleep(10 * time.Millisecond)
if cancelled {
t.Fatal("GET request should not trigger cancellation")
}
}
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ActiveTaskCount != want {
t.Errorf("active_task_count: got %d, want %d", resp.ActiveTaskCount, want)
}
}