mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
634 lines
19 KiB
Go
634 lines
19 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestFindLocalDirectoryAssignment(t *testing.T) {
|
|
const thisDaemon = "d-mine"
|
|
otherDaemon := "d-other"
|
|
|
|
mkRef := func(t *testing.T, ref localDirectoryRef) json.RawMessage {
|
|
t.Helper()
|
|
raw, err := json.Marshal(ref)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
tmp := t.TempDir()
|
|
|
|
t.Run("no resources returns nil", func(t *testing.T) {
|
|
got, err := findLocalDirectoryAssignment(nil, thisDaemon)
|
|
if err != nil || got != nil {
|
|
t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("other daemon is skipped", func(t *testing.T) {
|
|
got, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: otherDaemon})},
|
|
}, thisDaemon)
|
|
if err != nil || got != nil {
|
|
t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("non-matching type is skipped", func(t *testing.T) {
|
|
got, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: "github_repo", ResourceRef: json.RawMessage(`{"url":"https://x"}`)},
|
|
}, thisDaemon)
|
|
if err != nil || got != nil {
|
|
t.Fatalf("expected (nil, nil), got (%+v, %v)", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("matching daemon returns assignment", func(t *testing.T) {
|
|
got, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})},
|
|
}, thisDaemon)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("expected assignment, got nil")
|
|
}
|
|
if got.AbsPath != filepath.Clean(tmp) {
|
|
t.Errorf("AbsPath = %q, want %q", got.AbsPath, filepath.Clean(tmp))
|
|
}
|
|
if got.RealPath == "" {
|
|
t.Errorf("RealPath empty")
|
|
}
|
|
})
|
|
|
|
t.Run("missing daemon_id is rejected", func(t *testing.T) {
|
|
_, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp})},
|
|
}, thisDaemon)
|
|
if err == nil {
|
|
t.Fatalf("expected error for missing daemon_id")
|
|
}
|
|
})
|
|
|
|
t.Run("relative path is rejected", func(t *testing.T) {
|
|
_, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: "relative/path", DaemonID: thisDaemon})},
|
|
}, thisDaemon)
|
|
if err == nil {
|
|
t.Fatalf("expected error for relative path")
|
|
}
|
|
})
|
|
|
|
t.Run("malformed ref json fails", func(t *testing.T) {
|
|
_, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: json.RawMessage(`{not json`)},
|
|
}, thisDaemon)
|
|
if err == nil {
|
|
t.Fatalf("expected error for malformed json")
|
|
}
|
|
})
|
|
|
|
t.Run("two local_directory rows on this daemon fail fast", func(t *testing.T) {
|
|
// Server-side findLocalDirectoryConflict enforces one
|
|
// local_directory per (project, daemon). If two rows are
|
|
// somehow present (older API client, direct DB writes), the
|
|
// daemon must refuse to guess which directory to execute in.
|
|
tmp2 := t.TempDir()
|
|
_, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})},
|
|
{ID: "r2", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp2, DaemonID: thisDaemon})},
|
|
}, thisDaemon)
|
|
if err == nil {
|
|
t.Fatalf("expected error for two local_directory rows pinned to this daemon")
|
|
}
|
|
if !strings.Contains(err.Error(), "multiple local_directory") {
|
|
t.Errorf("error %q did not mention multiple local_directory", err)
|
|
}
|
|
})
|
|
|
|
t.Run("local_directory rows on different daemons coexist", func(t *testing.T) {
|
|
// Different daemons MAY each carry one row — same path on
|
|
// different machines is allowed; this daemon only resolves
|
|
// its own row regardless of how many other-daemon rows are
|
|
// in the list.
|
|
got, err := findLocalDirectoryAssignment([]ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: thisDaemon})},
|
|
{ID: "r2", ResourceType: localDirectoryResourceType, ResourceRef: mkRef(t, localDirectoryRef{LocalPath: tmp, DaemonID: otherDaemon})},
|
|
}, thisDaemon)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("expected assignment, got nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAcquireLocalDirectoryLockSkipsSquadLeaderTasks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const daemonID = "d-mine"
|
|
tmp := t.TempDir()
|
|
raw, err := json.Marshal(localDirectoryRef{LocalPath: tmp, DaemonID: daemonID})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
resources := []ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: raw},
|
|
}
|
|
|
|
worker := Task{
|
|
ID: "worker-task",
|
|
ProjectResources: resources,
|
|
}
|
|
assignment, err := localDirectoryAssignmentForTask(worker, daemonID)
|
|
if err != nil {
|
|
t.Fatalf("worker assignment: %v", err)
|
|
}
|
|
if assignment == nil {
|
|
t.Fatal("worker assignment is nil")
|
|
}
|
|
|
|
d := &Daemon{
|
|
cfg: Config{DaemonID: daemonID},
|
|
localPathLocks: NewLocalPathLocker(),
|
|
logger: slog.Default(),
|
|
}
|
|
leader := Task{
|
|
ID: "leader-task",
|
|
IsLeaderTask: true,
|
|
ProjectResources: resources,
|
|
}
|
|
leaderAssignment, err := localDirectoryAssignmentForTask(leader, daemonID)
|
|
if err != nil {
|
|
t.Fatalf("leader assignment: %v", err)
|
|
}
|
|
if leaderAssignment != nil {
|
|
t.Fatalf("leader assignment = %+v, want nil", leaderAssignment)
|
|
}
|
|
leaderRelease, abort := d.acquireLocalDirectoryLockIfNeeded(context.Background(), leader, slog.Default())
|
|
if abort {
|
|
t.Fatal("leader lock acquisition aborted")
|
|
}
|
|
if leaderRelease != nil {
|
|
t.Fatal("leader lock acquisition returned a release callback")
|
|
}
|
|
if got := d.localPathLocks.Holder(assignment.RealPath); got != "" {
|
|
t.Fatalf("holder after leader skip = %q, want empty", got)
|
|
}
|
|
|
|
release, abort := d.acquireLocalDirectoryLockIfNeeded(context.Background(), worker, slog.Default())
|
|
if abort {
|
|
t.Fatal("worker lock acquisition aborted")
|
|
}
|
|
if release == nil {
|
|
t.Fatal("worker lock acquisition returned nil release")
|
|
}
|
|
defer release()
|
|
if got := d.localPathLocks.Holder(assignment.RealPath); got != worker.ID {
|
|
t.Fatalf("holder = %q, want %q", got, worker.ID)
|
|
}
|
|
|
|
leaderRelease, abort = d.acquireLocalDirectoryLockIfNeeded(context.Background(), leader, slog.Default())
|
|
if abort {
|
|
t.Fatal("leader lock acquisition aborted")
|
|
}
|
|
if leaderRelease != nil {
|
|
t.Fatal("leader lock acquisition returned a release callback")
|
|
}
|
|
if got := d.localPathLocks.Holder(assignment.RealPath); got != worker.ID {
|
|
t.Fatalf("holder after leader skip = %q, want %q", got, worker.ID)
|
|
}
|
|
}
|
|
|
|
func TestValidateLocalPath(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("blacklist constants are POSIX-only in this test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
|
|
t.Run("accepts a writable directory", func(t *testing.T) {
|
|
if err := validateLocalPath(dir); err != nil {
|
|
t.Errorf("unexpected: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects relative path", func(t *testing.T) {
|
|
if err := validateLocalPath("relative"); err == nil {
|
|
t.Errorf("expected error")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects empty path", func(t *testing.T) {
|
|
if err := validateLocalPath(""); err == nil {
|
|
t.Errorf("expected error")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects system roots", func(t *testing.T) {
|
|
for _, banned := range []string{"/", "/Users", "/home"} {
|
|
if err := validateLocalPath(banned); err == nil {
|
|
t.Errorf("expected error for %q", banned)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("rejects the user home directory", func(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
t.Skip("no home dir")
|
|
}
|
|
if err := validateLocalPath(home); err == nil {
|
|
t.Errorf("expected error for $HOME")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects missing path", func(t *testing.T) {
|
|
missing := filepath.Join(dir, "does-not-exist")
|
|
if err := validateLocalPath(missing); err == nil {
|
|
t.Errorf("expected error")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects a regular file", func(t *testing.T) {
|
|
f := filepath.Join(dir, "afile")
|
|
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := validateLocalPath(f); err == nil {
|
|
t.Errorf("expected error")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects an unwritable directory", func(t *testing.T) {
|
|
// chmod-based unwritable is unreliable as root; skip when uid==0.
|
|
if os.Getuid() == 0 {
|
|
t.Skip("test cannot run as root; chmod is a no-op")
|
|
}
|
|
ro := filepath.Join(dir, "ro")
|
|
if err := os.Mkdir(ro, 0o555); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(ro, 0o755) })
|
|
if err := validateLocalPath(ro); err == nil {
|
|
t.Errorf("expected error for read-only directory")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects a symlink pointing at the user home", func(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
t.Skip("no home dir")
|
|
}
|
|
link := filepath.Join(dir, "home-link")
|
|
if err := os.Symlink(home, link); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
// The literal cleaned path is dir/home-link, which is NOT in the
|
|
// blacklist. Without the realpath check this used to pass.
|
|
err = validateLocalPath(link)
|
|
if err == nil {
|
|
t.Fatal("expected error for symlink pointing at $HOME")
|
|
}
|
|
if !strings.Contains(err.Error(), "user's home directory") {
|
|
t.Errorf("error %q did not flag the home-dir reason", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("rejects a symlink pointing at a system root", func(t *testing.T) {
|
|
link := filepath.Join(dir, "root-link")
|
|
// Pick a banned system root that's predictably present on the
|
|
// host. /Users on macOS; /home on Linux. Fall back to /etc which
|
|
// is in the blacklist and exists on both.
|
|
target := "/etc"
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
err := validateLocalPath(link)
|
|
if err == nil {
|
|
t.Fatal("expected error for symlink pointing at a system root")
|
|
}
|
|
if !strings.Contains(err.Error(), "protected system root") {
|
|
t.Errorf("error %q did not flag the system-root reason", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("accepts a symlink to a non-blacklisted directory", func(t *testing.T) {
|
|
target := filepath.Join(dir, "real-proj")
|
|
if err := os.Mkdir(target, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
link := filepath.Join(dir, "proj-link")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
if err := validateLocalPath(link); err != nil {
|
|
t.Errorf("symlink to a regular directory should pass, got %v", err)
|
|
}
|
|
})
|
|
|
|
// macOS aliases /tmp, /etc, /var to /private/{tmp,etc,var} via OS-level
|
|
// symlinks. A user typing the canonical /private/... form in the picker
|
|
// would pass the literal blacklist (it doesn't contain /private/tmp)
|
|
// and EvalSymlinks would be a no-op (the input is already canonical),
|
|
// so the old "only re-check when realPath != absPath" gate skipped it.
|
|
// Cover the regression so the realpath blacklist always runs.
|
|
t.Run("rejects canonical macOS /private/{tmp,etc,var}", func(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("macOS-only: /private/* aliases don't exist elsewhere")
|
|
}
|
|
for _, p := range []string{"/private/tmp", "/private/etc", "/private/var"} {
|
|
if _, statErr := os.Stat(p); statErr != nil {
|
|
t.Logf("skipping %q: %v", p, statErr)
|
|
continue
|
|
}
|
|
err := validateLocalPath(p)
|
|
if err == nil {
|
|
t.Errorf("expected error for canonical %q", p)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "protected system root") {
|
|
t.Errorf("error %q for %q did not flag the system-root reason", err.Error(), p)
|
|
}
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
// TestIsDriveRoot covers the Windows drive-root generalisation. Static
|
|
// enumeration in the old blacklist (C..F) missed mounts at G:\ and up; the
|
|
// new check goes through filepath.VolumeName so any drive letter (and UNC
|
|
// roots) is rejected.
|
|
func TestIsDriveRoot(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
// filepath.VolumeName returns "" on POSIX, so isDriveRoot always
|
|
// returns false off Windows. The semantic contract is enforced by
|
|
// the early `runtime.GOOS != "windows"` guard; the case table
|
|
// below is only meaningful on a Windows runner.
|
|
t.Skip("windows-only behaviour")
|
|
}
|
|
cases := []struct {
|
|
p string
|
|
want bool
|
|
}{
|
|
{`C:\`, true},
|
|
{`G:\`, true},
|
|
{`Z:\`, true},
|
|
{`C:/`, true},
|
|
{`C:`, true},
|
|
{`\\srv\share`, true},
|
|
{`\\srv\share\`, true},
|
|
{`C:\Users`, false},
|
|
{`D:\proj`, false},
|
|
{`C:\Users\me\code`, false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isDriveRoot(c.p); got != c.want {
|
|
t.Errorf("isDriveRoot(%q) = %v, want %v", c.p, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalPathLockerSerializes(t *testing.T) {
|
|
locker := NewLocalPathLocker()
|
|
const path = "/some/path"
|
|
|
|
rel1, err := locker.Acquire(context.Background(), path, "task-1", nil)
|
|
if err != nil {
|
|
t.Fatalf("acquire 1: %v", err)
|
|
}
|
|
if got := locker.Holder(path); got != "task-1" {
|
|
t.Errorf("holder = %q, want task-1", got)
|
|
}
|
|
|
|
// task-2 must wait, with onWait fired and the holder reported.
|
|
var waitCalls atomic.Int32
|
|
var sawHolder atomic.Value
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
rel, err := locker.Acquire(context.Background(), path, "task-2", func(holder string) {
|
|
waitCalls.Add(1)
|
|
sawHolder.Store(holder)
|
|
})
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
if got := locker.Holder(path); got != "task-2" {
|
|
done <- errorsNew("holder after handover = " + got)
|
|
return
|
|
}
|
|
rel()
|
|
done <- nil
|
|
}()
|
|
|
|
// give the goroutine time to enter the wait
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) && waitCalls.Load() == 0 {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if waitCalls.Load() != 1 {
|
|
t.Fatalf("onWait calls = %d, want 1", waitCalls.Load())
|
|
}
|
|
if got := sawHolder.Load(); got != "task-1" {
|
|
t.Errorf("onWait holder = %v, want task-1", got)
|
|
}
|
|
|
|
rel1()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("waiter result: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("waiter never woke")
|
|
}
|
|
if got := locker.Holder(path); got != "" {
|
|
t.Errorf("holder after release = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestLocalPathLockerCtxCancel(t *testing.T) {
|
|
locker := NewLocalPathLocker()
|
|
const path = "/some/path"
|
|
|
|
rel1, err := locker.Acquire(context.Background(), path, "task-1", nil)
|
|
if err != nil {
|
|
t.Fatalf("acquire 1: %v", err)
|
|
}
|
|
defer rel1()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err = locker.Acquire(ctx, path, "task-2", nil)
|
|
if err == nil {
|
|
t.Fatalf("expected ctx error, got nil")
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("err = %v, want DeadlineExceeded", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalPathLockerDistinctPathsParallel(t *testing.T) {
|
|
locker := NewLocalPathLocker()
|
|
|
|
rel1, err := locker.Acquire(context.Background(), "/a", "task-1", nil)
|
|
if err != nil {
|
|
t.Fatalf("acquire 1: %v", err)
|
|
}
|
|
defer rel1()
|
|
|
|
// Different path must not block.
|
|
done := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
rel2, err := locker.Acquire(context.Background(), "/b", "task-2", nil)
|
|
if err != nil {
|
|
t.Errorf("acquire 2: %v", err)
|
|
return
|
|
}
|
|
rel2()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("acquire on distinct path blocked")
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// errorsNew is a tiny helper so the goroutine above can return a typed error
|
|
// without importing errors / fmt at the call site.
|
|
func errorsNew(msg string) error { return &waiterError{msg: msg} }
|
|
|
|
type waiterError struct{ msg string }
|
|
|
|
func (e *waiterError) Error() string { return e.msg }
|
|
|
|
// TestAcquireLocalDirectoryLock_CancelDuringWait covers the gap between
|
|
// dispatch and StartTask: while the path mutex is contended, the main
|
|
// per-task cancellation watcher hasn't started yet. If the issue is
|
|
// cancelled (or the task row is reassigned / deleted) during the wait,
|
|
// the daemon must notice promptly and bail — otherwise the slot stays
|
|
// pinned by a phantom waiter for the full lifetime of the holder.
|
|
func TestAcquireLocalDirectoryLock_CancelDuringWait(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir() // valid, writable, non-blacklisted
|
|
// Pre-claim must use the same key the production path computes, which
|
|
// is the symlink-resolved realpath. On macOS, /tmp/... resolves to
|
|
// /private/tmp/..., so a literal preclaim with `dir` would miss the
|
|
// production key and the new acquire would win on the fast path.
|
|
realDir, err := filepath.EvalSymlinks(dir)
|
|
if err != nil {
|
|
t.Fatalf("evalsymlinks: %v", err)
|
|
}
|
|
|
|
// Server-side state for the fake. Mark the task cancelled only after
|
|
// we've seen the daemon call wait-local-directory, so the test can
|
|
// assert the watcher reacted to the post-park cancel rather than
|
|
// reading stale state on the very first poll.
|
|
var (
|
|
parked atomic.Bool
|
|
waitCall atomic.Int32
|
|
)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/wait-local-directory"):
|
|
waitCall.Add(1)
|
|
parked.Store(true)
|
|
w.WriteHeader(http.StatusOK)
|
|
case strings.HasSuffix(r.URL.Path, "/status"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if parked.Load() {
|
|
_, _ = w.Write([]byte(`{"status":"cancelled"}`))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"status":"running"}`))
|
|
}
|
|
default:
|
|
// We don't expect /fail in the cancel path — assert that
|
|
// by failing loud if it gets called.
|
|
t.Errorf("unexpected daemon call: %s %s", r.Method, r.URL.Path)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
const daemonID = "d-test"
|
|
const heldByTaskID = "task-holder"
|
|
const newTaskID = "task-waiter"
|
|
|
|
locker := NewLocalPathLocker()
|
|
// Pre-claim the lock so the new task has to wait. Use the resolved
|
|
// realpath as the key to match findLocalDirectoryAssignment.
|
|
release, err := locker.Acquire(context.Background(), realDir, heldByTaskID, nil)
|
|
if err != nil {
|
|
t.Fatalf("preclaim acquire: %v", err)
|
|
}
|
|
t.Cleanup(release)
|
|
|
|
d := &Daemon{
|
|
client: NewClient(srv.URL),
|
|
logger: slog.Default(),
|
|
localPathLocks: locker,
|
|
cancelPollInterval: 10 * time.Millisecond,
|
|
cfg: Config{DaemonID: daemonID},
|
|
}
|
|
|
|
ref, err := json.Marshal(localDirectoryRef{LocalPath: dir, DaemonID: daemonID})
|
|
if err != nil {
|
|
t.Fatalf("marshal ref: %v", err)
|
|
}
|
|
task := Task{
|
|
ID: newTaskID,
|
|
ProjectResources: []ProjectResourceData{
|
|
{ID: "r1", ResourceType: localDirectoryResourceType, ResourceRef: ref},
|
|
},
|
|
}
|
|
|
|
type result struct {
|
|
release func()
|
|
abort bool
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
rel, abort := d.acquireLocalDirectoryLockIfNeeded(context.Background(), task, slog.Default())
|
|
done <- result{release: rel, abort: abort}
|
|
}()
|
|
|
|
select {
|
|
case got := <-done:
|
|
if !got.abort {
|
|
t.Fatal("expected abort=true after server-side cancel, got abort=false")
|
|
}
|
|
if got.release != nil {
|
|
t.Fatal("expected nil release on cancel, got a non-nil callback")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("acquireLocalDirectoryLockIfNeeded blocked past 2s — cancel was not observed during wait")
|
|
}
|
|
|
|
if got := waitCall.Load(); got != 1 {
|
|
t.Errorf("wait-local-directory calls = %d, want 1", got)
|
|
}
|
|
}
|