carlos / platform Public

Join listener closure and carry deterministic gate fixtures

claude agent, operated by paul@keymail.dev ed256537a3e4cf9e24dc51781390312990376657 parent 2e489fd
4 files changed, +355 −43
  • internal/activator/activator_test.go +43 −1
  • internal/activator/unitproc.go +10 −10
  • internal/activator/unitproc_close_test.go +106 −0
  • internal/host/movescheckpoint_test.go +196 −32

CI passed — run details

diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index a16d90892..4420ba8a1 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -3242,6 +3242,19 @@ func TestWorkerSurvivingSettleWindowIsAwake(t *testing.T) {
}
}
+type workerReadyBackend struct {
+ *fakeBackend
+ acquired chan struct{}
+ proceed <-chan struct{}
+}
+
+func (b *workerReadyBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) {
+ lease, err := b.fakeBackend.Acquire(ctx, rt)
+ close(b.acquired)
+ <-b.proceed
+ return lease, err
+}
+
// …and a child that reports ready ends the window early: a fast sidecar must
// not pay ten seconds of dead time on every wake.
func TestWorkerReadyEndsSettleWindowEarly(t *testing.T) {
@@ -3250,11 +3263,40 @@ func TestWorkerReadyEndsSettleWindowEarly(t *testing.T) {
sidecarSettle = 5 * time.Second
defer func() { sidecarSettle = old }()
+ acquired, proceed := make(chan struct{}), make(chan struct{})
+ var release sync.Once
+ unblock := func() { release.Do(func() { close(proceed) }) }
+ defer unblock()
+ a.backend = &workerReadyBackend{fakeBackend: b, acquired: acquired, proceed: proceed}
+
done := make(chan error, 1)
start := time.Now()
go func() { done <- a.WakeWorker(testWorkerRoute("bot.x.internal")) }()
- waitFor(t, "the waking entry to appear", func() bool { return a.Owns("bot.x.internal") })
+ select {
+ case <-acquired:
+ case <-time.After(time.Second):
+ t.Fatal("wake did not acquire")
+ }
+ if !a.Owns("bot.x.internal") || a.WorkerToken("bot.x.internal") != "" || b.workerStarts() != 0 {
+ t.Fatal("pre-spawn interleaving was not established")
+ }
+ readyToReport := make(chan struct{})
+ go func() {
+ waitFor(t, "the child to be spawned", func() bool { return b.workerStarts() == 1 })
+ close(readyToReport)
+ }()
+ select {
+ case <-readyToReport:
+ t.Fatal("entry creation was mistaken for a child able to report ready")
+ case <-time.After(100 * time.Millisecond):
+ }
+ unblock()
+ select {
+ case <-readyToReport:
+ case <-time.After(time.Second):
+ t.Fatal("child did not start after acquire continued")
+ }
noteReadyAsChild(t, a, b, "bot.x.internal", "v9")
select {
diff --git a/internal/activator/unitproc.go b/internal/activator/unitproc.go
index 480a6a3a0..bfc65bc80 100644
--- a/internal/activator/unitproc.go
+++ b/internal/activator/unitproc.go
@@ -8,6 +8,7 @@ import (
"log"
"os"
"path/filepath"
+ "sync"
"sync/atomic"
"time"
@@ -39,7 +40,7 @@ type unitProc struct {
logs *logListener
exited atomic.Bool
done chan struct{}
- closeOnce atomic.Bool
+ closeOnce sync.Once
// listenersArmed guards the done-watcher goroutine Stop's ctx.Done
// branch starts: several callers bound their wait on Stop shorter than
// the unit may actually take to die (a sidecar co-stop, a discarded
@@ -104,15 +105,14 @@ func (p *unitProc) Stop(ctx context.Context) error {
}
func (p *unitProc) closeListeners() {
- if p.closeOnce.Swap(true) {
- return
- }
- if p.ctl != nil {
- p.ctl.Close()
- }
- if p.logs != nil {
- p.logs.Close()
- }
+ p.closeOnce.Do(func() {
+ if p.ctl != nil {
+ p.ctl.Close()
+ }
+ if p.logs != nil {
+ p.logs.Close()
+ }
+ })
}
// observe is the goroutine of design §6.5. It proves termination from two
diff --git a/internal/activator/unitproc_close_test.go b/internal/activator/unitproc_close_test.go
new file mode 100644
index 000000000..c2facf1c9
--- /dev/null
+++ b/internal/activator/unitproc_close_test.go
@@ -0,0 +1,106 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "net"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type gatedUnitLogListener struct {
+ net.Listener
+ entered chan struct{}
+ release chan struct{}
+ calls atomic.Int32
+}
+
+func (l *gatedUnitLogListener) Close() error {
+ if l.calls.Add(1) == 1 {
+ close(l.entered)
+ }
+ <-l.release
+ return l.Listener.Close()
+}
+
+func TestUnitProcStopJoinsListenerClosure(t *testing.T) {
+ for _, watcher := range []bool{true, false} {
+ t.Run(map[bool]string{true: "timeout-watcher", false: "concurrent-stop"}[watcher], func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "log.sock")
+ ln, err := net.Listen("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gate := &gatedUnitLogListener{Listener: ln, entered: make(chan struct{}), release: make(chan struct{})}
+ var release sync.Once
+ unblock := func() { release.Do(func() { close(gate.release) }) }
+ t.Cleanup(func() { unblock(); ln.Close() })
+ p := &unitProc{b: &ExecBackend{Control: &fakeControl{}}, done: make(chan struct{}), logs: &logListener{ln: gate}}
+ if watcher {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) {
+ t.Fatalf("initial Stop=%v", err)
+ }
+ if gate.calls.Load() != 0 {
+ t.Fatal("listeners closed before termination proof")
+ }
+ }
+ p.exited.Store(true)
+ close(p.done)
+ first := make(chan error, 1)
+ if !watcher {
+ go func() { first <- p.Stop(context.Background()) }()
+ }
+ select {
+ case <-gate.entered:
+ case <-time.After(time.Second):
+ t.Fatal("closure did not start")
+ }
+ c, err := net.Dial("unix", path)
+ if err != nil {
+ t.Fatalf("gated listener unexpectedly closed: %v", err)
+ }
+ c.Close()
+ second := make(chan error, 1)
+ go func() { second <- p.Stop(context.Background()) }()
+ select {
+ case err := <-second:
+ t.Fatalf("Stop returned before listener closure completed: %v", err)
+ case <-time.After(100 * time.Millisecond):
+ }
+ unblock()
+ select {
+ case err := <-second:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Stop did not join completed closure")
+ }
+ if !watcher {
+ select {
+ case err := <-first:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("first Stop did not finish")
+ }
+ }
+ if c, err := net.Dial("unix", path); err == nil {
+ c.Close()
+ t.Fatal("listener accepts after successful Stop")
+ }
+ if err := p.Stop(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if gate.calls.Load() != 1 {
+ t.Fatalf("underlying closes=%d", gate.calls.Load())
+ }
+ })
+ }
+}
diff --git a/internal/host/movescheckpoint_test.go b/internal/host/movescheckpoint_test.go
index 4143f6821..f421b4610 100644
--- a/internal/host/movescheckpoint_test.go
+++ b/internal/host/movescheckpoint_test.go
@@ -13,19 +13,28 @@ import (
"time"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/usage"
)
// checkpointS3 is enough of S3 for a leased litestream sync: an empty
-// listing, absent objects, and writes that succeed. It records each write
-// by path.
+// listing, absent objects, and writes that succeed. It records every
+// physical request so the ledger can be reconciled with the store.
type checkpointS3 struct {
- mu sync.Mutex
- puts []string
+ mu sync.Mutex
+ requests []checkpointRequest
+}
+
+type checkpointRequest struct {
+ Method string
+ Path string
}
func (f *checkpointS3) serve(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ f.requests = append(f.requests, checkpointRequest{Method: r.Method, Path: r.URL.Path})
+ f.mu.Unlock()
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/xml")
switch {
@@ -35,9 +44,6 @@ func (f *checkpointS3) serve(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message>no such key</Message></Error>`)
case r.Method == http.MethodPut:
- f.mu.Lock()
- f.puts = append(f.puts, r.URL.Path)
- f.mu.Unlock()
w.Header().Set("ETag", `"etag"`)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
@@ -50,14 +56,125 @@ func (f *checkpointS3) replicaPuts() int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
- for _, p := range f.puts {
- if !strings.HasSuffix(p, "/lock.json") {
+ for _, request := range f.requests {
+ if request.Method == http.MethodPut && !strings.HasSuffix(request.Path, "/lock.json") {
n++
}
}
return n
}
+func (f *checkpointS3) requestCounts() (leaseRequests, replicaRequests int, requests []checkpointRequest) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ requests = append(requests, f.requests...)
+ for _, request := range requests {
+ if strings.HasSuffix(request.Path, "/lock.json") {
+ leaseRequests++
+ } else {
+ replicaRequests++
+ }
+ }
+ return leaseRequests, replicaRequests, requests
+}
+
+type checkpointLedgerEvidence struct {
+ leaseAttempts int64
+ checkpointAttempts int64
+ checkpointUnknown int64
+ checkpointAnswered int64
+}
+
+func checkpointEvidence(t *testing.T, content usage.DurabilityContent, rec provision.Record) checkpointLedgerEvidence {
+ t.Helper()
+ var evidence checkpointLedgerEvidence
+ for _, rows := range content.Hours {
+ for _, row := range rows {
+ if row.Account != rec.Account || row.App != rec.App || row.Host != rec.Host {
+ t.Fatalf("a checkpoint request lost its owner: %+v", row)
+ }
+ switch row.Purpose {
+ case string(usage.PurposeLease):
+ evidence.leaseAttempts += row.Attempts
+ case string(usage.PurposeCheckpoint):
+ evidence.checkpointAttempts += row.Attempts
+ evidence.checkpointUnknown += row.Unknown
+ for _, answered := range row.Answered {
+ evidence.checkpointAnswered += answered
+ }
+ default:
+ t.Fatalf("a checkpoint request used purpose %q: %+v", row.Purpose, row)
+ }
+ }
+ }
+ return evidence
+}
+
+func checkpointDeficit(requests int, attempts int64) int64 {
+ deficit := int64(requests) - attempts
+ if deficit < 0 {
+ return 0
+ }
+ return deficit
+}
+
+func waitCheckpointEvidence(t *testing.T, ctx context.Context, f *checkpointS3, p *durabilityPublisher, rec provision.Record, requireUnknown bool) (usage.DurabilityContent, checkpointLedgerEvidence) {
+ t.Helper()
+ leaseRequests, replicaRequests, requests := f.requestCounts()
+ for {
+ content, err := p.ledger.Snapshot(time.Now().UTC().Format("2006-01-02"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ evidence := checkpointEvidence(t, content, rec)
+ failures := int64(0)
+ var failureDiagnostic any
+ if content.ActualRecordingFailures != nil {
+ failures = *content.ActualRecordingFailures
+ failureDiagnostic = failures
+ }
+ deficit := checkpointDeficit(leaseRequests, evidence.leaseAttempts) + checkpointDeficit(replicaRequests, evidence.checkpointAttempts)
+ complete := evidence.leaseAttempts > 0 && evidence.checkpointAttempts > 0 && failures >= deficit && content.Held == 0 && content.Barred == 0
+ if requireUnknown {
+ complete = complete && evidence.checkpointUnknown == evidence.checkpointAttempts && evidence.checkpointAnswered == 0
+ }
+ if complete {
+ return content, evidence
+ }
+ select {
+ case <-ctx.Done():
+ t.Fatalf("checkpoint evidence did not settle: %v; actual_recording_failures=%v content=%+v evidence=%+v lease_requests=%d replica_requests=%d requests=%+v", ctx.Err(), failureDiagnostic, content, evidence, leaseRequests, replicaRequests, requests)
+ case <-time.After(20 * time.Millisecond):
+ }
+ }
+}
+
+type blockingCheckpointRecorder struct {
+ lease.RequestRecorder
+ release chan struct{}
+ entered chan struct{}
+ done chan struct{}
+ once sync.Once
+}
+
+func newBlockingCheckpointRecorder(recorder lease.RequestRecorder) *blockingCheckpointRecorder {
+ return &blockingCheckpointRecorder{
+ RequestRecorder: recorder,
+ release: make(chan struct{}),
+ entered: make(chan struct{}, 4),
+ done: make(chan struct{}, 4),
+ }
+}
+
+func (r *blockingCheckpointRecorder) Start(operation string, at time.Time) (uint64, error) {
+ r.entered <- struct{}{}
+ defer func() { r.done <- struct{}{} }()
+ <-r.release
+ return r.RequestRecorder.Start(operation, at)
+}
+
+func (r *blockingCheckpointRecorder) unblock() { r.once.Do(func() { close(r.release) }) }
+
func checkpointFixture(t *testing.T) (*checkpointS3, *durabilityPublisher, string, provision.Record) {
t.Helper()
f := &checkpointS3{}
@@ -124,34 +241,81 @@ func TestMovesPassCheckpointIsMeteredToTheRouteOwner(t *testing.T) {
if p.bindingFailed.Load() {
t.Fatal("a metered checkpoint filed an escape")
}
- deadline := time.Now().Add(5 * time.Second)
- for {
- c, err := p.ledger.Snapshot(time.Now().UTC().Format("2006-01-02"))
+ waitCheckpointEvidence(t, ctx, f, p, rec, false)
+}
+
+func TestMovesPassCheckpointRecordsSaturationAsBoundedIncomplete(t *testing.T) {
+ f, p, dbPath, rec := checkpointFixture(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ attr := usage.ReplicationAttribution{Account: rec.Account, App: rec.App, Host: rec.Host, Purpose: usage.PurposeCheckpoint}
+ client, err := p.ledger.Bind(attr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ blocked := newBlockingCheckpointRecorder(&durabilityRecorder{client})
+ t.Cleanup(blocked.unblock)
+ ready := make(chan struct{})
+ close(ready)
+ p.mu.Lock()
+ p.bindings[attr] = &durabilityBinding{ready: ready, recorder: blocked}
+ p.mu.Unlock()
+
+ result := make(chan error, 1)
+ go func() { result <- armedMovesWorker.Load().cfg.CheckpointFor(ctx, rec, dbPath) }()
+ for range 4 {
+ select {
+ case <-blocked.entered:
+ case <-ctx.Done():
+ t.Fatalf("checkpoint did not fill all recorder slots: %v", ctx.Err())
+ }
+ }
+ select {
+ case err := <-result:
if err != nil {
t.Fatal(err)
}
- writes := map[string]int64{}
- for _, rows := range c.Hours {
- for _, row := range rows {
- if row.Account != rec.Account || row.App != rec.App || row.Host != rec.Host {
- t.Fatalf("a checkpoint request lost its owner: %+v", row)
- }
- if row.Purpose != string(usage.PurposeCheckpoint) && row.Purpose != string(usage.PurposeLease) {
- t.Fatalf("a checkpoint request under purpose %q: %+v", row.Purpose, row)
- }
- if row.Operation == "PutObject" {
- writes[row.Purpose] += row.Attempts
- }
- }
- }
- if writes[string(usage.PurposeCheckpoint)] >= int64(f.replicaPuts()) && writes[string(usage.PurposeLease)] > 0 {
- return
- }
- if time.Now().After(deadline) {
- t.Fatalf("ledger writes %+v; the store saw %d replica writes", writes, f.replicaPuts())
+ case <-ctx.Done():
+ t.Fatalf("checkpoint did not finish while recorder starts were blocked: %v", ctx.Err())
+ }
+ if f.replicaPuts() == 0 {
+ t.Fatal("fixture: the checkpoint wrote nothing to the replica")
+ }
+ if p.bindingFailed.Load() {
+ t.Fatal("a metered checkpoint filed an escape")
+ }
+ content, err := p.ledger.Snapshot(time.Now().UTC().Format("2006-01-02"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ evidence := checkpointEvidence(t, content, rec)
+ _, replicaRequests, requests := f.requestCounts()
+ failures := int64(0)
+ var failureDiagnostic any
+ if content.ActualRecordingFailures != nil {
+ failures = *content.ActualRecordingFailures
+ failureDiagnostic = failures
+ }
+ // The live meter holds which pin these attempts are transient latch state,
+ // not the durable uncertainty rows reported by content.Held. Do not require
+ // the complete-phase zero-held invariant until the workers have drained.
+ if evidence.checkpointAttempts != 0 || failures < int64(replicaRequests) || content.Barred != 0 {
+ t.Fatalf("blocked starts did not expose their pending and unrecorded state: actual_recording_failures=%v content=%+v evidence=%+v replica_requests=%d requests=%+v", failureDiagnostic, content, evidence, replicaRequests, requests)
+ }
+
+ blocked.unblock()
+ for range 4 {
+ select {
+ case <-blocked.done:
+ case <-ctx.Done():
+ t.Fatalf("blocked recorder start did not finish: %v", ctx.Err())
}
- time.Sleep(20 * time.Millisecond)
}
+ if err := p.ledger.WaitLate(ctx); err != nil {
+ t.Fatal(err)
+ }
+ waitCheckpointEvidence(t, ctx, f, p, rec, true)
}
// With no recorder to bind (the publisher has stopped), the checkpoint