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:
+21
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user