Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

850 lines
28 KiB
Go

package daemon
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/platform"
)
// Server is the long-living Gortex daemon. It owns the Unix socket
// listener, the session registry, and the control-surface dispatcher.
// MCP traffic is plumbed through a ToolDispatcher that's injected at
// construction time — the daemon package deliberately doesn't depend
// on internal/mcp to keep the direction of imports clean.
type Server struct {
SocketPath string
Version string
Logger *zap.Logger
// Dispatcher handles MCP mode traffic (JSON-RPC 2.0) after handshake.
// A nil Dispatcher means this daemon is control-only — useful for
// tests and for early integration before the MCP passthrough lands.
MCPDispatcher MCPDispatcher
// Controller handles control-mode RPCs (track/untrack/reload/status/shutdown).
Controller Controller
// Ready, when set, reports whether the daemon has finished warmup and the
// current warmup phase. The result is surfaced on every successful
// handshake ack (HandshakeAck.Warming / WarmupPhase) so a connecting proxy
// or CLI knows the graph is still filling rather than guessing — a session
// that connects mid-warmup keeps working and self-heals as the graph fills.
// Optional: a nil probe means "assume ready" (control-only test servers).
Ready func() (ready bool, phase string)
// HTTPHandler, when non-nil, is mounted on a TCP listener at
// HTTPAddr alongside the unix-socket dispatcher. This is how the
// MCP 2026 Streamable HTTP transport reaches the daemon —
// internal/mcp/streamable.Transport plugs in here. Nil disables
// the HTTP face entirely; the unix-socket transport keeps
// working unchanged. HTTPAddr accepts standard net.Listen
// addresses; "127.0.0.1:7411" is the recommended default for a
// single-user dev box.
HTTPHandler http.Handler
HTTPAddr string
sessions *SessionRegistry
listener net.Listener
httpListener net.Listener
httpServer *http.Server
started time.Time
shutdown chan struct{}
doneOnce sync.Once
conns map[net.Conn]struct{}
connsMu sync.Mutex
}
// MCPDispatcher is implemented by whichever layer runs the MCP tool
// handlers. The daemon hands off one JSON-RPC frame at a time (raw bytes,
// newline-delimited) and the dispatcher returns the response bytes to
// write back. Session gives the dispatcher the per-client context it
// needs (scope, session-level state). Return an empty slice to suppress
// the response (notifications with no reply).
type MCPDispatcher interface {
Dispatch(ctx context.Context, sess *Session, frame []byte) ([]byte, error)
}
// SessionEndedHook is an optional extension that MCPDispatcher
// implementations can satisfy to get a disconnect callback. The daemon
// invokes it in the per-connection goroutine's defer, giving
// implementations a chance to release per-session state (e.g., the
// `*mcp.Server.sessions` map entry) so idle memory doesn't grow with
// total session-count-ever.
//
// Implementations must be fast and non-blocking — this fires during
// connection teardown.
type SessionEndedHook interface {
SessionEnded(sess *Session)
}
// Controller implements the daemon's control surface. Separated from
// MCPDispatcher so the two can evolve independently and so control-only
// tests don't need a full MCP stack.
type Controller interface {
Track(ctx context.Context, params TrackParams) (json.RawMessage, error)
Untrack(ctx context.Context, params UntrackParams) (json.RawMessage, error)
Reload(ctx context.Context) (json.RawMessage, error)
// ReloadServers re-reads servers.toml and atomically swaps the
// daemon's multi-server Router (building or tearing it down as the
// roster requires), then invalidates the roster cache — applying
// `gortex proxy on/off/add/remove` to a running daemon without a
// restart. Distinct from Reload, which reconciles tracked repos.
ReloadServers(ctx context.Context) (json.RawMessage, error)
Status(ctx context.Context) (StatusResponse, error)
// SearchSymbols is the cheap probe path used by external clients
// (Claude Code's Grep-redirect hook) that need a single short answer
// without setting up a full MCP session.
SearchSymbols(ctx context.Context, params SearchSymbolsParams) (SearchSymbolsResult, error)
// EnrichChurn runs the per-symbol / per-file churn enricher against
// the daemon's in-process graph. Exposed over the control surface so
// CLI invocations (and the post-commit / post-merge git hook) can
// trigger it without taking the on-disk store's write lock the daemon owns.
EnrichChurn(ctx context.Context, params EnrichChurnParams) (EnrichChurnResult, error)
// EnrichReleases runs the per-file release enricher against the
// daemon's in-process graph. Same routing rationale as
// EnrichChurn — keeps the on-disk store's write lock with the daemon.
EnrichReleases(ctx context.Context, params EnrichReleasesParams) (EnrichReleasesResult, error)
// EnrichBlame runs the git-blame authorship enricher against the
// daemon's in-process graph. Same routing rationale as EnrichChurn.
EnrichBlame(ctx context.Context, params EnrichBlameParams) (EnrichBlameResult, error)
// EnrichCoverage projects pre-parsed Go cover-profile segments onto
// the daemon's in-process graph. The CLI parses the profile so the
// daemon never reads the caller's filesystem.
EnrichCoverage(ctx context.Context, params EnrichCoverageParams) (EnrichCoverageResult, error)
// EnrichCochange mines co-change edges against the daemon's
// in-process graph. Same routing rationale as EnrichChurn.
EnrichCochange(ctx context.Context, params EnrichCochangeParams) (EnrichCochangeResult, error)
// Shutdown is invoked via the control surface and should return
// quickly; the daemon's actual shutdown work happens after the
// response is written.
Shutdown(ctx context.Context) error
}
// New builds a Server but does not start listening.
func New(socketPath, version string, logger *zap.Logger) *Server {
if logger == nil {
logger = zap.NewNop()
}
return &Server{
SocketPath: socketPath,
Version: version,
Logger: logger,
sessions: NewSessionRegistry(),
shutdown: make(chan struct{}),
conns: make(map[net.Conn]struct{}),
}
}
// Listen creates the socket, writes the PID file, and installs the
// shutdown-signal handlers for graceful shutdown. The socket permissions
// are 0o600 on Unix — the daemon is user-local and nothing else on the
// machine should reach it; on Windows, %USERPROFILE% ACLs scope it to
// the user instead.
func (s *Server) Listen() error {
if err := EnsureParentDir(s.SocketPath); err != nil {
return fmt.Errorf("ensure socket dir: %w", err)
}
// Remove stale socket file from a crashed previous run. If the daemon
// is actually running, the PID check below will catch it and abort.
_ = os.Remove(s.SocketPath)
if err := s.writePIDFile(); err != nil {
return fmt.Errorf("pid file: %w", err)
}
lc := &net.ListenConfig{}
l, err := lc.Listen(context.Background(), "unix", s.SocketPath)
if err != nil {
_ = os.Remove(PIDFilePath())
return fmt.Errorf("listen: %w", err)
}
// chmod the socket to user-only on Unix. Windows has no POSIX mode
// bits — the socket inherits the ACLs of %USERPROFILE%, which is
// already user-scoped — so skip it there.
if runtime.GOOS != "windows" {
if err := os.Chmod(s.SocketPath, 0o600); err != nil {
_ = l.Close()
return fmt.Errorf("chmod socket: %w", err)
}
}
s.listener = l
s.started = time.Now()
// Optional HTTP listener for the MCP 2026 Streamable transport.
// We bring it up alongside the unix-socket listener so both
// transports share the same shutdown / lifecycle plumbing. A
// listen failure here is fatal — running the unix-socket
// transport silently while HTTP is down would mask the operator
// misconfiguration that pointed clients at a port that never
// answered.
if s.HTTPHandler != nil && s.HTTPAddr != "" {
httpLn, herr := net.Listen("tcp", s.HTTPAddr)
if herr != nil {
_ = l.Close()
_ = os.Remove(PIDFilePath())
return fmt.Errorf("listen http: %w", herr)
}
s.httpListener = httpLn
s.httpServer = &http.Server{
Handler: s.HTTPHandler,
ReadHeaderTimeout: 10 * time.Second,
}
}
// Install signal handlers once the listener is live.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, platform.ShutdownSignals()...)
go func() {
<-sigCh
s.Logger.Info("daemon: received signal, shutting down")
_ = s.Shutdown()
}()
return nil
}
// Serve runs the accept loop. Blocks until Shutdown is called or the
// listener returns an unrecoverable error. When an HTTP listener was
// brought up by Listen it runs concurrently in its own goroutine; an
// HTTP-side failure pushes onto the same shutdown channel so the
// unix-socket loop tears down too.
func (s *Server) Serve() error {
if s.listener == nil {
return errors.New("daemon: Listen must be called before Serve")
}
if s.httpListener != nil && s.httpServer != nil {
go func() {
if err := s.httpServer.Serve(s.httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.Logger.Warn("daemon: http serve exited", zap.Error(err))
}
}()
s.Logger.Info("daemon: http listener active",
zap.String("addr", s.httpListener.Addr().String()))
}
s.Logger.Info("daemon: serving", zap.String("socket", s.SocketPath))
// Background hygiene: reap sessions whose client process died without a
// clean disconnect, and (opt-in) auto-exit after an idle window.
go s.runMaintenance()
var emfileBackoff time.Duration
for {
conn, err := s.listener.Accept()
if err != nil {
// listener closed during Shutdown — normal exit.
select {
case <-s.shutdown:
return nil
default:
}
if errors.Is(err, net.ErrClosed) {
return nil
}
// EMFILE means the process is out of file descriptors.
// Without backoff the loop spins, pinning a CPU and making
// the FD pressure even worse. The exponential ramp gives
// in-flight handlers time to release descriptors.
if isEMFILE(err) {
if emfileBackoff == 0 {
emfileBackoff = 5 * time.Millisecond
} else if emfileBackoff < time.Second {
emfileBackoff *= 2
}
s.Logger.Warn("daemon: accept failed, FD-starved — backing off",
zap.Error(err), zap.Duration("sleep", emfileBackoff))
select {
case <-time.After(emfileBackoff):
case <-s.shutdown:
return nil
}
continue
}
emfileBackoff = 0
s.Logger.Warn("daemon: accept failed", zap.Error(err))
continue
}
emfileBackoff = 0
s.trackConn(conn)
go s.handle(conn)
}
}
// deadPeerSweepInterval is how often runMaintenance reaps dead-peer sessions.
// A var so tests can shorten it.
var deadPeerSweepInterval = 30 * time.Second
// runMaintenance is the daemon's background hygiene loop: every
// deadPeerSweepInterval it sweeps sessions whose originating client process has
// died (platform.ProcessAlive), and — when GORTEX_DAEMON_IDLE_TIMEOUT is set —
// it shuts the daemon down after that long with no live sessions. Exits on the
// shutdown signal.
func (s *Server) runMaintenance() {
idle := IdleTimeoutFromEnv()
tick := deadPeerSweepInterval
if idle > 0 && idle/4 < tick {
tick = idle / 4 // sample often enough to honour a short idle window
}
if tick <= 0 {
tick = deadPeerSweepInterval
}
t := time.NewTicker(tick)
defer t.Stop()
var idleSince time.Time
for {
select {
case <-s.shutdown:
return
case <-t.C:
for _, sd := range s.sessions.SweepDead(platform.ProcessAlive) {
s.Logger.Info("daemon: swept dead session",
zap.String("session_id", sd.ID), zap.Int("client_pid", sd.ClientPID))
if sd.Conn != nil {
s.untrackConn(sd.Conn)
}
}
if idle <= 0 {
continue
}
if s.sessions.Count() > 0 {
idleSince = time.Time{}
continue
}
if idleSince.IsZero() {
idleSince = time.Now()
continue
}
if time.Since(idleSince) >= idle {
s.Logger.Info("daemon: idle timeout reached, shutting down",
zap.Duration("idle_timeout", idle))
_ = s.Shutdown()
return
}
}
}
}
// IdleTimeoutFromEnv reads the opt-in GORTEX_DAEMON_IDLE_TIMEOUT — a Go
// duration (e.g. "30m", "2h"). Returns 0 (disabled) when unset, empty, or
// unparseable, so the daemon only ever auto-exits when the user asked it to.
func IdleTimeoutFromEnv() time.Duration {
return parseIdleTimeout(os.Getenv("GORTEX_DAEMON_IDLE_TIMEOUT"))
}
func parseIdleTimeout(v string) time.Duration {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return 0
}
return d
}
// handle runs the per-connection lifecycle: handshake → dispatch loop →
// cleanup. Every exit path must remove the session and close the conn.
func (s *Server) handle(conn net.Conn) {
defer func() {
_ = conn.Close()
s.untrackConn(conn)
if sess := s.sessions.Remove(conn); sess != nil {
// Fire the optional disconnect hook so implementations can
// release per-session resources keyed by this ID.
if hook, ok := s.MCPDispatcher.(SessionEndedHook); ok && hook != nil {
hook.SessionEnded(sess)
}
s.Logger.Debug("daemon: session closed",
zap.String("session_id", sess.ID),
zap.String("client", sess.ClientName))
}
}()
reader := bufio.NewReader(conn)
sess, err := s.handshake(conn, reader)
if err != nil {
if errors.Is(err, io.EOF) {
// Liveness probe (daemon.IsRunningAt and friends): the peer
// dialed the socket and closed it without sending a handshake
// frame, so the first read returns a clean EOF. This is an
// expected "is the socket accepting?" knock, not a fault —
// keep it at Debug. A partially-written frame yields
// io.ErrUnexpectedEOF instead and still warns below.
s.Logger.Debug("daemon: connection closed before handshake", zap.Error(err))
} else {
s.Logger.Warn("daemon: handshake failed", zap.Error(err))
}
return
}
switch sess.Mode {
case ModeMCP:
s.serveMCP(conn, reader, sess)
case ModeControl:
s.serveControl(conn, reader, sess)
default:
s.Logger.Warn("daemon: unknown mode after handshake",
zap.String("mode", string(sess.Mode)))
}
}
// handshake reads one handshake frame, validates it, and replies with an
// ack. A rejected handshake writes an error ack then closes the connection.
func (s *Server) handshake(conn net.Conn, reader *bufio.Reader) (*Session, error) {
line, err := reader.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read handshake: %w", err)
}
var h Handshake
if err := json.Unmarshal(line, &h); err != nil {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrInternal,
ErrorMsg: "invalid handshake json: " + err.Error(),
})
return nil, fmt.Errorf("parse handshake: %w", err)
}
if h.Version != ProtocolVersion {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrProtocolMismatch,
ErrorMsg: fmt.Sprintf("daemon expects protocol %d, client sent %d",
ProtocolVersion, h.Version),
})
return nil, fmt.Errorf("protocol mismatch: %d vs %d", ProtocolVersion, h.Version)
}
if h.Mode != ModeMCP && h.Mode != ModeControl {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrUnsupportedMode,
ErrorMsg: "mode must be 'mcp' or 'control'",
})
return nil, fmt.Errorf("unsupported mode: %q", h.Mode)
}
sess := s.sessions.Register(conn, h)
ack := HandshakeAck{
OK: true,
SessionID: sess.ID,
DaemonVersion: s.Version,
}
// Stamp warmup state so the client can tell a still-warming daemon from a
// ready one. The session is established either way — Warming is advisory.
if s.Ready != nil {
ready, phase := s.Ready()
ack.Warming = !ready
ack.WarmupPhase = phase
}
if err := WriteJSONLine(conn, ack); err != nil {
_ = s.sessions.Remove(conn)
return nil, fmt.Errorf("write ack: %w", err)
}
s.Logger.Debug("daemon: session established",
zap.String("session_id", sess.ID),
zap.String("mode", string(sess.Mode)),
zap.String("cwd", sess.CWD),
zap.String("client", sess.ClientName))
return sess, nil
}
// serveMCP pumps MCP JSON-RPC frames. Each line on the wire is a single
// message. The Dispatcher gets the raw frame + session context and
// returns the raw reply to write back. Nil reply = no response (the
// client sent a notification).
func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) {
if s.MCPDispatcher == nil {
_ = WriteJSONLine(conn, map[string]any{
"jsonrpc": "2.0",
"error": map[string]any{
"code": -32000,
"message": "daemon started without MCP dispatcher; control-only mode",
},
"id": nil,
})
return
}
for {
line, err := reader.ReadBytes('\n')
if err != nil {
if !errors.Is(err, io.EOF) {
s.Logger.Debug("daemon: mcp read closed",
zap.String("session_id", sess.ID), zap.Error(err))
}
return
}
// Scanner-style: trim trailing newline but keep the payload as-is
// so the dispatcher sees valid JSON.
if n := len(line); n > 0 && line[n-1] == '\n' {
line = line[:n-1]
}
if len(line) == 0 {
continue
}
ctx := context.Background()
reply, err := s.MCPDispatcher.Dispatch(ctx, sess, line)
if err != nil {
s.Logger.Warn("daemon: dispatch error",
zap.String("session_id", sess.ID), zap.Error(err))
continue
}
if len(reply) == 0 {
continue
}
// The dispatcher returns a full JSON-RPC frame; re-append newline.
if _, werr := conn.Write(append(reply, '\n')); werr != nil {
s.Logger.Debug("daemon: mcp write failed",
zap.String("session_id", sess.ID), zap.Error(werr))
return
}
}
}
// serveControl drains ControlRequest messages, invokes the Controller,
// and writes paired ControlResponse messages.
func (s *Server) serveControl(conn net.Conn, reader *bufio.Reader, sess *Session) {
if s.Controller == nil {
_ = WriteJSONLine(conn, ControlResponse{
ErrorCode: ErrInternal,
ErrorMsg: "daemon started without controller",
})
return
}
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var req ControlRequest
if err := json.Unmarshal(line, &req); err != nil {
_ = WriteJSONLine(conn, ControlResponse{
ErrorCode: ErrInternal,
ErrorMsg: "malformed request: " + err.Error(),
})
continue
}
resp := s.handleControl(sess, req)
if err := WriteJSONLine(conn, resp); err != nil {
return
}
if req.Kind == ControlShutdown && resp.OK {
// Give the client one more moment to flush the ack before the
// listener goes away, then stop.
go func() {
time.Sleep(100 * time.Millisecond)
_ = s.Shutdown()
}()
return
}
}
}
func (s *Server) handleControl(_ *Session, req ControlRequest) ControlResponse {
ctx := context.Background()
switch req.Kind {
case ControlTrack:
var p TrackParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.Track(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlUntrack:
var p UntrackParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.Untrack(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlProxy:
result, err := s.Controller.ReloadServers(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlReload:
result, err := s.Controller.Reload(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlStatus:
st, err := s.Controller.Status(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
// Daemon-level fields the controller doesn't know about.
st.Version = s.Version
st.PID = os.Getpid()
st.UptimeSeconds = int64(time.Since(s.started).Seconds())
st.SocketPath = s.SocketPath
st.Sessions = s.sessions.Count()
// Per-session detail (cwd, client name, connect time) for the
// status command's "sessions" block. The controller can't see
// these — sessions live on the daemon server, not the
// MultiIndexer — so we attach them here. Sorted newest-first
// so the list reads as "what's connected right now".
if all := s.sessions.All(); len(all) > 0 {
now := time.Now()
rows := make([]MCPSessionStatus, 0, len(all))
for _, sess := range all {
if sess == nil {
continue
}
name, version := sess.SnapshotClientInfo()
row := MCPSessionStatus{
ID: sess.ID,
Cwd: sess.CWD,
ClientName: name,
ClientVersion: version,
}
if !sess.StartedAt.IsZero() {
row.ConnectedSecs = int64(now.Sub(sess.StartedAt).Seconds())
}
rows = append(rows, row)
}
st.MCPSessions = rows
}
buf, _ := json.Marshal(st)
return ControlResponse{OK: true, Result: buf}
case ControlSearchSymbols:
var p SearchSymbolsParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.SearchSymbols(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal search result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlShutdown:
if err := s.Controller.Shutdown(ctx); err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true}
case ControlEnrichChurn:
var p EnrichChurnParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichChurn(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_churn result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichReleases:
var p EnrichReleasesParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichReleases(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_releases result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichBlame:
var p EnrichBlameParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichBlame(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_blame result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichCoverage:
var p EnrichCoverageParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichCoverage(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_coverage result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichCochange:
var p EnrichCochangeParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichCochange(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_cochange result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
}
return controlErr(ErrInternal, "unknown control kind: "+req.Kind)
}
// Shutdown stops the accept loop, closes outstanding connections, and
// removes the socket and PID files. Safe to call multiple times.
func (s *Server) Shutdown() error {
var first error
s.doneOnce.Do(func() {
close(s.shutdown)
if s.listener != nil {
first = s.listener.Close()
}
// Tear down the HTTP listener with a short grace window so
// in-flight Streamable responses can finish flushing. We
// don't propagate the http error unless the unix-socket
// listener succeeded — the operator already sees a
// unix-socket close error in the same path.
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if herr := s.httpServer.Shutdown(ctx); herr != nil && first == nil {
first = herr
}
cancel()
}
// Close all live conns so per-conn goroutines exit their read loops.
s.connsMu.Lock()
for c := range s.conns {
_ = c.Close()
}
s.connsMu.Unlock()
_ = os.Remove(s.SocketPath)
_ = os.Remove(PIDFilePath())
})
return first
}
// writePIDFile fails if a live daemon is already running, so starting
// twice is a loud "already running" error rather than a silent overwrite.
func (s *Server) writePIDFile() error {
path := PIDFilePath()
if err := EnsureParentDir(path); err != nil {
return err
}
if existing, err := os.ReadFile(path); err == nil {
if pid, _ := strconv.Atoi(string(existing)); pid > 0 {
if platform.ProcessAlive(pid) {
return fmt.Errorf("daemon already running (pid %d)", pid)
}
// Stale pid file — old daemon crashed without cleanup.
_ = os.Remove(path)
}
}
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600)
}
// RunningPID reports the PID of a live daemon recorded in the PID file, or
// (0, false) when none is. Unlike IsRunning — which only probes the control
// socket — this still reports a daemon that is *mid-shutdown*: the
// ControlShutdown handler tears the listener down ~100ms after acking, but
// the process stays alive while it flushes and closes the store, and it
// holds the store's on-disk lock until it exits. That window is exactly what
// turned a quick restart into a "failed to open database" lock conflict, so
// callers that must not start a second daemon over the top of a dying one —
// or that need to wait for it to exit — consult this, not the socket.
//
// A PID file whose process is dead is stale (the owner crashed without
// cleanup) and reported as not-running, mirroring writePIDFile's own
// staleness handling.
func RunningPID() (int, bool) {
b, err := os.ReadFile(PIDFilePath())
if err != nil {
return 0, false
}
// TrimSpace so a PID file written with a trailing newline — by a shell
// `echo`, a process manager, or a hand edit — still parses. The daemon
// writes it without one, but tolerating both is free and the silent
// failure mode (guard never fires, restart races the lock again) is
// exactly the bug this helper exists to prevent.
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || pid <= 0 {
return 0, false
}
if !platform.ProcessAlive(pid) {
return 0, false
}
return pid, true
}
func (s *Server) trackConn(c net.Conn) {
s.connsMu.Lock()
s.conns[c] = struct{}{}
s.connsMu.Unlock()
}
func (s *Server) untrackConn(c net.Conn) {
s.connsMu.Lock()
delete(s.conns, c)
s.connsMu.Unlock()
}
// Sessions exposes the registry for inspection (status command, tests).
func (s *Server) Sessions() *SessionRegistry { return s.sessions }
// StartedAt returns the time Listen() completed — used for uptime math.
func (s *Server) StartedAt() time.Time { return s.started }
// unmarshalParams decodes RawMessage into a typed struct, treating empty
// or null params as an empty struct (zero value) so callers don't need
// to special-case missing params.
func unmarshalParams(raw json.RawMessage, v any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return json.Unmarshal(raw, v)
}
func controlErr(code, msg string) ControlResponse {
return ControlResponse{ErrorCode: code, ErrorMsg: msg}
}