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
403 lines
12 KiB
Go
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
|
|
}
|