Files
multica/server/internal/daemon/health.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

190 lines
5.8 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
)
// HealthResponse is returned by the daemon's local health endpoint.
type HealthResponse struct {
Status string `json:"status"`
PID int `json:"pid"`
Uptime string `json:"uptime"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
ServerURL string `json:"server_url"`
CLIVersion string `json:"cli_version"`
ActiveTaskCount int64 `json:"active_task_count"`
Agents []string `json:"agents"`
Workspaces []healthWorkspace `json:"workspaces"`
}
type healthWorkspace struct {
ID string `json:"id"`
Runtimes []string `json:"runtimes"`
}
// listenHealth binds the health port. Returns the listener or an error if
// another daemon is already running (port taken).
func (d *Daemon) listenHealth() (net.Listener, error) {
addr := fmt.Sprintf("127.0.0.1:%d", d.cfg.HealthPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("another daemon is already running on %s: %w", addr, err)
}
return ln, nil
}
// repoCheckoutRequest is the body of a POST /repo/checkout request.
type repoCheckoutRequest struct {
URL string `json:"url"`
WorkspaceID string `json:"workspace_id"`
WorkDir string `json:"workdir"`
AgentName string `json:"agent_name"`
TaskID string `json:"task_id"`
}
// healthHandler returns the /health HTTP handler. Extracted from serveHealth
// so tests can exercise it without spinning up a listener.
func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
var wsList []healthWorkspace
for id, ws := range d.workspaces {
wsList = append(wsList, healthWorkspace{
ID: id,
Runtimes: ws.runtimeIDs,
})
}
d.mu.Unlock()
agents := make([]string, 0, len(d.cfg.Agents))
for name := range d.cfg.Agents {
agents = append(agents, name)
}
resp := HealthResponse{
Status: "running",
PID: os.Getpid(),
Uptime: time.Since(startedAt).Truncate(time.Second).String(),
DaemonID: d.cfg.DaemonID,
DeviceName: d.cfg.DeviceName,
ServerURL: d.cfg.ServerBaseURL,
CLIVersion: d.cfg.CLIVersion,
ActiveTaskCount: d.activeTasks.Load(),
Agents: agents,
Workspaces: wsList,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}
// shutdownHandler triggers a graceful daemon shutdown by cancelling the
// top-level context. Used by `multica daemon stop` so we don't depend on
// OS-signal delivery, which is unreliable on Windows once the daemon is
// spawned with DETACHED_PROCESS (no shared console with the stop caller).
// The listener is bound to 127.0.0.1 only, so only local processes can hit
// this endpoint.
func (d *Daemon) shutdownHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "shutting down"})
if d.cancelFunc != nil {
// Cancel asynchronously so the response flushes first; otherwise
// srv.Close() races with the writer.
go d.cancelFunc()
}
}
}
// serveHealth runs the health HTTP server on the given listener.
// Blocks until ctx is cancelled.
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
mux := http.NewServeMux()
mux.HandleFunc("/health", d.healthHandler(startedAt))
mux.HandleFunc("/shutdown", d.shutdownHandler())
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req repoCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.URL == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
if req.WorkspaceID == "" {
http.Error(w, "workspace_id is required", http.StatusBadRequest)
return
}
if req.WorkDir == "" {
http.Error(w, "workdir is required", http.StatusBadRequest)
return
}
if d.repoCache == nil {
http.Error(w, "repo cache not initialized", http.StatusInternalServerError)
return
}
if err := d.ensureRepoReady(r.Context(), req.WorkspaceID, req.URL); err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, ErrRepoNotConfigured) {
statusCode = http.StatusBadRequest
}
d.logger.Error("repo checkout readiness failed", "workspace_id", req.WorkspaceID, "url", req.URL, "error", err)
http.Error(w, err.Error(), statusCode)
return
}
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
})
if err != nil {
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
srv := &http.Server{Handler: mux}
go func() {
<-ctx.Done()
srv.Close()
}()
d.logger.Info("health server listening", "addr", ln.Addr().String())
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
d.logger.Warn("health server error", "error", err)
}
}