Files
zzet--gortex/cmd/gortex/executor_daemonfirst_test.go
T
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

311 lines
9.2 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"sync"
"testing"
"github.com/zzet/gortex/internal/daemon"
)
// stubDaemonServer is a minimal in-process daemon that speaks just enough of
// the AF_UNIX wire protocol for resolveExecutor to treat it as a warm daemon:
// it ACKs the handshake, answers ControlStatus with a configurable tracked
// repo set, and echoes a tools/call result back over a ModeMCP channel.
//
// It deliberately implements the protocol by hand (rather than booting the
// real daemon.Server) so the test exercises resolveExecutor end-to-end
// against an isolated socket without indexing anything.
type stubDaemonServer struct {
ln net.Listener
trackedRepos []string
mu sync.Mutex
lastTool string
lastArgs map[string]any
mcpResult json.RawMessage // raw result payload echoed in the content block
mcpError *stubRPCError // when set, the MCP reply is a JSON-RPC error
}
type stubRPCError struct {
Code int
Message string
ErrorCode string // rides on error.data.error_code
}
// startStubDaemon binds a unix socket, points GORTEX_DAEMON_SOCKET at it,
// and serves until the test ends. The socket lives under a short temp dir
// (not t.TempDir()) because the AF_UNIX path limit (~104 bytes on macOS) is
// shorter than the deeply-nested per-subtest t.TempDir() paths.
func startStubDaemon(t *testing.T, trackedRepos []string) *stubDaemonServer {
t.Helper()
dir, err := os.MkdirTemp("", "gxd")
if err != nil {
t.Fatalf("mkdir temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
sock := filepath.Join(dir, "d.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Setenv("GORTEX_DAEMON_SOCKET", sock)
s := &stubDaemonServer{
ln: ln,
trackedRepos: trackedRepos,
mcpResult: json.RawMessage(`{"ok":true}`),
}
go s.serve()
t.Cleanup(func() { _ = ln.Close() })
return s
}
func (s *stubDaemonServer) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go s.handleConn(conn)
}
}
func (s *stubDaemonServer) handleConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
hsLine, err := reader.ReadBytes('\n')
if err != nil {
return
}
var hs daemon.Handshake
if err := json.Unmarshal(hsLine, &hs); err != nil {
return
}
if err := daemon.WriteJSONLine(conn, daemon.HandshakeAck{OK: true, DaemonVersion: "stub"}); err != nil {
return
}
switch hs.Mode {
case daemon.ModeControl:
s.serveControl(conn, reader)
case daemon.ModeMCP:
s.serveMCP(conn, reader)
}
}
func (s *stubDaemonServer) serveControl(conn net.Conn, reader *bufio.Reader) {
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var req daemon.ControlRequest
if err := json.Unmarshal(line, &req); err != nil {
return
}
if req.Kind != daemon.ControlStatus {
_ = daemon.WriteJSONLine(conn, daemon.ControlResponse{OK: false, ErrorCode: "unsupported"})
continue
}
st := daemon.StatusResponse{Version: "stub", Ready: true}
for _, p := range s.trackedRepos {
st.TrackedRepos = append(st.TrackedRepos, daemon.TrackedRepoStatus{Path: p})
}
raw, _ := json.Marshal(st)
_ = daemon.WriteJSONLine(conn, daemon.ControlResponse{OK: true, Result: raw})
}
}
func (s *stubDaemonServer) serveMCP(conn net.Conn, reader *bufio.Reader) {
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var frame struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
} `json:"params"`
}
if err := json.Unmarshal(line, &frame); err != nil {
return
}
s.mu.Lock()
s.lastTool = frame.Params.Name
s.lastArgs = frame.Params.Arguments
mcpErr := s.mcpError
result := s.mcpResult
s.mu.Unlock()
if mcpErr != nil {
resp := map[string]any{
"jsonrpc": "2.0",
"id": json.RawMessage(frame.ID),
"error": map[string]any{
"code": mcpErr.Code,
"message": mcpErr.Message,
"data": map[string]any{"error_code": mcpErr.ErrorCode},
},
}
b, _ := json.Marshal(resp)
_, _ = conn.Write(append(b, '\n'))
continue
}
resp := map[string]any{
"jsonrpc": "2.0",
"id": json.RawMessage(frame.ID),
"result": map[string]any{
"content": []map[string]any{{"type": "text", "text": string(result)}},
},
}
b, _ := json.Marshal(resp)
_, _ = conn.Write(append(b, '\n'))
}
}
func (s *stubDaemonServer) seenTool() (string, map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastTool, s.lastArgs
}
// TestResolveExecutor_DaemonFirst asserts resolveExecutor is daemon-first:
// when a warm daemon owns the repo it returns a daemonExecutor that relays
// the tool call over the daemon's MCP channel (the same warm graph the
// editor proxies hit). When no daemon is reachable, or the daemon does not
// track the repo, it returns ErrNoExecutor so the caller can fall back.
func TestResolveExecutor_DaemonFirst(t *testing.T) {
repo := t.TempDir()
t.Run("warm daemon owns repo -> daemonExecutor relays", func(t *testing.T) {
stub := startStubDaemon(t, []string{repo})
stub.mcpResult = json.RawMessage(`{"total":7}`)
exec, err := resolveExecutor(repo)
if err != nil {
t.Fatalf("resolveExecutor on a warm daemon that owns the repo: %v", err)
}
defer exec.Close()
if _, ok := exec.(*daemonExecutor); !ok {
t.Fatalf("daemon-first path must return a *daemonExecutor, got %T", exec)
}
out, err := exec.CallTool(context.Background(), "graph_stats", map[string]any{"x": "y"})
if err != nil {
t.Fatalf("relay CallTool: %v", err)
}
var payload struct {
Total int `json:"total"`
}
if err := json.Unmarshal(out, &payload); err != nil {
t.Fatalf("relayed payload should be the tool json: %v (%s)", err, out)
}
if payload.Total != 7 {
t.Fatalf("relayed payload total = %d, want 7", payload.Total)
}
// The tool name and the caller args really made it to the daemon.
tool, args := stub.seenTool()
if tool != "graph_stats" {
t.Fatalf("daemon saw tool %q, want graph_stats", tool)
}
if args["x"] != "y" {
t.Fatalf("caller args must be relayed, daemon saw %v", args)
}
})
t.Run("no daemon reachable -> ErrNoExecutor", func(t *testing.T) {
// Point the socket env at a path with no listener.
t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(t.TempDir(), "dead.sock"))
_, err := resolveExecutor(repo)
if !errors.Is(err, ErrNoExecutor) {
t.Fatalf("a dead socket must yield ErrNoExecutor, got %v", err)
}
})
t.Run("daemon up but does not own repo -> ErrNoExecutor", func(t *testing.T) {
// The daemon tracks some other tree, not our repo.
startStubDaemon(t, []string{filepath.Join(t.TempDir(), "elsewhere")})
_, err := resolveExecutor(repo)
if !errors.Is(err, ErrNoExecutor) {
t.Fatalf("an untracked repo must yield ErrNoExecutor, got %v", err)
}
})
}
// TestDaemonExecutor_ErrDistinct asserts the daemonExecutor relay
// distinguishes the daemon's typed refusals (ErrRepoNotTracked) and the
// daemon-unavailable case (ErrNoExecutor) from a genuine tool error, which
// is surfaced verbatim. This is the distinction a CLI command relies on to
// decide whether to fall back or fail.
func TestDaemonExecutor_ErrDistinct(t *testing.T) {
repo := t.TempDir()
t.Run("repo_not_tracked maps to ErrRepoNotTracked", func(t *testing.T) {
stub := startStubDaemon(t, []string{repo})
stub.mcpError = &stubRPCError{Code: -32000, Message: "repository not tracked", ErrorCode: "repo_not_tracked"}
exec, err := resolveExecutor(repo)
if err != nil {
t.Fatalf("resolveExecutor: %v", err)
}
defer exec.Close()
_, callErr := exec.CallTool(context.Background(), "search_symbols", nil)
if !errors.Is(callErr, ErrRepoNotTracked) {
t.Fatalf("repo_not_tracked must map to ErrRepoNotTracked, got %v", callErr)
}
// It must NOT be confused with a generic tool error or no-executor.
if errors.Is(callErr, ErrNoExecutor) {
t.Fatalf("ErrRepoNotTracked must be distinct from ErrNoExecutor")
}
})
t.Run("real tool error surfaces verbatim", func(t *testing.T) {
stub := startStubDaemon(t, []string{repo})
stub.mcpError = &stubRPCError{Code: -32602, Message: "bad symbol id"}
exec, err := resolveExecutor(repo)
if err != nil {
t.Fatalf("resolveExecutor: %v", err)
}
defer exec.Close()
_, callErr := exec.CallTool(context.Background(), "get_symbol", nil)
if callErr == nil {
t.Fatal("a real tool error must surface, got nil")
}
if errors.Is(callErr, ErrRepoNotTracked) || errors.Is(callErr, ErrNoExecutor) {
t.Fatalf("a real tool error must not collapse to a sentinel, got %v", callErr)
}
if callErr.Error() != "bad symbol id" {
t.Fatalf("real error must surface verbatim, got %q", callErr.Error())
}
})
t.Run("ErrNoExecutor is its own sentinel", func(t *testing.T) {
// No daemon at all -> the resolve step (not the call step) yields
// ErrNoExecutor, which is a distinct sentinel from a tool error.
t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(t.TempDir(), "absent.sock"))
_, err := resolveExecutor(repo)
if !errors.Is(err, ErrNoExecutor) {
t.Fatalf("want ErrNoExecutor, got %v", err)
}
if errors.Is(err, ErrRepoNotTracked) {
t.Fatalf("ErrNoExecutor must be distinct from ErrRepoNotTracked")
}
})
}