fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+21 -3
View File
@@ -169,7 +169,27 @@ func (s *Server) handleSessionEvents(w http.ResponseWriter, r *http.Request) {
if limit > maxEventsLimit {
limit = maxEventsLimit
}
events, err := s.store.EventsAfter(id, after, limit)
var (
events []Event
err error
)
switch {
case r.URL.Query().Get("latest") != "":
if r.URL.Query().Get("latest") != "1" {
writeError(w, http.StatusBadRequest, "invalid latest")
return
}
events, err = s.store.EventsLatest(id, limit)
case r.URL.Query().Get("before") != "":
before, perr := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
if perr != nil || before < 0 {
writeError(w, http.StatusBadRequest, "invalid before")
return
}
events, err = s.store.EventsBefore(id, before, limit)
default:
events, err = s.store.EventsAfter(id, after, limit)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -360,7 +380,5 @@ func (s *Server) webHandler(webdist string) http.Handler {
})
}
var _ = daemonVersion
// daemonToken is set by main from LVMH_TOKEN (single process, read-only).
var daemonToken string
+47 -2
View File
@@ -4,6 +4,7 @@ package main
// serving, body/token validation edges.
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -37,7 +38,7 @@ func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -139,7 +140,7 @@ func TestAPISpawnFailures(t *testing.T) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -279,6 +280,50 @@ func TestAPIPromptBodyValidation(t *testing.T) {
}
}
func TestAPIEventsLatestAndBeforeParams(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
for seq := int64(1); seq <= 5; seq++ {
if err := store.AppendEvent(Event{SessionID: "s1", Seq: seq, TS: seq, Type: evAgentSettled, Payload: []byte(`{}`)}); err != nil {
t.Fatalf("append %d: %v", seq, err)
}
}
getSeqs := func(query string) []int64 {
t.Helper()
code, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+query, auth, "")
if code != http.StatusOK {
t.Fatalf("%s → %d %s, want 200", query, code, body)
}
var frames []map[string]any
if err := json.Unmarshal([]byte(body), &frames); err != nil {
t.Fatalf("decode %s: %v", query, err)
}
seqs := make([]int64, 0, len(frames))
for _, f := range frames {
seqs = append(seqs, int64(f["seq"].(float64)))
}
return seqs
}
if got := getSeqs("?latest=1&limit=2"); len(got) != 2 || got[0] != 4 || got[1] != 5 {
t.Fatalf("latest=1&limit=2 = %v, want [4 5] ascending", got)
}
if got := getSeqs("?before=4&limit=2"); len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("before=4&limit=2 = %v, want [2 3] ascending", got)
}
// default behavior unchanged
if got := getSeqs("?after=0&limit=10"); len(got) != 5 || got[0] != 1 {
t.Fatalf("after=0 = %v, want full ascending replay", got)
}
for _, q := range []string{"?before=abc", "?before=-1", "?latest=2"} {
if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events"+q, auth, ""); code != http.StatusBadRequest {
t.Fatalf("%s → %d, want 400", q, code)
}
}
}
func TestAPIEventsEdgeCases(t *testing.T) {
ts, store := newTestServer(t)
auth := testToken
+111 -29
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
"os/exec"
@@ -61,6 +62,7 @@ const (
envLVMHRepo string = "LVMH_REPO"
stopTimeoutSeconds int = 10
buildContextReadLimit int64 = 64 << 20
maxSpawnJobs int = 50 // jobs map pruned to this many entries
)
var errNoContainer = errors.New("no container for session")
@@ -70,7 +72,7 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
// validRepoPath accepts "group/project" style paths (at least two segments).
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "-") }
func repoSlug(repo string) string { return strings.ReplaceAll(repo, "/", "--") }
// newUUID returns a random RFC 4122 v4 UUID string.
func newUUID() string {
@@ -112,6 +114,7 @@ type SpawnResult struct {
// Spawner owns the docker client, per-repo clone serialization and job state.
type Spawner struct {
ctx context.Context // base ctx for async jobs (cancelled on shutdown)
store *Store
hub *Hub
cli *client.Client
@@ -125,16 +128,18 @@ type Spawner struct {
mu sync.Mutex
jobs map[string]*SpawnJob // keyed by sessionId
jobOrder []string // insertion order of jobs, for pruning
slugLocks map[string]*sync.Mutex
}
func NewSpawner(store *Store, hub *Hub, baseURL string) (*Spawner, error) {
func NewSpawner(ctx context.Context, store *Store, hub *Hub, baseURL string) (*Spawner, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("docker client: %w", err)
}
dockerfile := envOr(envWorkerDockerfile, defaultDockerfile)
return &Spawner{
ctx: ctx,
store: store,
hub: hub,
cli: cli,
@@ -173,6 +178,13 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
if !ok {
j = &SpawnJob{SessionID: sessionID, Repo: repo}
s.jobs[sessionID] = j
s.jobOrder = append(s.jobOrder, sessionID)
if len(s.jobOrder) > maxSpawnJobs {
oldest := s.jobOrder[0]
copy(s.jobOrder, s.jobOrder[1:])
s.jobOrder = s.jobOrder[:len(s.jobOrder)-1]
delete(s.jobs, oldest)
}
}
j.Repo = repo
j.State = state
@@ -183,6 +195,23 @@ func (s *Spawner) setJob(sessionID, repo, state, containerID, message string) {
s.hub.BroadcastSpawnStatus()
}
// deleteJob drops a session's job from the map and the insertion order.
func (s *Spawner) deleteJob(sessionID string) {
s.mu.Lock()
if _, ok := s.jobs[sessionID]; !ok {
s.mu.Unlock()
return
}
delete(s.jobs, sessionID)
for i, id := range s.jobOrder {
if id == sessionID {
s.jobOrder = append(s.jobOrder[:i], s.jobOrder[i+1:]...)
break
}
}
s.mu.Unlock()
}
// Start launches the async spawn pipeline and returns the new sessionId.
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
if _, err := s.imageExists(ctx); err != nil {
@@ -234,12 +263,12 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
return
}
s.setJob(sessionID, repo, stateBuilding, "", "")
if err := s.ensureImage(context.Background()); err != nil {
if err := s.ensureImage(s.ctx); err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
}
s.setJob(sessionID, repo, stateCreating, "", "")
containerID, err := s.createAndStart(context.Background(), repo, slug, sessionID)
containerID, err := s.createAndStart(s.ctx, repo, slug, sessionID)
if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
@@ -252,6 +281,8 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
}
// cloneOrUpdate clones the repo into reposDir/<slug> or fast-forwards it.
// Credentials never appear in the clone URL (which git persists into
// .git/config); auth is passed per-invocation via http.extraHeader.
func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
dir := filepath.Join(s.reposDir, slug)
cloneURL, err := s.cloneURL(repo)
@@ -259,7 +290,16 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return err
}
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
if err := gitRun(dir, "pull", "--ff-only"); err != nil {
auth := s.gitAuthArgs()
if branch != "" && gitBranch(dir) != branch {
if err := gitRun(dir, append(append([]string{}, auth...), "fetch", "origin", branch)...); err != nil {
return fmt.Errorf("git fetch %s %s: %w", repo, branch, err)
}
if err := gitRun(dir, "checkout", branch); err != nil {
return fmt.Errorf("git checkout %s: %w", repo, err)
}
}
if err := gitRun(dir, append(append([]string{}, auth...), "pull", "--ff-only")...); err != nil {
return fmt.Errorf("git pull %s: %w", repo, err)
}
return nil
@@ -267,7 +307,7 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
if err := os.MkdirAll(s.reposDir, 0o755); err != nil {
return err
}
args := []string{"clone"}
args := append(s.gitAuthArgs(), "clone")
if branch != "" {
args = append(args, "--branch", branch)
}
@@ -278,18 +318,34 @@ func (s *Spawner) cloneOrUpdate(repo, branch, slug string) error {
return nil
}
// cloneURL builds an authenticated https clone URL when a PAT is stored.
// cloneURL builds the clean https clone URL (never credential-bearing).
func (s *Spawner) cloneURL(repo string) (string, error) {
u, err := url.Parse(s.baseURL + "/" + repo + ".git")
if err != nil {
return "", err
}
if token, ok, _ := s.store.GetSetting(settingGitLabToken); ok && token != "" && u.User == nil {
u.User = url.User(token)
}
return u.String(), nil
}
// gitAuthArgs returns per-invocation git args carrying the stored PAT via
// an HTTP header, or nil when no PAT is stored.
func (s *Spawner) gitAuthArgs() []string {
token, ok, _ := s.store.GetSetting(settingGitLabToken)
if !ok || token == "" {
return nil
}
return []string{"-c", "http.extraHeader=Authorization: token " + token}
}
// gitBranch returns the checked-out branch of an existing clone, "" on failure.
func gitBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func gitRun(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
@@ -327,17 +383,29 @@ func (s *Spawner) ensureImage(ctx context.Context) error {
if err != nil {
return err
}
var buf bytes.Buffer
if err := tarDir(&buf, s.buildContext); err != nil {
return fmt.Errorf("build context %s: %w", s.buildContext, err)
}
resp, err := s.cli.ImageBuild(ctx, &buf, build.ImageBuildOptions{
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, s.buildContext)
_ = pw.CloseWithError(err)
tarDone <- err
}()
resp, buildErr := s.cli.ImageBuild(ctx, pr, build.ImageBuildOptions{
Tags: []string{imageRefWorker},
Dockerfile: relDockerfile,
Remove: true,
})
if err != nil {
return fmt.Errorf("docker build: %w", err)
if buildErr != nil {
_ = pr.CloseWithError(buildErr) // unblock the tar goroutine
}
if tarErr := <-tarDone; tarErr != nil {
if resp.Body != nil {
_ = resp.Body.Close()
}
return fmt.Errorf("build context %s: %w", s.buildContext, tarErr)
}
if buildErr != nil {
return fmt.Errorf("docker build: %w", buildErr)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, buildContextReadLimit))
@@ -378,6 +446,11 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume); err != nil {
// drop the half-seeded volume so the next spawn retries fresh
// instead of silently booting into an empty workspace.
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
}
return "", fmt.Errorf("seed %s: %w", repoVolume, err)
}
}
@@ -409,6 +482,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
Binds: binds,
NetworkMode: container.NetworkMode(s.network),
AutoRemove: false,
Init: &[]bool{true}[0], // tini reaps bridge setup-hook zombies
}
name := "lvmh-agent-" + strings.ReplaceAll(sessionID, "-", "")[:12]
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
@@ -445,18 +519,32 @@ func (s *Spawner) seedVolume(ctx context.Context, slug, repoVolume string) error
}
hostCfg := &container.HostConfig{
Binds: []string{repoVolume + ":" + workspaceMount},
Init: &[]bool{true}[0],
}
created, err := s.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, "lvmh-seed-"+slug)
if err != nil {
return err
}
defer s.removeContainer(context.Background(), created.ID)
var buf bytes.Buffer
if err := tarDir(&buf, repoDir); err != nil {
return fmt.Errorf("tar clone: %w", err)
pr, pw := io.Pipe()
tarDone := make(chan error, 1)
go func() {
err := tarDir(pw, repoDir)
_ = pw.CloseWithError(err)
tarDone <- err
}()
copyErr := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, pr, container.CopyToContainerOptions{})
if copyErr != nil {
_ = pr.CloseWithError(copyErr) // unblock the tar goroutine
}
if err := s.cli.CopyToContainer(ctx, created.ID, workspaceMount, &buf, container.CopyToContainerOptions{}); err != nil {
return fmt.Errorf("copy into volume: %w", err)
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if tarErr := <-tarDone; tarErr != nil {
return fmt.Errorf("tar clone: %w", tarErr)
}
if copyErr != nil {
return fmt.Errorf("copy into volume: %w", copyErr)
}
return nil
}
@@ -484,13 +572,7 @@ func (s *Spawner) RemoveSession(ctx context.Context, sessionID string) error {
if err := s.store.DeleteContainer(sessionID); err != nil {
return err
}
s.mu.Lock()
if j, ok := s.jobs[sessionID]; ok {
j.State = stateError
j.Message = "container removed"
j.UpdatedAt = time.Now().UnixMilli()
}
s.mu.Unlock()
s.deleteJob(sessionID)
s.hub.BroadcastSpawnStatus()
return nil
}
+18 -2
View File
@@ -4,6 +4,7 @@ package main
// fake git binary (PATH shim recording invocations).
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -38,6 +39,7 @@ type recordedCreate struct {
HostConfig struct {
Binds []string `json:"Binds"`
NetworkMode string `json:"NetworkMode"`
Init *bool `json:"Init"`
} `json:"HostConfig"`
}
@@ -58,6 +60,7 @@ type fakeDocker struct {
failStop bool
failWait bool
failVolumeCreate bool
failVolumeDelete bool
failArchive bool
archiveHang bool
waitHang bool
@@ -205,6 +208,16 @@ func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`)
case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/volumes/"):
if f.failVolumeDelete {
writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume delete failed"}`)
return
}
name := strings.TrimPrefix(call.Path, "/volumes/")
f.mu.Lock()
delete(f.volume, name)
f.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case call.Method == http.MethodPost && call.Path == "/containers/create":
var rc recordedCreate
if err := json.Unmarshal(body, &rc); err != nil {
@@ -290,8 +303,11 @@ func useFakeGit(t *testing.T, mode string) string {
" noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" +
" silent) exit 1;;\n" +
"esac\n" +
"# branch probe: emit the configured HEAD name\n" +
"if [ \"$1\" = '-C' ] && [ \"$3\" = rev-parse ]; then printf '%s' \"$FAKE_GIT_HEAD\"; exit 0; fi\n" +
"# clone: create a real worktree so seeding (tarDir) has files to copy\n" +
"if [ \"$1\" = clone ]; then d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\n' > \"$d/README.md\"; fi\n" +
"# (clone may be $1 or $3, depending on leading -c auth args)\n" +
"case \" $* \" in *\" clone \"*) d=$(eval \"echo \\${$#}\"); mkdir -p \"$d\" && printf 'fake-repo\\n' > \"$d/README.md\";; esac\n" +
"exit 0\n"
if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil {
t.Fatalf("write fake git: %v", err)
@@ -345,7 +361,7 @@ func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) {
daemonToken = testToken
store := openTestStore(t)
hub := NewHub(store)
sp, err := NewSpawner(store, hub, "https://gitlab.example/")
sp, err := NewSpawner(context.Background(), store, hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
+25 -18
View File
@@ -18,6 +18,7 @@ import (
const (
settingGitLabToken string = "gitlab_pat"
projectsPerPage int = 50
maxRepoPages int = 5 // pagination cap: 5 pages / 250 repos
gitlabTimeout time.Duration = 15 * time.Second
)
@@ -133,30 +134,36 @@ func (g *GitLab) token() (string, error) {
return token, nil
}
// Repos lists member projects sorted by most recent activity.
// Repos lists member projects sorted by most recent activity, paging
// upstream until a short page (capped at maxRepoPages).
func (g *GitLab) Repos(ctx context.Context) ([]GitLabRepo, error) {
token, err := g.token()
if err != nil {
return nil, err
}
var projects []giteaRepo
path := fmt.Sprintf("/api/v1/user/repos?limit=%d", projectsPerPage)
if err := g.do(ctx, path, token, &projects); err != nil {
return nil, err
}
repos := make([]GitLabRepo, 0, len(projects))
for _, p := range projects {
if p.FullName == "" {
continue
var repos []GitLabRepo
for page := 1; page <= maxRepoPages; page++ {
path := fmt.Sprintf("/api/v1/user/repos?limit=%d&page=%d", projectsPerPage, page)
var projects []giteaRepo
if err := g.do(ctx, path, token, &projects); err != nil {
return nil, err
}
for _, p := range projects {
if p.FullName == "" {
continue
}
repos = append(repos, GitLabRepo{
Path: p.FullName,
Name: p.Name,
Namespace: p.Owner.Login,
LastActivityAt: p.UpdatedAt,
WebURL: p.HTMLURL,
DefaultBranch: p.DefaultBranch,
})
}
if len(projects) < projectsPerPage {
break
}
repos = append(repos, GitLabRepo{
Path: p.FullName,
Name: p.Name,
Namespace: p.Owner.Login,
LastActivityAt: p.UpdatedAt,
WebURL: p.HTMLURL,
DefaultBranch: p.DefaultBranch,
})
}
sort.SliceStable(repos, func(i, j int) bool {
return repos[i].LastActivityAt > repos[j].LastActivityAt
+62
View File
@@ -4,8 +4,10 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
@@ -131,3 +133,63 @@ func TestGitLabReposSkipsEmptyPaths(t *testing.T) {
t.Fatalf("repos = %+v, want empty (blank path skipped)", repos)
}
}
// newPagedGitLab serves full pages until pagesToFull+1, then a short page.
func newPagedGitLab(t *testing.T, alwaysFull bool) *GitLab {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/user/repos", func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
count := projectsPerPage
if !alwaysFull && page > 2 {
count = 1
}
var b strings.Builder
b.WriteString("[")
for i := 0; i < count; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"full_name":"team/p%d-%d","name":"P","owner":{"login":"team"},`+
`"updated_at":"2024-01-01T00:00:00Z","html_url":"https://gl","default_branch":"main"}`, page, i)
}
b.WriteString("]")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(b.String()))
})
up := httptest.NewServer(mux)
t.Cleanup(up.Close)
store := openTestStore(t)
if err := store.SetSetting(settingGitLabToken, "pat"); err != nil {
t.Fatalf("set token: %v", err)
}
return NewGitLab(store, up.URL)
}
func TestGitLabReposPaginatesUntilShortPage(t *testing.T) {
gl := newPagedGitLab(t, false) // 50 + 50 + 1 (short) → stops after page 3
repos, err := gl.Repos(context.Background())
if err != nil {
t.Fatalf("repos: %v", err)
}
if len(repos) != 2*projectsPerPage+1 {
t.Fatalf("repos = %d, want %d (pages 1-2 full + short page 3)", len(repos), 2*projectsPerPage+1)
}
if repos[0].Path != "team/p1-0" || repos[len(repos)-1].Path != "team/p3-0" {
t.Fatalf("first/last = %q..%q, want page1..page3", repos[0].Path, repos[len(repos)-1].Path)
}
}
func TestGitLabReposPaginationCapped(t *testing.T) {
gl := newPagedGitLab(t, true) // upstream always returns full pages
repos, err := gl.Repos(context.Background())
if err != nil {
t.Fatalf("repos: %v", err)
}
if len(repos) != maxRepoPages*projectsPerPage {
t.Fatalf("repos = %d, want capped at %d", len(repos), maxRepoPages*projectsPerPage)
}
}
+102 -32
View File
@@ -44,8 +44,11 @@ const (
agentSendQueue int = 32
webSendQueue int = 128
webFlushInterval time.Duration = 40 * time.Millisecond
webMaxPending int = 4096 // drop slow clients beyond this backlog
webMaxPending int = 4096 // drop slow clients beyond this backlog
webMaxPendingByte int = 64 << 20 // ...or beyond this many queued bytes
promptSendTimeout time.Duration = 3 * time.Second
pongWait time.Duration = 60 * time.Second
pingPeriod time.Duration = 30 * time.Second
writeWait time.Duration = 5 * time.Second
daemonVersion int = 1 // envelope "v"
)
@@ -109,11 +112,12 @@ type webClient struct {
hub *Hub
conn *websocket.Conn
mu sync.Mutex
sub string // subscribed sessionId, "" when none
control [][]byte
events []pendingEvent
dropped bool
mu sync.Mutex
sub string // subscribed sessionId, "" when none
control [][]byte
events []pendingEvent
pendingBytes int
dropped bool
closeOnce sync.Once
done chan struct{}
@@ -126,18 +130,25 @@ func (c *webClient) drop() {
})
}
// overflows reports whether one more item of size n would exceed the caps.
func (c *webClient) overflows(n int) bool {
return len(c.control)+len(c.events) >= webMaxPending ||
c.pendingBytes+n > webMaxPendingByte
}
func (c *webClient) deliverControl(b []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if c.dropped {
return
}
if len(c.control)+len(c.events) >= webMaxPending {
if c.overflows(len(b)) {
c.dropped = true
go c.drop()
return
}
c.control = append(c.control, b)
c.pendingBytes += len(b)
}
func (c *webClient) deliverEvent(e pendingEvent) {
@@ -146,30 +157,68 @@ func (c *webClient) deliverEvent(e pendingEvent) {
if c.dropped {
return
}
if len(c.control)+len(c.events) >= webMaxPending {
if c.overflows(len(e.raw)) {
c.dropped = true
go c.drop()
return
}
c.events = append(c.events, e)
c.pendingBytes += len(e.raw)
}
// writeEventsFrame flushes one batched events frame for a single session.
func (c *webClient) writeEventsFrame(batch []pendingEvent) error {
items := make([]json.RawMessage, 0, len(batch))
for _, e := range batch {
items = append(items, e.raw)
}
payload, err := json.Marshal(map[string]any{
"type": frameEvents,
"sessionId": batch[0].sessionID,
"after": batch[0].seq - 1,
"events": items,
})
if err != nil {
return nil // unmarshalable raw JSON cannot happen; drop the batch
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
return c.conn.WriteMessage(websocket.TextMessage, payload)
}
// writePump flushes queued frames every webFlushInterval, batching events of
// the subscribed session into single frames. Never blocks the hub.
// the subscribed session into single frames (one frame per contiguous
// sessionID run — a mid-queue resubscribe must never mix sessions).
// Never blocks the hub.
func (c *webClient) writePump() {
ticker := time.NewTicker(webFlushInterval)
pings := time.NewTicker(pingPeriod)
defer ticker.Stop()
defer pings.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
case <-pings.C:
if !c.sendPing() {
c.hub.dropWeb(c)
return
}
continue
}
c.mu.Lock()
control := c.control
c.control = nil
events := c.events
c.events = nil
drained := 0
for _, b := range control {
drained += len(b)
}
for _, e := range events {
drained += len(e.raw)
}
c.pendingBytes -= drained
c.mu.Unlock()
for _, b := range control {
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
@@ -178,35 +227,34 @@ func (c *webClient) writePump() {
return
}
}
if len(events) == 0 {
continue
}
items := make([]json.RawMessage, 0, len(events))
after := events[0].seq - 1
for _, e := range events {
items = append(items, e.raw)
}
payload, err := json.Marshal(map[string]any{
"type": frameEvents,
"sessionId": events[0].sessionID,
"after": after,
"events": items,
})
if err != nil {
continue
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
c.hub.dropWeb(c)
return
for i := 0; i < len(events); {
j := i
for j < len(events) && events[j].sessionID == events[i].sessionID {
j++
}
if err := c.writeEventsFrame(events[i:j]); err != nil {
c.hub.dropWeb(c)
return
}
i = j
}
}
}
// sendPing writes one ping frame honoring the write deadline.
func (c *webClient) sendPing() bool {
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
return c.conn.WriteMessage(websocket.PingMessage, nil) == nil
}
// readPump consumes subscribe/unsubscribe frames; malformed input never kills the server.
func (c *webClient) readPump() {
defer c.hub.dropWeb(c)
c.conn.SetReadLimit(maxFrameSize)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
@@ -252,8 +300,11 @@ func (a *agentConn) drop() {
})
}
// writePump serializes daemon→plugin frames (prompt/abort/welcome).
// writePump serializes daemon→plugin frames (prompt/abort/welcome) and
// keeps the conn alive with periodic pings.
func (a *agentConn) writePump() {
pings := time.NewTicker(pingPeriod)
defer pings.Stop()
for {
select {
case <-a.done:
@@ -264,10 +315,21 @@ func (a *agentConn) writePump() {
a.drop()
return
}
case <-pings.C:
if !a.sendPing() {
a.drop()
return
}
}
}
}
// sendPing writes one ping frame honoring the write deadline.
func (a *agentConn) sendPing() bool {
_ = a.conn.SetWriteDeadline(time.Now().Add(writeWait))
return a.conn.WriteMessage(websocket.PingMessage, nil) == nil
}
// Hub tracks live agent conns and web subscribers; it is the only component
// that mutates online state.
type Hub struct {
@@ -418,6 +480,10 @@ func (h *Hub) ServeAgentWS(w http.ResponseWriter, r *http.Request) {
func (h *Hub) readAgentLoop(ac *agentConn) {
defer ac.drop()
ac.conn.SetReadLimit(maxFrameSize)
_ = ac.conn.SetReadDeadline(time.Now().Add(pongWait))
ac.conn.SetPongHandler(func(string) error {
return ac.conn.SetReadDeadline(time.Now().Add(pongWait))
})
registered := false
defer func() {
if registered {
@@ -543,11 +609,15 @@ func (h *Hub) handleEvent(f frame) {
func (h *Hub) unregister(ac *agentConn) {
h.mu.Lock()
removed := false
if cur, ok := h.agents[ac.sessionID]; ok && cur == ac {
delete(h.agents, ac.sessionID)
removed = true
}
h.mu.Unlock()
if ac.sessionID != "" {
// Only flip the persisted flag when no newer conn replaced this one;
// a reconnect races this unregister path.
if removed && ac.sessionID != "" {
if err := h.store.SetOnline(ac.sessionID, false); err != nil {
log.Printf("hub: mark offline %s: %v", ac.sessionID, err)
}
+223
View File
@@ -491,3 +491,226 @@ func TestWebClientDeliverToDropped(t *testing.T) {
t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending)
}
}
func TestWebClientByteOverflowDrops(t *testing.T) {
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})}
// stay under the frame-count cap; cross the byte cap on the last event.
meg := make([]byte, 1<<20)
var i int
for ; (i+1)*(1<<20) <= webMaxPendingByte; i++ {
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: meg})
}
if c.dropped {
t.Fatalf("client dropped at %dMiB, want only beyond %dMiB", i, webMaxPendingByte>>20)
}
c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: meg}) // one over the cap
select {
case <-c.done:
case <-time.After(2 * time.Second):
t.Fatal("byte-overflowed web client was not dropped")
}
if !c.dropped {
t.Fatal("client must be marked dropped on byte overflow")
}
}
func TestUnregisterReplacedConnKeepsOnline(t *testing.T) {
store := openTestStore(t)
hub := NewHub(store)
if err := store.UpsertSession(SessionInfo{ID: "s1", Cwd: "/w", Model: "m", Provider: "p"}); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := store.SetOnline("s1", true); err != nil {
t.Fatalf("online: %v", err)
}
old := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
old.sessionID = "s1"
fresh := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})}
fresh.sessionID = "s1"
hub.agents["s1"] = old
hub.agents["s1"] = fresh // reconnect replaced old
hub.unregister(old)
rows, err := store.Sessions()
if err != nil || len(rows) != 1 {
t.Fatalf("sessions: %v %d", err, len(rows))
}
if !rows[0].OnlineDB {
t.Fatal("replaced conn unregistering must not flip the live session offline")
}
hub.unregister(fresh)
rows, err = store.Sessions()
if err != nil {
t.Fatalf("sessions: %v", err)
}
if rows[0].OnlineDB {
t.Fatal("last live conn unregistering must mark the session offline")
}
}
func TestPingPongConstants(t *testing.T) {
if pingPeriod >= pongWait {
t.Fatalf("pingPeriod %v must be < pongWait %v", pingPeriod, pongWait)
}
}
func TestWebClientSendPing(t *testing.T) {
serverConn := make(chan *websocket.Conn, 1)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
serverConn <- c
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
t.Cleanup(up.Close)
clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = clientConn.Close() })
var server *websocket.Conn
select {
case server = <-serverConn:
case <-time.After(2 * time.Second):
t.Fatal("server conn never upgraded")
}
hub := NewHub(openTestStore(t))
c := &webClient{hub: hub, conn: server, done: make(chan struct{})}
if !c.sendPing() {
t.Fatal("sendPing over a live conn must succeed")
}
ac := &agentConn{hub: hub, conn: server, send: make(chan []byte, 1), done: make(chan struct{})}
if !ac.sendPing() {
t.Fatal("agent sendPing over a live conn must succeed")
}
}
func TestPongFramesKeepReadLoopsAlive(t *testing.T) {
// unsolicited pongs must pass through both read pumps (refreshing the
// read deadline) without killing the conn.
ts, _ := newTestServer(t)
ws := dialAgent(t, ts)
if err := ws.WriteMessage(websocket.PongMessage, nil); err != nil {
t.Fatalf("agent pong: %v", err)
}
_ = ws.WriteJSON(helloFrame("s1"))
if welcome := readFrame(t, ws); welcome["type"] != evWelcome {
t.Fatalf("agent read loop died after pong: %v", welcome)
}
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first web frame = %v", first)
}
if err := web.WriteMessage(websocket.PongMessage, nil); err != nil {
t.Fatalf("web pong: %v", err)
}
// subscribe processed by the (still live) read pump → live events flow.
if err := web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"}); err != nil {
t.Fatalf("subscribe: %v", err)
}
// A reader goroutine avoids deadline-based reads (gorilla conns fail
// permanently after a read timeout); resend until a batch arrives.
frames := make(chan map[string]any, 16)
go func() {
defer close(frames)
for {
var m map[string]any
if err := web.ReadJSON(&m); err != nil {
return
}
frames <- m
}
}()
start := time.Now()
for i := 0; ; i++ {
_ = ws.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s1", "seq": int64(i + 1), "ts": 10, "delta": "x"})
select {
case m, ok := <-frames:
if !ok {
t.Fatal("web conn closed; read loop died after pong")
}
if m["type"] == frameEvents {
return // read pump survived the pong and routed the subscription
}
case <-time.After(200 * time.Millisecond):
}
if time.Since(start) > 3*time.Second {
t.Fatal("no events frame delivered after pong")
}
}
}
func TestWebResubscribeSplitsEventBatches(t *testing.T) {
ts, _, hub := newTestServerHub(t)
agent1 := dialAgent(t, ts)
_ = agent1.WriteJSON(helloFrame("s1"))
_ = readFrame(t, agent1)
agent2 := dialAgent(t, ts)
_ = agent2.WriteJSON(helloFrame("s2"))
_ = readFrame(t, agent2)
web := dialWeb(t, ts)
if first := readFrame(t, web); first["type"] != frameSessionList {
t.Fatalf("first web frame = %v", first)
}
webSubscribed := func(want string) func() bool {
return func() bool {
hub.mu.Lock()
defer hub.mu.Unlock()
for c := range hub.webs {
c.mu.Lock()
sub := c.sub
c.mu.Unlock()
if sub == want {
return true
}
}
return false
}
}
_ = web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"})
waitFor(t, 2*time.Second, webSubscribed("s1"))
_ = agent1.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s1", "seq": 1, "ts": 10, "delta": "one"})
readBatch := func(want string) map[string]any {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
_ = web.SetReadDeadline(time.Now().Add(2 * time.Second))
var m map[string]any
if err := web.ReadJSON(&m); err != nil {
t.Fatalf("read frame (want batch %s): %v", want, err)
}
if m["type"] != frameEvents {
continue
}
if m["sessionId"] != want {
t.Fatalf("events frame sessionId = %v, want %q (sessions must not mix)", m["sessionId"], want)
}
for _, e := range m["events"].([]any) {
if e.(map[string]any)["sessionId"] != want {
t.Fatalf("batch for %s contains event of %v", want, e.(map[string]any)["sessionId"])
}
}
return m
}
t.Fatalf("no events frame for %s before timeout", want)
return nil
}
readBatch("s1") // event A flushed while subscribed to A
_ = web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s2"})
waitFor(t, 2*time.Second, webSubscribed("s2"))
_ = agent2.WriteJSON(map[string]any{"v": 1, "type": evMessageUpdate, "sessionId": "s2", "seq": 1, "ts": 11, "delta": "two"})
readBatch("s2") // event B must arrive as its own frame, never mixed with A's
}
+5 -1
View File
@@ -67,7 +67,9 @@ func run(args []string) error {
hub := NewHub(store)
gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase))
spawner, err := NewSpawner(store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
spawnerCtx, spawnerCtxCancel := context.WithCancel(context.Background())
defer spawnerCtxCancel()
spawner, err := NewSpawner(spawnerCtx, store, hub, envOr(envGitLabBaseURL, defaultGitLabBase))
if err != nil {
return fmt.Errorf("docker client: %w", err)
}
@@ -94,10 +96,12 @@ func run(args []string) error {
defer signal.Stop(stop)
select {
case err := <-serveErr:
spawnerCtxCancel()
return fmt.Errorf("listen: %w", err)
case <-stop:
}
log.Printf("shutting down")
spawnerCtxCancel()
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
+220 -20
View File
@@ -75,7 +75,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
t.Fatalf("missing env %v", wantEnv)
}
wantBinds := []string{
"lvmh-repo-group-project:" + workspaceMount,
"lvmh-repo-group--project:" + workspaceMount,
volumeSessions + ":" + sessionsMount,
volumePiCache + ":" + cacheMount,
}
@@ -90,6 +90,9 @@ func TestSpawnerStartHappyPath(t *testing.T) {
if c.HostConfig.NetworkMode != defaultNetwork {
t.Fatalf("network = %q", c.HostConfig.NetworkMode)
}
if c.HostConfig.Init == nil || !*c.HostConfig.Init {
t.Fatalf("agent container Init = %v, want true (tini zombie reaping)", c.HostConfig.Init)
}
// fresh repo volume seeded from the clone via CopyToContainer (tar)
seed := f.createsByName("lvmh-seed-")
@@ -99,10 +102,13 @@ func TestSpawnerStartHappyPath(t *testing.T) {
if seed[0].Image != imageRefWorker {
t.Fatalf("seed create = %+v", seed[0])
}
wantSeedBinds := []string{"lvmh-repo-group-project:" + workspaceMount}
wantSeedBinds := []string{"lvmh-repo-group--project:" + workspaceMount}
if !reflect.DeepEqual(seed[0].HostConfig.Binds, wantSeedBinds) {
t.Fatalf("seed binds = %v, want %v (no host-path binds)", seed[0].HostConfig.Binds, wantSeedBinds)
}
if seed[0].HostConfig.Init == nil || !*seed[0].HostConfig.Init {
t.Fatalf("seed container Init = %v, want true", seed[0].HostConfig.Init)
}
if len(f.archives) != 1 || f.archives[0] == 0 {
t.Fatalf("CopyToContainer archives = %v, want one non-empty tar", f.archives)
}
@@ -150,7 +156,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
// docker reachable but image absent and no Dockerfile anywhere → clear error
t.Setenv(envWorkerDockerfile, filepath.Join(t.TempDir(), "missing.Dockerfile"))
sp2, err := NewSpawner(sp.store, sp.hub, "https://gitlab.example/")
sp2, err := NewSpawner(context.Background(), sp.store, sp.hub, "https://gitlab.example/")
if err != nil {
t.Fatalf("NewSpawner: %v", err)
}
@@ -250,7 +256,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
sp, _ := newTestSpawner(t, f)
// existing clone → pull --ff-only in that dir, no clone
dir := filepath.Join(sp.reposDir, "group-project")
dir := filepath.Join(sp.reposDir, repoSlug("group/project"))
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
@@ -263,7 +269,7 @@ func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) {
}
}
func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
func TestSpawnerCloneURLNeverCarriesPAT(t *testing.T) {
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
@@ -273,12 +279,13 @@ func TestSpawnerCloneURLInjectsPAT(t *testing.T) {
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
// A stored PAT must never leak into the persisted clone URL.
u, err := sp.cloneURL("group/project")
if err != nil {
t.Fatalf("cloneURL: %v", err)
}
if u != "https://pat-1@gitlab.example/group/project.git" {
t.Fatalf("cloneURL with PAT = %q", u)
if u != "https://gitlab.example/group/project.git" || strings.Contains(u, "pat-1") {
t.Fatalf("cloneURL with stored PAT = %q, want clean URL", u)
}
}
@@ -305,8 +312,8 @@ func TestSpawnerRemoveSession(t *testing.T) {
t.Fatal("container row must be deleted")
}
for _, j := range sp.JobsSnapshot() {
if j.SessionID == "s1" && j.State != stateError {
t.Fatalf("job after removal = %+v, want error state", j)
if j.SessionID == "s1" {
t.Fatalf("job after removal = %+v, want entry deleted", j)
}
}
@@ -326,17 +333,17 @@ func TestSpawnerSeedVolumeCancel(t *testing.T) {
f := newFakeDocker()
f.archiveHang = true // server never answers the archive PUT
sp, _ := newTestSpawner(t, f)
if err := os.MkdirAll(filepath.Join(sp.reposDir, "group-project"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(sp.reposDir, repoSlug("group/project")), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sp.reposDir, "group-project", "README.md"), []byte("x"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(sp.reposDir, repoSlug("group/project"), "README.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project") }()
go func() { done <- sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project") }()
waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPut, "/archive") })
cancel()
select {
@@ -379,9 +386,13 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
}
func TestRepoSlugAndUUID(t *testing.T) {
if got := repoSlug("a/b/c"); got != "a-b-c" {
if got := repoSlug("a/b/c"); got != "a--b--c" {
t.Fatalf("repoSlug = %q", got)
}
// "a/b/c" and "a/b-c" must map to distinct slugs (no collision).
if repoSlug("a/b/c") == repoSlug("a/b-c") {
t.Fatalf("slug collision: %q", repoSlug("a/b/c"))
}
id := newUUID()
if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' {
t.Fatalf("newUUID shape = %q", id)
@@ -391,6 +402,195 @@ func TestRepoSlugAndUUID(t *testing.T) {
}
}
func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
waitJobState(t, sp, res.SessionID, stateRunning)
calls := readGitLog(t, gitLog)
if len(calls) != 1 {
t.Fatalf("git calls = %v", calls)
}
call := calls[0]
// auth travels as a per-invocation -c http.extraHeader arg…
if !strings.HasPrefix(call, "-c http.extraHeader=Authorization: token pat-1 clone ") {
t.Fatalf("git call = %q, want -c http.extraHeader auth before clone", call)
}
// …and the URL recorded into .git/config stays credential-free.
if !strings.Contains(call, " -- https://gitlab.example/group/project.git ") {
t.Fatalf("git call = %q, want clean clone URL", call)
}
if strings.Contains(call, "pat-1@") {
t.Fatalf("git call = %q leaks the PAT into the URL", call)
}
}
func TestSpawnerJobsPrunedToCap(t *testing.T) {
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
for i := 0; i < maxSpawnJobs+10; i++ {
sp.setJob(fmt.Sprintf("s%d", i), "group/project", stateCloning, "", "")
}
jobs := sp.JobsSnapshot()
if len(jobs) != maxSpawnJobs {
t.Fatalf("jobs = %d, want capped at %d", len(jobs), maxSpawnJobs)
}
seen := map[string]bool{}
for _, j := range jobs {
seen[j.SessionID] = true
}
if seen["s0"] {
t.Fatal("oldest job must be pruned first")
}
for _, id := range []string{fmt.Sprintf("s%d", maxSpawnJobs), fmt.Sprintf("s%d", maxSpawnJobs+9)} {
if !seen[id] {
t.Fatalf("newest job %s pruned; kept = %v", id, seen)
}
}
}
func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.failArchive = true // CopyToContainer fails → seed fails
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
job := waitJobState(t, sp, res.SessionID, stateError)
if !strings.Contains(job.Message, "seed") {
t.Fatalf("job message = %q, want seed failure", job.Message)
}
if !f.hasCall(http.MethodDelete, "/volumes/lvmh-repo-group--project") {
t.Fatal("failed seed must force-remove the repo volume for a fresh retry")
}
if f.volumeExists("lvmh-repo-group--project") {
t.Fatal("repo volume must not linger half-seeded")
}
}
func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.failArchive = true
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
if err != nil {
t.Fatalf("Start: %v", err)
}
job := waitJobState(t, sp, res.SessionID, stateError)
if !strings.Contains(job.Message, "seed") {
t.Fatalf("job message = %q, want seed failure despite cleanup failure", job.Message)
}
}
func TestSpawnerCloneOrUpdateBranchFetchFails(t *testing.T) {
useFakeGit(t, fakeGitModeFail) // branch probe and fetch both fail
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
slug := repoSlug("group/project")
if err := os.MkdirAll(filepath.Join(sp.reposDir, slug, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
err := sp.cloneOrUpdate("group/project", "dev", slug)
if err == nil || !strings.Contains(err.Error(), "git fetch") {
t.Fatalf("cloneOrUpdate branch fetch failure = %v, want git fetch error", err)
}
}
func TestSpawnerDeleteJobMissing(t *testing.T) {
sp, _ := newTestSpawner(t, newFakeDocker())
sp.deleteJob("never-existed") // no-op, must not panic
sp.setJob("s1", "group/project", stateRunning, "cid", "")
sp.deleteJob("s1")
for _, j := range sp.JobsSnapshot() {
if j.SessionID == "s1" {
t.Fatalf("job %s still present after deleteJob", j.SessionID)
}
}
}
func TestSpawnerCloneOrUpdateSwitchesBranch(t *testing.T) {
cases := []struct {
name string
branch string
headOut string
wantTail []string
}{
{
name: "same-branch-skips-fetch-checkout",
branch: "main",
headOut: "main\n",
wantTail: []string{"pull --ff-only"},
},
{
name: "different-branch-fetches-and-checks-out",
branch: "dev",
headOut: "main\n",
wantTail: []string{"fetch origin dev", "checkout dev", "pull --ff-only"},
},
{
name: "unknown-head-falls-through-to-fetch",
branch: "dev",
headOut: "",
wantTail: []string{"fetch origin dev", "checkout dev", "pull --ff-only"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gitLog := useFakeGit(t, fakeGitModeOK)
t.Setenv("FAKE_GIT_HEAD", tc.headOut)
f := newFakeDocker()
sp, store := newTestSpawner(t, f)
if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil {
t.Fatalf("set token: %v", err)
}
slug := repoSlug("group/project")
dir := filepath.Join(sp.reposDir, slug)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := sp.cloneOrUpdate("group/project", tc.branch, slug); err != nil {
t.Fatalf("cloneOrUpdate: %v", err)
}
calls := readGitLog(t, gitLog)
if len(calls) != len(tc.wantTail)+1 { // +1: rev-parse probe
t.Fatalf("git calls = %v, want %v (+rev-parse)", calls, tc.wantTail)
}
if !strings.HasPrefix(calls[0], "-C ") || !strings.HasSuffix(calls[0], "rev-parse --abbrev-ref HEAD") {
t.Fatalf("first call = %q, want branch probe", calls[0])
}
authPrefix := "-c http.extraHeader=Authorization: token pat-1 "
for i, want := range tc.wantTail {
got := calls[i+1]
if want == "checkout dev" {
if got != want { // checkout needs no auth
t.Fatalf("call %d = %q, want %q", i+1, got, want)
}
continue
}
if got != authPrefix+want {
t.Fatalf("call %d = %q, want %q%q", i+1, got, authPrefix, want)
}
}
})
}
}
func TestEnvOr(t *testing.T) {
t.Setenv("LVMH_TEST_ENV_OR", " value ")
if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "value" {
@@ -484,7 +684,7 @@ func TestGitRunSilentFailure(t *testing.T) {
func TestSpawnerNewBadDockerHost(t *testing.T) {
t.Setenv("DOCKER_HOST", "http://")
store := openTestStore(t)
if _, err := NewSpawner(store, NewHub(store), "https://gitlab.example"); err == nil {
if _, err := NewSpawner(context.Background(), store, NewHub(store), "https://gitlab.example"); err == nil {
t.Fatal("NewSpawner must fail on an unparseable DOCKER_HOST")
}
}
@@ -533,7 +733,7 @@ func TestSpawnerCloneOrUpdateErrors(t *testing.T) {
t.Fatalf("write file: %v", err)
}
sp.reposDir = file
if err := sp.cloneOrUpdate("group/project", "", "group-project"); err == nil {
if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err == nil {
t.Fatal("cloneOrUpdate with file reposDir must fail")
}
}
@@ -570,7 +770,7 @@ func TestSpawnerEnsureImageErrors(t *testing.T) {
func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true // repo volume exists → skip seed
f.volume["lvmh-repo-group--project"] = true // repo volume exists → skip seed
f.failVolumeCreate = true
sp, _ := newTestSpawner(t, f)
res, err := sp.Start(context.Background(), "group/project", "")
@@ -590,12 +790,12 @@ func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) {
ctx := context.Background()
f.failCreate = true
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
t.Fatal("seedVolume with failing create must fail")
}
f.failCreate = false
f.failStart = true
if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil {
if err := sp.seedVolume(ctx, repoSlug("group/project"), "lvmh-repo-group--project"); err == nil {
t.Fatal("seedVolume with failing start must fail")
}
}
@@ -632,7 +832,7 @@ func TestSpawnerCloneOrUpdatePullFails(t *testing.T) {
useFakeGit(t, fakeGitModeFail)
f := newFakeDocker()
sp, _ := newTestSpawner(t, f)
dir := filepath.Join(sp.reposDir, "group-project")
dir := filepath.Join(sp.reposDir, repoSlug("group/project"))
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
@@ -647,7 +847,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
// worker container create/start instead of at the seed container.
useFakeGit(t, fakeGitModeOK)
f := newFakeDocker()
f.volume["lvmh-repo-group-project"] = true
f.volume["lvmh-repo-group--project"] = true
sp, _ := newTestSpawner(t, f)
f.failCreate = true
+42 -1
View File
@@ -131,6 +131,47 @@ func (s *Store) EventsAfter(sessionID string, after int64, limit int) ([]Event,
return out, rows.Err()
}
// EventsLatest returns the newest limit events for the session, ascending.
func (s *Store) EventsLatest(sessionID string, limit int) ([]Event, error) {
return s.eventsDesc(sessionID, ` WHERE sessionId=? ORDER BY seq DESC LIMIT ?`, limit, sessionID)
}
// EventsBefore returns the newest window of at most limit events with
// seq < before, ascending.
func (s *Store) EventsBefore(sessionID string, before int64, limit int) ([]Event, error) {
return s.eventsDesc(sessionID, ` WHERE sessionId=? AND seq<? ORDER BY seq DESC LIMIT ?`,
limit, sessionID, before)
}
// eventsDesc runs a newest-first query and returns the rows reversed
// (oldest-first), for newest-window reads.
func (s *Store) eventsDesc(sessionID, where string, limit int, args ...any) ([]Event, error) {
rows, err := s.db.Query(
`SELECT sessionId, seq, ts, type, payload FROM events`+where, append(args, limit)...)
if err != nil {
return nil, err
}
defer rows.Close()
var desc []Event
for rows.Next() {
var e Event
var payload string
if err := rows.Scan(&e.SessionID, &e.Seq, &e.TS, &e.Type, &payload); err != nil {
return nil, err
}
e.Payload = json.RawMessage(payload)
desc = append(desc, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Event, len(desc))
for i, e := range desc {
out[len(desc)-1-i] = e
}
return out, nil
}
// UpsertSession inserts or refreshes the info snapshot for a session.
func (s *Store) UpsertSession(info SessionInfo) error {
blob, err := json.Marshal(info)
@@ -158,7 +199,7 @@ func (s *Store) SetOnline(sessionID string, online bool) error {
return err
}
// Sessions returns every known session row ordered oldest-first.
// Sessions returns every known session row ordered by session id.
func (s *Store) Sessions() ([]SessionRow, error) {
rows, err := s.db.Query(`SELECT id, info, lastSeq, lastEventAt, online FROM sessions ORDER BY id`)
if err != nil {
+54
View File
@@ -85,6 +85,9 @@ func TestStoreCorruptRows(t *testing.T) {
if _, err := store.EventsAfter("s1", 0, 10); err == nil {
t.Fatal("EventsAfter with text seq must scan-error")
}
if _, err := store.EventsLatest("s1", 10); err == nil {
t.Fatal("EventsLatest with text seq must scan-error")
}
}
func TestStoreAppendEventDefaultPayload(t *testing.T) {
@@ -100,3 +103,54 @@ func TestStoreAppendEventDefaultPayload(t *testing.T) {
t.Fatalf("payload = %q, want {}", events[0].Payload)
}
}
func seedEvents(t *testing.T, store *Store, sessionID string, seqs ...int64) {
t.Helper()
for _, seq := range seqs {
if err := store.AppendEvent(Event{SessionID: sessionID, Seq: seq, TS: seq, Type: evAgentSettled, Payload: []byte(`{}`)}); err != nil {
t.Fatalf("append %d: %v", seq, err)
}
}
}
func TestStoreEventsLatestAndBefore(t *testing.T) {
store := openTestStore(t)
seedEvents(t, store, "s1", 1, 2, 3, 4, 5)
latest, err := store.EventsLatest("s1", 3)
if err != nil {
t.Fatalf("EventsLatest: %v", err)
}
if len(latest) != 3 {
t.Fatalf("latest = %d events, want 3", len(latest))
}
for i, want := range []int64{3, 4, 5} {
if latest[i].Seq != want {
t.Fatalf("latest[%d].Seq = %d, want %d (newest window, ascending)", i, latest[i].Seq, want)
}
}
before, err := store.EventsBefore("s1", 4, 2)
if err != nil {
t.Fatalf("EventsBefore: %v", err)
}
if len(before) != 2 {
t.Fatalf("before = %d events, want 2", len(before))
}
for i, want := range []int64{2, 3} {
if before[i].Seq != want {
t.Fatalf("before[%d].Seq = %d, want %d (newest below 4, ascending)", i, before[i].Seq, want)
}
}
// windows past the boundaries degrade cleanly
if all, _ := store.EventsLatest("s1", 100); len(all) != 5 {
t.Fatalf("latest over count = %d, want 5", len(all))
}
if none, _ := store.EventsBefore("s1", 1, 10); len(none) != 0 {
t.Fatalf("before 1 = %d events, want 0", len(none))
}
if missing, _ := store.EventsLatest("nope", 10); len(missing) != 0 {
t.Fatalf("latest unknown session = %d events, want 0", len(missing))
}
}