chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user