bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
//go:build !windows
// The three checks the async lane needs that the wire cannot reach: the job
// table's own bounds, and the two numbers this lane chose. Split out of
// modelswap_async_test.go so THAT file compiles against the synchronous handler
// and its failures are behavioural rather than a build error.
package main
import (
"fmt"
"testing"
"time"
)
// ── a test must outlive the job it started ─────────────────────────────────
//
// runModelPull is spawned off the management frame and keeps writing after the
// start has answered: progress into the job table, then the approval and the
// ownership ledger into the pairing's ROOT (stepupMarkUsed, recordPlaced, both
// through atomicWrite's temp-file-and-rename). A test that returns while its
// job is still running has that goroutine writing into a t.TempDir() that
// t.Cleanup is deleting at the same moment — and RemoveAll then fails with
// "directory not empty", reported against the test at its function line, at
// 0.00s. That was the whole intermittent failure of this file's tests: not an
// order effect, not shared table state, a goroutine outliving the test.
//
// modelPullJobIDs / awaitModelPullsSince are the two halves of the fix: note
// which jobs already exist, and at cleanup wait on `done` for every job this
// test added. Scoped to this test's own jobs so a record some other file left
// running (or built by hand, with no done channel) cannot hang a cleanup.
func modelPullJobIDs() map[string]bool {
modelPullJobsMu.Lock()
defer modelPullJobsMu.Unlock()
out := map[string]bool{}
for id := range modelPullJobs {
out[id] = true
}
return out
}
// awaitModelPullsSince blocks until every job not in `before` has finished.
// The deadline is a GUARD against a job that can never finish (a daemon that
// was closed under it would take modelPullTimeout to notice), not a wait: a
// finished job returns at once.
func awaitModelPullsSince(t *testing.T, before map[string]bool) {
t.Helper()
modelPullJobsMu.Lock()
var pending []<-chan struct{}
for id, j := range modelPullJobs {
if !before[id] && j.done != nil {
pending = append(pending, j.done)
}
}
modelPullJobsMu.Unlock()
deadline := time.After(30 * time.Second)
for _, d := range pending {
select {
case <-d:
case <-deadline:
t.Errorf("a model pull this test started was still running 30s after the test " +
"ended — its goroutine would be writing into a directory the test is deleting")
return
}
}
}
// A finished record is kept so a late poll can still be answered, but a
// long-lived daemon must not accumulate them for ever. Eviction takes the
// OLDEST FINISHED job and never a running one — forgetting a live download is
// how a host ends up starting a second one on the same approval.
func TestFinishedJobsAreEvictedOldestFirstAndRunningOnesAreNot(t *testing.T) {
resetModelPullJobs()
f := &fakeDaemon{}
release := newSlowPullDaemon(t, f)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
// One job that stays RUNNING for the whole test. Its root comes from
// newPullRoot, whose cleanup releases the daemon and then waits for this
// job before the directory is deleted — the `defer close(release)` that
// used to sit here let the goroutine finish INTO the RemoveAll.
liveRoot := newPullRoot(t, release)
liveApproval := proposeAndApprove(t, liveRoot, "slowmodel", "embed", 1<<30, "")
live := startPull(t, tAnchor, liveRoot, "slowmodel", "embed", 1<<30, liveApproval)
liveID, _ := live["id"].(string)
// The download goroutine reads the daemon's address only when it sends
// its request, so the switch to the instant daemon below must wait until
// that request has reached the slow one: its 400-of-1000 progress line is
// the proof. Without this, a goroutine scheduled late sends to the instant
// daemon, the "running" job finishes, and it is evicted like any other.
deadline := time.Now().Add(10 * time.Second)
for {
out, _, code, _ := driveModelOp(t, tAnchor, "model:pull", []string{statusArg(liveID)}, liveRoot)
if code != 0 {
t.Fatalf("status exit %d", code)
}
st := mustJSON(t, out)
if st["state"] == "running" && st["got_bytes"] == float64(400) && st["want_bytes"] == float64(1000) {
break
}
if time.Now().After(deadline) {
t.Fatalf("the live download never reached the slow daemon: %v", st)
}
time.Sleep(10 * time.Millisecond)
}
// Then fill the table past its cap with finished ones. A separate daemon, so
// these complete instantly. Bare TempDirs here, deliberately: each is polled
// to completion below, and a newPullRoot cleanup on them would run BEFORE
// liveRoot's (last registered, first run) and wait on the live job while
// nothing had yet released it.
newFakeDaemon(t, &fakeDaemon{})
var firstID string
for i := 0; i <= modelPullJobsMax; i++ {
root := t.TempDir()
enrollFactor(t, root)
name := fmt.Sprintf("m%d", i)
ap := proposeAndApprove(t, root, name, "embed", 1<<20, "")
j := startPull(t, tAnchor, root, name, "embed", 1<<20, ap)
id, _ := j["id"].(string)
if i == 0 {
firstID = id
}
pollUntilFinished(t, tAnchor, root, id)
}
// The running job is still there and still answerable.
out, _, code, _ := driveModelOp(t, tAnchor, "model:pull", []string{statusArg(liveID)}, liveRoot)
if code != 0 {
t.Fatalf("status exit %d", code)
}
if got := mustJSON(t, out); got["state"] != "running" {
t.Fatalf("a RUNNING job must never be evicted: %v", got)
}
// The oldest FINISHED one has gone.
out, _, code, _ = driveModelOp(t, tAnchor, "model:pull", []string{statusArg(firstID)}, liveRoot)
if code != 0 {
t.Fatalf("status exit %d", code)
}
if got := mustJSON(t, out); got["state"] != "unknown" {
t.Fatalf("the table grew past its cap of %d without evicting the oldest "+
"finished record: %v", modelPullJobsMax, got)
}
}
// A record past its retention is forgotten even when the table is small.
func TestAFinishedJobIsForgottenPastItsRetention(t *testing.T) {
resetModelPullJobs()
modelPullJobsMu.Lock()
modelPullJobs["mp-old"] = &modelPullJob{ID: "mp-old", State: "done",
finishedAt: time.Now().Add(-modelPullJobRetention - time.Hour)}
modelPullJobs["mp-new"] = &modelPullJob{ID: "mp-new", State: "done",
finishedAt: time.Now()}
modelPullJobs["mp-live"] = &modelPullJob{ID: "mp-live", State: "running"}
modelPullPrune()
_, old := modelPullJobs["mp-old"]
_, fresh := modelPullJobs["mp-new"]
_, live := modelPullJobs["mp-live"]
modelPullJobsMu.Unlock()
if old {
t.Fatal("a record past its retention must be forgotten")
}
if !fresh {
t.Fatal("a record inside its retention must still answer a late poll")
}
if !live {
t.Fatal("a RUNNING job has no finished time and must never be pruned")
}
resetModelPullJobs()
}
// The retention window is the approval's own, not a number invented for this
// lane — the same argument modelswap.go's TTL block already makes for not
// introducing a clock of its own.
func TestJobRetentionIsTheApprovalsOwnWindow(t *testing.T) {
if modelPullJobRetention != stepupApprovalTTL {
t.Fatalf("a job record outliving the approval that authorized it can tell "+
"nobody anything actionable; want %v, got %v", stepupApprovalTTL, modelPullJobRetention)
}
}
// The cadence the box asks for is the one the host already runs against
// backup_fetch: 2 s, x1.5, capped at 30 s. One lane, one cadence.
func TestThePollCadenceIsBackupFetchs(t *testing.T) {
if got := modelPullPollAfter(0); got != 2.0 {
t.Fatalf("first poll: want 2s, got %v", got)
}
if got := modelPullPollAfter(1); got != 3.0 {
t.Fatalf("second poll: want 3s (x1.5), got %v", got)
}
if got := modelPullPollAfter(40); got != 30.0 {
t.Fatalf("the cadence must cap at 30s, got %v", got)
}
}
// Both sub-ops ride ONE op id, in ONE caps family. A separate op id would be
// ungranted in every signed grant already written, so a user whose grant says
// model:pull could start a download and not be allowed to watch it finish.
func TestStatusRidesTheModelPullOpAndTheModelsFamily(t *testing.T) {
if fam := opFamily("model:pull"); fam != modelGateOpFamily {
t.Fatalf("model:pull must be in %q, got %q", modelGateOpFamily, fam)
}
if _, ok := handlerOps["model:pull"]; !ok {
t.Fatal("model:pull must be in the handler table")
}
for _, gone := range []string{"model:status", "model:pull_status", "model:poll"} {
if _, ok := handlerOps[gone]; ok {
t.Fatalf("%q exists as its own op — every grant already written names "+
"model:pull and would not cover it", gone)
}
}
}