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

403 lines
12 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// osStat is an alias so fileExists() reads linearly without import rearrangement.
var osStat = os.Stat
// fakeController is a Controller stub that records calls and returns
// canned responses — lets the daemon lifecycle tests run without wiring
// in the real MultiIndexer.
type fakeController struct {
mu sync.Mutex
trackCalls []TrackParams
untrackCalls []UntrackParams
reloadCalls int
reloadServersCalls int
statusCalls int
shutdownCalls int
shutdownErr error
searchCalls []SearchSymbolsParams
searchHits []SymbolHit
searchErr error
enrichChurnCalls []EnrichChurnParams
enrichReleasesCalls []EnrichReleasesParams
enrichBlameCalls []EnrichBlameParams
enrichCoverageCalls []EnrichCoverageParams
enrichCochangeCalls []EnrichCochangeParams
}
func (f *fakeController) Track(_ context.Context, p TrackParams) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.trackCalls = append(f.trackCalls, p)
return json.RawMessage(fmt.Sprintf(`{"tracked":%q}`, p.Path)), nil
}
func (f *fakeController) Untrack(_ context.Context, p UntrackParams) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.untrackCalls = append(f.untrackCalls, p)
return json.RawMessage(`{"untracked":true}`), nil
}
func (f *fakeController) Reload(_ context.Context) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.reloadCalls++
return json.RawMessage(`{"reloaded":true}`), nil
}
func (f *fakeController) ReloadServers(_ context.Context) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.reloadServersCalls++
return json.RawMessage(`{"servers":0,"router_wired":false}`), nil
}
func (f *fakeController) Status(_ context.Context) (StatusResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return StatusResponse{
TrackedRepos: []TrackedRepoStatus{
{Prefix: "myrepo", Path: "/tmp/myrepo", Files: 42, Nodes: 100, Edges: 200},
},
}, nil
}
func (f *fakeController) Shutdown(_ context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.shutdownCalls++
return f.shutdownErr
}
func (f *fakeController) SearchSymbols(_ context.Context, p SearchSymbolsParams) (SearchSymbolsResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.searchCalls = append(f.searchCalls, p)
if f.searchErr != nil {
return SearchSymbolsResult{}, f.searchErr
}
return SearchSymbolsResult{Hits: f.searchHits}, nil
}
func (f *fakeController) EnrichChurn(_ context.Context, p EnrichChurnParams) (EnrichChurnResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichChurnCalls = append(f.enrichChurnCalls, p)
return EnrichChurnResult{Files: 1, Symbols: 2, Branch: p.Branch}, nil
}
func (f *fakeController) EnrichReleases(_ context.Context, p EnrichReleasesParams) (EnrichReleasesResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichReleasesCalls = append(f.enrichReleasesCalls, p)
return EnrichReleasesResult{Files: 3, Branch: p.Branch}, nil
}
func (f *fakeController) EnrichBlame(_ context.Context, p EnrichBlameParams) (EnrichBlameResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichBlameCalls = append(f.enrichBlameCalls, p)
return EnrichBlameResult{Nodes: 5}, nil
}
func (f *fakeController) EnrichCoverage(_ context.Context, p EnrichCoverageParams) (EnrichCoverageResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichCoverageCalls = append(f.enrichCoverageCalls, p)
return EnrichCoverageResult{Symbols: 7, Segments: len(p.Segments)}, nil
}
func (f *fakeController) EnrichCochange(_ context.Context, p EnrichCochangeParams) (EnrichCochangeResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichCochangeCalls = append(f.enrichCochangeCalls, p)
return EnrichCochangeResult{Edges: 11}, nil
}
// newDaemon spins up a Server on a short socket path + Fake controller.
// macOS limits Unix socket paths to ~104 chars (sizeof(sun_path)), and
// Go's t.TempDir() path can exceed that for long test names, so we mint
// our own short directory under /tmp/gx-<random>.
func newDaemon(t *testing.T, ctrl Controller) (*Server, string) {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err, "short tmp dir for unix socket")
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test-0.0.0", zap.NewNop())
srv.Controller = ctrl
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
// Wait until the socket is actually accepting connections.
require.Eventually(t, func() bool {
return IsRunningAt(socket)
}, 2*time.Second, 10*time.Millisecond, "daemon socket never came up")
t.Cleanup(func() { _ = srv.Shutdown() })
return srv, socket
}
func TestDaemon_ControlStatus(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
require.NotEmpty(t, c.Ack.SessionID, "ack must carry session id")
require.Equal(t, "test-0.0.0", c.Ack.DaemonVersion)
resp, err := c.Control(ControlStatus, nil)
require.NoError(t, err)
require.True(t, resp.OK, "status: %+v", resp)
var st StatusResponse
require.NoError(t, json.Unmarshal(resp.Result, &st))
assert.Equal(t, "test-0.0.0", st.Version)
assert.NotZero(t, st.PID)
assert.Equal(t, socket, st.SocketPath)
require.Len(t, st.TrackedRepos, 1)
assert.Equal(t, "myrepo", st.TrackedRepos[0].Prefix)
}
func TestDaemon_ControlTrackUntrack(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
trackResp, err := c.Control(ControlTrack, TrackParams{Path: "/tmp/myapp", Name: "myapp"})
require.NoError(t, err)
require.True(t, trackResp.OK)
assert.Contains(t, string(trackResp.Result), "/tmp/myapp")
untrackResp, err := c.Control(ControlUntrack, UntrackParams{PathOrPrefix: "myapp"})
require.NoError(t, err)
require.True(t, untrackResp.OK)
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
require.Len(t, ctrl.trackCalls, 1)
assert.Equal(t, "/tmp/myapp", ctrl.trackCalls[0].Path)
require.Len(t, ctrl.untrackCalls, 1)
assert.Equal(t, "myapp", ctrl.untrackCalls[0].PathOrPrefix)
}
// TestDaemon_ControlEnrichDispatch exercises the control dispatch for
// every enrich verb — confirming each routes to the matching Controller
// method, round-trips its Params, and decodes the typed Result. This is
// the contract the `gortex enrich` CLI relies on when it forwards to a
// running daemon.
func TestDaemon_ControlEnrichDispatch(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
// churn
churnResp, err := c.Control(ControlEnrichChurn, EnrichChurnParams{Path: "/r", Branch: "main"})
require.NoError(t, err)
require.True(t, churnResp.OK, "churn: %+v", churnResp)
var churnOut EnrichChurnResult
require.NoError(t, json.Unmarshal(churnResp.Result, &churnOut))
assert.Equal(t, 1, churnOut.Files)
assert.Equal(t, 2, churnOut.Symbols)
assert.Equal(t, "main", churnOut.Branch)
// releases
relResp, err := c.Control(ControlEnrichReleases, EnrichReleasesParams{Path: "/r", Branch: "main"})
require.NoError(t, err)
require.True(t, relResp.OK, "releases: %+v", relResp)
var relOut EnrichReleasesResult
require.NoError(t, json.Unmarshal(relResp.Result, &relOut))
assert.Equal(t, 3, relOut.Files)
// blame
blameResp, err := c.Control(ControlEnrichBlame, EnrichBlameParams{Path: "/r"})
require.NoError(t, err)
require.True(t, blameResp.OK, "blame: %+v", blameResp)
var blameOut EnrichBlameResult
require.NoError(t, json.Unmarshal(blameResp.Result, &blameOut))
assert.Equal(t, 5, blameOut.Nodes)
// coverage
covResp, err := c.Control(ControlEnrichCoverage, EnrichCoverageParams{
Path: "/r",
Segments: []EnrichCoverageSegment{
{File: "a.go", StartLine: 1, EndLine: 3, NumStmt: 2, Count: 1},
{File: "a.go", StartLine: 4, EndLine: 6, NumStmt: 1, Count: 0},
},
})
require.NoError(t, err)
require.True(t, covResp.OK, "coverage: %+v", covResp)
var covOut EnrichCoverageResult
require.NoError(t, json.Unmarshal(covResp.Result, &covOut))
assert.Equal(t, 7, covOut.Symbols)
assert.Equal(t, 2, covOut.Segments)
// cochange
coResp, err := c.Control(ControlEnrichCochange, EnrichCochangeParams{Path: "/r"})
require.NoError(t, err)
require.True(t, coResp.OK, "cochange: %+v", coResp)
var coOut EnrichCochangeResult
require.NoError(t, json.Unmarshal(coResp.Result, &coOut))
assert.Equal(t, 11, coOut.Edges)
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
require.Len(t, ctrl.enrichChurnCalls, 1)
assert.Equal(t, "/r", ctrl.enrichChurnCalls[0].Path)
require.Len(t, ctrl.enrichReleasesCalls, 1)
require.Len(t, ctrl.enrichBlameCalls, 1)
assert.Equal(t, "/r", ctrl.enrichBlameCalls[0].Path)
require.Len(t, ctrl.enrichCoverageCalls, 1)
assert.Len(t, ctrl.enrichCoverageCalls[0].Segments, 2)
require.Len(t, ctrl.enrichCochangeCalls, 1)
}
func TestDaemon_ProtocolMismatchRejected(t *testing.T) {
_, socket := newDaemon(t, &fakeController{})
// Bump the version so the daemon rejects us.
_, err := DialTo(socket, Handshake{Version: ProtocolVersion + 1, Mode: ModeControl})
require.Error(t, err)
assert.Contains(t, err.Error(), "protocol")
}
func TestDial_NoDaemon_ReturnsErrUnavailable(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
missing := filepath.Join(dir, "missing")
_, err = DialTo(missing, Handshake{Mode: ModeControl})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrDaemonUnavailable),
"Dial against missing socket must wrap ErrDaemonUnavailable; got %v", err)
}
func TestIsRunningAt(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
missing := filepath.Join(dir, "nope")
assert.False(t, IsRunningAt(missing))
_, socket := newDaemon(t, &fakeController{})
assert.True(t, IsRunningAt(socket))
}
func TestDaemon_ConcurrentSessions(t *testing.T) {
// Multiple clients handshake simultaneously. Each must get a unique
// session ID and the daemon's session count must reflect them.
srv, socket := newDaemon(t, &fakeController{})
const N = 8
clients := make([]*Client, N)
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int) {
defer wg.Done()
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: fmt.Sprintf("c%d", i)})
if err != nil {
t.Errorf("dial %d: %v", i, err)
return
}
clients[i] = c
}(i)
}
wg.Wait()
seen := make(map[string]bool)
for _, c := range clients {
require.NotNil(t, c)
require.NotEmpty(t, c.Ack.SessionID)
assert.False(t, seen[c.Ack.SessionID], "session_id collision: %s", c.Ack.SessionID)
seen[c.Ack.SessionID] = true
}
require.Eventually(t, func() bool {
return srv.Sessions().Count() == N
}, 2*time.Second, 10*time.Millisecond,
"daemon should see all %d sessions", N)
for _, c := range clients {
_ = c.Close()
}
require.Eventually(t, func() bool {
return srv.Sessions().Count() == 0
}, 2*time.Second, 10*time.Millisecond,
"sessions should drain after clients close")
}
func TestDaemon_ShutdownRemovesSocketAndPIDFile(t *testing.T) {
srv, socket := newDaemon(t, &fakeController{})
pidFile := PIDFilePath()
require.True(t, fileExists(socket))
require.True(t, fileExists(pidFile))
require.NoError(t, srv.Shutdown())
require.Eventually(t, func() bool {
return !fileExists(socket) && !fileExists(pidFile)
}, 2*time.Second, 10*time.Millisecond)
}
func TestDaemon_ShutdownViaControl(t *testing.T) {
ctrl := &fakeController{}
srv, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl})
require.NoError(t, err)
resp, err := c.Control(ControlShutdown, nil)
require.NoError(t, err)
require.True(t, resp.OK)
_ = c.Close()
require.Eventually(t, func() bool { return !IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
assert.Equal(t, 1, ctrl.shutdownCalls, "Controller.Shutdown must be invoked")
// Calling Shutdown again must be safe (idempotent).
require.NoError(t, srv.Shutdown())
}
// --- helpers ---
func fileExists(path string) bool {
_, err := osStat(path)
return err == nil
}