mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* 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.
145 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|