mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Inbound sync was invisible to the machine it landed on — a local index, cache or notifier had to poll. A `post_sync` command in the folder's own .bdrive/config.json now runs once per cycle that applied peer changes, with the batch as JSON on stdin. The batch rides out on a new Result.Inbound rather than the inbound spool: DrainInbound is destructive and `bdrive sync --hook` is its only consumer, so a second drainer would silently empty the agent's "teammates changed X" context. Both are kept, and both comments now say why. Cycle becomes a thin wrapper over cycleLocked so the hook is spawned after the volume flock drops — a property of the code shape, not a rule each of the seven call sites has to remember. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
package webapp
|
|
|
|
// post_sync against the real binary and a real volume flock: a hook that runs
|
|
// a bdrive subcommand — the case the whole "fire outside the lock" rule exists
|
|
// for — receives the batch on stdin and runs to completion.
|
|
//
|
|
// It does NOT prove the ordering, and the plan's claim that it would is wrong:
|
|
// the child is detached and nothing waits on it, so a hook spawned INSIDE the
|
|
// flock stalls on LOCK_EX until the parent's cycle ends rather than deadlocking
|
|
// (verified by moving the call and watching this test still pass). Firing after
|
|
// the unlock is still the right shape — a hook must not queue behind a long
|
|
// push — but it is a code-shape property, guarded by Cycle being a wrapper,
|
|
// not by an assertion here.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
)
|
|
|
|
func TestCLIPostSyncRunningBdriveDoesNotDeadlock(t *testing.T) {
|
|
hub := startTestHub(t)
|
|
first := newCLIEnvOn(t, hub)
|
|
|
|
owner := filepath.Join(t.TempDir(), "team")
|
|
if err := os.MkdirAll(owner, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(owner, "seed.md"), []byte("# seed\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := first.run(owner, "init", "--name", "postsync-e2e", "--yes"); err != nil {
|
|
t.Fatalf("init owner: %v\n%s", err, out)
|
|
}
|
|
defer first.run(owner, "stop", owner)
|
|
|
|
second := newCLIEnvOn(t, hub)
|
|
joiner := filepath.Join(t.TempDir(), "joined")
|
|
if err := os.MkdirAll(joiner, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := projectIDByName(t, first.browser, hub.URL, "postsync-e2e")
|
|
if out, err := second.run(joiner, "init", "--project", id, "--yes"); err != nil {
|
|
t.Fatalf("init joiner: %v\n%s", err, out)
|
|
}
|
|
defer second.run(joiner, "stop", joiner)
|
|
|
|
// The hook runs a bdrive command that takes the same volume flock the
|
|
// cycle spawning it just held, then leaves a marker holding the batch.
|
|
marker := filepath.Join(t.TempDir(), "marker.json")
|
|
batchFile := filepath.Join(t.TempDir(), "batch.json")
|
|
cfgPath := filepath.Join(joiner, ".bdrive", "config.json")
|
|
proj, ok, err := config.LoadProject(joiner)
|
|
if err != nil || !ok {
|
|
t.Fatalf("load joiner project: %v (ok=%v)", err, ok)
|
|
}
|
|
proj.PostSync = "cat > " + batchFile + " && " + second.bin + " sync " + joiner + " > /dev/null 2>&1 && mv " + batchFile + " " + marker
|
|
body, err := json.Marshal(proj)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(cfgPath, body, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A teammate's change, then the joiner picks it up.
|
|
if err := os.WriteFile(filepath.Join(owner, "from-owner.md"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := first.run(owner, "sync"); err != nil {
|
|
t.Fatalf("owner sync: %v\n%s", err, out)
|
|
}
|
|
if out, err := second.run(joiner, "sync"); err != nil {
|
|
t.Fatalf("joiner sync: %v\n%s", err, out)
|
|
}
|
|
|
|
// Whichever cycle applied it — this sync or the joiner's daemon — the hook
|
|
// must have run its bdrive command to completion.
|
|
deadline := time.Now().Add(60 * time.Second)
|
|
for {
|
|
raw, err := os.ReadFile(marker)
|
|
if err == nil {
|
|
var got struct {
|
|
Changed []struct{ Path, Op string } `json:"changed"`
|
|
}
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Fatalf("post_sync stdin was not the JSON batch: %v (%q)", err, raw)
|
|
}
|
|
var found bool
|
|
for _, c := range got.Changed {
|
|
if c.Path == "from-owner.md" && c.Op == "write" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("batch %+v does not name the teammate's file", got.Changed)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("post_sync running `bdrive sync` never completed — spawned inside the volume flock?")
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
}
|