carlos / platform Public

host: the lifetimes writer publishes (incarnation, serving_seq) and target_host

edgeHost gains incarnation, serving_seq and target_host. edgesFileWriteClaims
gains lifetimes (not the region-HA claims argument): on, a served row
publishes its registry pair, and an alias and a served catch-all their
primary's; off, no entry carries a pair. target_host names the primary of
every alias and served catch-all either way; a route and a certificate-only
entry publish none. runAgent derives lifetimes from boxJudge != nil, the
judge's own reading of the host-claim switch, and hands it to the loop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9d054e4ec19bff38530f7e35b951b4c846fd1830 parent 3571b26
5 files changed, +265 −19
  • internal/host/agent.go +5 −1
  • internal/host/boxaddress_test.go +7 −7
  • internal/host/edgesclaims_test.go +1 −1
  • internal/host/edgesfile.go +47 −10
  • internal/host/edgesfile_test.go +205 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index c09c85b6d..e9d7086c3 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -240,6 +240,10 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// The box's lifetime judge, built once and shared by every consumer of
// instance records below (newBoxJudge): nil with host claims on.
boxJudge := newBoxJudge(claimEntryEnv(claimCtx))
+ // The lifetimes switch the edges file publishes the pair under is the
+ // judge's own reading of the host-claim switch (!hostwrite.Enabled),
+ // taken from the judge so the two can never disagree.
+ lifetimes := boxJudge != nil
dd := dataDir()
regPath := peekRegistryPath(args, dd)
// A second handle on the same registry file the edge opens — the registry
@@ -820,7 +824,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
}
go retentionLoop(reg, collectBuildStates, boxJudge)
go registrySnapshotLoop(reg)
- go edgesFileLoop(reg)
+ go edgesFileLoop(reg, lifetimes)
// Region HA enrollment evidence (spec 1 §5.1): once, at start, and only
// with CARLOS_REGION_HA on. See boxrecord.go.
go boxRecordLoop()
diff --git a/internal/host/boxaddress_test.go b/internal/host/boxaddress_test.go
index ec234441f..7ead461cc 100644
--- a/internal/host/boxaddress_test.go
+++ b/internal/host/boxaddress_test.go
@@ -160,13 +160,13 @@ func TestEdgesFilePublishesOnlyWhatTheSourceReports(t *testing.T) {
var last []byte
ident := edgesFile{Box: "annie", BoxID: "annie"}
// The loop's own reading (the eip argument) is not what is published.
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "198.51.100.1", &last, false, claims); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "198.51.100.1", &last, false, claims, false); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "10.0.1.5" || f.PrivateCIDR != "10.0.0.0/16" {
t.Fatalf("first publication: %+v", f)
}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "" || f.PrivateIP != "10.0.1.5" {
@@ -184,10 +184,10 @@ func TestEdgesFileWithRegionHAOffKeepsTheEIPHold(t *testing.T) {
seedRoute(t, reg, "app.example.com")
var last []byte
ident := edgesFile{Box: "annie"}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "203.0.113.9", &last, false, nil); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "203.0.113.9", &last, false, nil, false); err != nil {
t.Fatal(err)
}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, nil); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, nil, false); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "" || f.PrivateCIDR != "" {
@@ -277,7 +277,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
claims.addrs = src
var last []byte
ident := edgesFile{Box: "annie", BoxID: "annie"}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false); err != nil {
t.Fatal(err)
}
before, _, err := s.Get(context.Background(), edgesFileKey("annie"))
@@ -286,7 +286,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
}
lastBefore := string(last)
// A forced timed pass, which would rewrite an unchanged body.
- err = edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims)
+ err = edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false)
if !errors.Is(err, errBoxAddressesUnknown) {
t.Fatalf("unknown read: err %v, want errBoxAddressesUnknown", err)
}
@@ -301,7 +301,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
t.Fatalf("published file lost its addresses: %+v", f)
}
// The retry reads again and publishes what it now reports.
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.PrivateIP != "10.0.1.6" {
diff --git a/internal/host/edgesclaims_test.go b/internal/host/edgesclaims_test.go
index a3f40a519..7c41f0490 100644
--- a/internal/host/edgesclaims_test.go
+++ b/internal/host/edgesclaims_test.go
@@ -57,7 +57,7 @@ func publishWithClaims(t *testing.T, reg *registry.Registry, claims *edgesClaims
}
st := release.NewDirStore(t.TempDir())
var last []byte
- if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims, false); err != nil {
t.Fatal(err)
}
body, _, err := st.Get(context.Background(), edgesFileKey("annie"))
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index 271281537..64d277b19 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -88,6 +88,19 @@ type edgeHost struct {
// that predates it. Two files claiming one host are settled by the
// higher stamp on the edge (homemap.go foldHomes).
Stamp int64 `json:"stamp,omitempty"`
+ // Incarnation and ServingSeq are the serving row's lifetime pair
+ // (registry.Route.Incarnation and ServingSeq; self-serve moves plan 1,
+ // design §6.4), published only by a box with lifetimes on (host claims
+ // off): an alias and a served catch-all carry their primary's. The
+ // edge orders two unstamped entries by the pair, an entry without it
+ // counting as (0, 0) (homemap.go dialBeats). Additive (omitempty).
+ Incarnation uint64 `json:"incarnation,omitempty"`
+ ServingSeq uint64 `json:"serving_seq,omitempty"`
+ // TargetHost is the primary an alias or a served catch-all is served
+ // by, published whatever the lifetimes switch: the edge compares two
+ // aliases' pairs only when they name one target. A route and a
+ // certificate-only entry publish none. Additive (omitempty).
+ TargetHost string `json:"target_host,omitempty"`
}
// hostOwner folds the owners of every route served under one hostname. Any
@@ -414,7 +427,7 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry,
// period stale. On every other pass a headroom change alone is not a
// reason to write.
func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool) error {
- return edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, nil)
+ return edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, nil, false)
}
// errBoxAddressesUnknown fails an edges file pass whose address source could
@@ -422,9 +435,14 @@ func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry
var errBoxAddressesUnknown = errors.New("edges file: the box's addresses could not be read")
// edgesFileWriteClaims is edgesFileWrite with the claim reader region HA
-// adds (edgesclaims.go). claims is nil unless CARLOS_REGION_HA is on, and
-// nil publishes exactly what edgesFileWrite always has.
-func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool, claims *edgesClaims) error {
+// adds (edgesclaims.go) and the lifetimes switch. claims is nil unless
+// CARLOS_REGION_HA is on, and nil publishes exactly what edgesFileWrite
+// always has. lifetimes is the box's own lifetimes switch (host claims off,
+// the same reading that builds the box judge, newBoxJudge): on, every
+// served row publishes its lifetime pair and every alias and served
+// catch-all its primary's; off, no entry carries a pair. target_host is
+// published either way.
+func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool, claims *edgesClaims, lifetimes bool) error {
box := ident.Box
routes, err := reg.List()
if err != nil {
@@ -477,7 +495,13 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
// host appears, so every reader of the file can tell the account's
// homes from its checkout replicas. See the replica loop below for
// the account apex that rides along.
- hosts[r.Host] = edgeHost{Role: "home", Platform: r.Platform || r.Provisioned, Replica: r.Replica}
+ h := edgeHost{Role: "home", Platform: r.Platform || r.Provisioned, Replica: r.Replica}
+ if lifetimes {
+ // The row's lifetime pair (self-serve moves plan 1, design
+ // §6.4): the edge orders two unstamped entries by it.
+ h.Incarnation, h.ServingSeq = r.Incarnation, r.ServingSeq
+ }
+ hosts[r.Host] = h
}
// Region HA (spec 1 §5.4): a relocatable host is home only on the box
// its claim names, stamped with the installation. Before owners and
@@ -550,7 +574,12 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
// the primary's stamp (region HA HA4): an unstamped alias would tie
// with a dead predecessor's and the edge would keep the first box
// by name.
- hosts[a.Alias] = edgeHost{Role: "home", Alias: true, Account: acct, App: app, Stamp: hosts[a.Host].Stamp}
+ // So it carries the primary's lifetime pair too (none with lifetimes
+ // off), and names the primary as its target: the edge compares two
+ // aliases' pairs only for one target.
+ p := hosts[a.Host]
+ hosts[a.Alias] = edgeHost{Role: "home", Alias: true, Account: acct, App: app, Stamp: p.Stamp,
+ Incarnation: p.Incarnation, ServingSeq: p.ServingSeq, TargetHost: a.Host}
}
// Replica placements (#162): a row minted from a role=replica record —
@@ -624,11 +653,15 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
// A catch-all we serve IS a materialized claim, which is what the
// mark means, so say so.
role, alias := "wildcard", false
- var acct, app string
+ var acct, app, target string
var stamp int64
+ var inc, seq uint64
if primary, served := catchallServed[wd.Host]; served {
role, alias = "home", true
stamp = hosts[primary].Stamp // the dial half is the primary's installation
+ // and the primary's lifetime: its pair, and the primary as the
+ // target. The cert half dials nothing and carries neither.
+ inc, seq, target = hosts[primary].Incarnation, hosts[primary].ServingSeq, primary
// The owner is the wildcard claim's, and only when the primary
// the catch-all is served by (with its prefixes) agrees with it:
// every route behind the name has to have one owner.
@@ -636,7 +669,8 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
o.add(wd.Account, wd.App)
acct, app = o.owner()
}
- hosts[key] = edgeHost{Role: role, Wildcard: true, Alias: alias, Account: acct, App: app, Stamp: stamp}
+ hosts[key] = edgeHost{Role: role, Wildcard: true, Alias: alias, Account: acct, App: app, Stamp: stamp,
+ Incarnation: inc, ServingSeq: seq, TargetHost: target}
}
var private BoxAddresses
@@ -806,7 +840,10 @@ func edgesFileSecretsFP(getenv func(string) string) string {
// Platform-box job, retentionLoop's rules: skipped on fleet boxes, disabled
// with a log line when there is no deployment bucket. Single-box deployments:
// the file is published and nothing reads it yet.
-func edgesFileLoop(reg *registry.Registry) {
+//
+// lifetimes is the box's lifetimes switch, runAgent's boxJudge != nil: the
+// writer publishes the lifetime pair exactly when the box judges lifetimes.
+func edgesFileLoop(reg *registry.Registry, lifetimes bool) {
ctx := context.Background()
if shouldSkipPublisherElection(os.Getenv) {
log.Printf("agent: edges file loop is a platform-box job — skipped on a fleet box")
@@ -898,7 +935,7 @@ func edgesFileLoop(reg *registry.Registry) {
ident.Headroom = headroomFor(headroomCapabilities(ident.Capabilities), dataDir, reg)
ident = withMovesFields(ident, dataDir, optDir)
ident = withSeedsFields(ident, dataDir, optDir)
- err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims)
+ err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims, lifetimes)
if err != nil {
// Throttled: a bucket that refuses every write (an IAM Deny, an
// outage) is retried on a backoff, and each retry must not be a
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
index 1bd876ff3..bb2154778 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -6,6 +6,10 @@ import (
"crypto/ecdh"
"encoding/base64"
"encoding/json"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
"log"
"os"
"path/filepath"
@@ -17,6 +21,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -1619,3 +1624,203 @@ func TestEdgesFileQuietBoxStaysFreshOnTimedPassesOnlyWithRegionHA(t *testing.T)
t.Fatalf("region HA off: puts = %d untimed, %d after a timed pass; want 1, 1", untimed, timed)
}
}
+
+// ---- the lifetime pair (self-serve moves plan 1, Task 8) -------------------
+
+// lifetimeRow upserts a ready relocatable instance at the lifetime pair
+// (inc, seq). A fresh insert writes the pair (registry.Upsert).
+func lifetimeRow(t *testing.T, reg *registry.Registry, host string, inc, seq uint64) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Socket: "/run/carlos/" + host + ".sock",
+ Provisioned: true, Account: "bab", App: "shop", Status: "ready", Incarnation: inc, ServingSeq: seq}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// publishLifetimes runs the real writer once as box and returns the raw
+// body and its hosts. claims nil is region HA off; lifetimes is the
+// box-side lifetimes switch.
+func publishLifetimes(t *testing.T, reg *registry.Registry, box string, claims *edgesClaims, lifetimes bool) ([]byte, map[string]edgeHost) {
+ t.Helper()
+ if claims != nil {
+ claims.addrs = staticAddresses{Public: "1.2.3.4"}
+ }
+ st := release.NewDirStore(t.TempDir())
+ var last []byte
+ if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: box, BoxID: box}, "1.2.3.4", &last, false, claims, lifetimes); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := st.Get(context.Background(), edgesFileKey(box))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(body, &f); err != nil {
+ t.Fatal(err)
+ }
+ return body, f.Hosts
+}
+
+// pairRegistry is a box serving shop.example.com at (2, 3), its pointing
+// alias, a served catch-all whose parent (shop.example.net) is not its
+// primary, and a certificate-only wildcard parent.
+func pairRegistry(t *testing.T) *registry.Registry {
+ t.Helper()
+ withheldAliasSaid = sync.Map{}
+ reg := edgesTestRegistry(t)
+ lifetimeRow(t, reg, "shop.example.com", 2, 3)
+ for _, a := range []registry.RouteAlias{
+ {Alias: "www.shop.example.net", Host: "shop.example.com", Account: "bab", App: "shop", DNSStatus: "pointing"},
+ {Alias: "*.shop.example.net", Host: "shop.example.com", Account: "bab", App: "shop"},
+ } {
+ if err := reg.UpsertAlias(a); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, w := range []registry.WildcardDomain{
+ {Host: "shop.example.net", Account: "bab", App: "shop"},
+ {Host: "certonly.example.net", Account: "bab", App: "shop"},
+ } {
+ if err := reg.UpsertWildcardDomain(w); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return reg
+}
+
+func wantEntry(t *testing.T, why string, hosts map[string]edgeHost, key string, inc, seq uint64, target string) edgeHost {
+ t.Helper()
+ h, ok := hosts[key]
+ if !ok {
+ t.Fatalf("%s: %s not published: %+v", why, key, hosts)
+ }
+ if h.Incarnation != inc || h.ServingSeq != seq || h.TargetHost != target {
+ t.Errorf("%s: %s = (%d, %d) target %q, want (%d, %d) target %q", why, key, h.Incarnation, h.ServingSeq, h.TargetHost, inc, seq, target)
+ }
+ return h
+}
+
+func TestEdgesFilePublishesThePairAndTargetHost(t *testing.T) {
+ reg := pairRegistry(t)
+ body, hosts := publishLifetimes(t, reg, "annie", nil, true)
+ wantEntry(t, "the row", hosts, "shop.example.com", 2, 3, "")
+ if h := wantEntry(t, "the alias", hosts, "www.shop.example.net", 2, 3, "shop.example.com"); !h.Alias {
+ t.Errorf("alias entry = %+v, want marked alias", h)
+ }
+ if h := wantEntry(t, "the served catch-all", hosts, "*.shop.example.net", 2, 3, "shop.example.com"); h.Role != "home" || !h.Wildcard || !h.Alias {
+ t.Errorf("catch-all entry = %+v, want home, wildcard, alias", h)
+ }
+ if h := wantEntry(t, "the cert-only parent", hosts, "*.certonly.example.net", 0, 0, ""); h.Role != "wildcard" {
+ t.Errorf("cert-only entry = %+v, want role wildcard", h)
+ }
+ for _, s := range []string{`"incarnation":2,"serving_seq":3}`, `"incarnation":2,"serving_seq":3,"target_host":"shop.example.com"}`} {
+ if !bytes.Contains(body, []byte(s)) {
+ t.Errorf("raw body lacks %s:\n%s", s, body)
+ }
+ }
+}
+
+func TestLifetimesOffWriterPublishesNoPair(t *testing.T) {
+ reg := pairRegistry(t)
+ for _, ha := range []bool{false, true} {
+ var claims *edgesClaims
+ var stamp int64
+ if ha {
+ book := &claimsBook{claims: map[string]*hostclaim.Claim{"shop.example.com": serving("annie", 41)}}
+ claims, stamp = newEdgesClaims("annie", book.read), 41
+ }
+ why := fmt.Sprintf("region HA %v", ha)
+ body, hosts := publishLifetimes(t, reg, "annie", claims, false)
+ for _, key := range []string{"shop.example.com", "www.shop.example.net", "*.shop.example.net"} {
+ if h := hosts[key]; h.Stamp != stamp {
+ t.Errorf("%s: %s stamp %d, want %d", why, key, h.Stamp, stamp)
+ }
+ }
+ wantEntry(t, why, hosts, "shop.example.com", 0, 0, "")
+ wantEntry(t, why, hosts, "www.shop.example.net", 0, 0, "shop.example.com")
+ wantEntry(t, why, hosts, "*.shop.example.net", 0, 0, "shop.example.com")
+ wantEntry(t, why, hosts, "*.certonly.example.net", 0, 0, "")
+ for _, s := range []string{"incarnation", "serving_seq"} {
+ if bytes.Contains(body, []byte(s)) {
+ t.Errorf("%s: a lifetimes-off body carries %s:\n%s", why, s, body)
+ }
+ }
+ if !bytes.Contains(body, []byte(`"target_host":"shop.example.com"`)) {
+ t.Errorf("%s: a lifetimes-off body lacks target_host:\n%s", why, body)
+ }
+ }
+}
+
+// The box-side lifetimes switch is the one the box judge is built from
+// (controller ruling 1): runAgent derives it from boxJudge, once, and hands
+// it to the edges file loop, which hands it to the writer.
+func TestEdgesFileLoopTakesTheJudgesLifetimesSwitch(t *testing.T) {
+ assigns, derived, handed := 0, false, false
+ inspectFunc(t, "agent.go", "runAgent", func(n ast.Node) {
+ switch n := n.(type) {
+ case *ast.AssignStmt:
+ for i, l := range n.Lhs {
+ if id, ok := l.(*ast.Ident); !ok || id.Name != "lifetimes" {
+ continue
+ }
+ assigns++
+ if len(n.Rhs) != len(n.Lhs) {
+ continue
+ }
+ be, ok := n.Rhs[i].(*ast.BinaryExpr)
+ if !ok || be.Op != token.NEQ {
+ continue
+ }
+ x, xok := be.X.(*ast.Ident)
+ y, yok := be.Y.(*ast.Ident)
+ derived = xok && yok && x.Name == "boxJudge" && y.Name == "nil"
+ }
+ case *ast.CallExpr:
+ if id, ok := n.Fun.(*ast.Ident); ok && id.Name == "edgesFileLoop" {
+ for _, a := range n.Args {
+ if ai, ok := a.(*ast.Ident); ok && ai.Name == "lifetimes" {
+ handed = true
+ }
+ }
+ }
+ }
+ })
+ if assigns != 1 || !derived {
+ t.Errorf("runAgent assigns lifetimes %d times (derived from boxJudge != nil: %v), want once, from it", assigns, derived)
+ }
+ if !handed {
+ t.Error("runAgent's edgesFileLoop call does not pass lifetimes")
+ }
+ passed := false
+ inspectFunc(t, "edgesfile.go", "edgesFileLoop", func(n ast.Node) {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "edgesFileWriteClaims" || len(call.Args) == 0 {
+ return
+ }
+ if a, ok := call.Args[len(call.Args)-1].(*ast.Ident); ok && a.Name == "lifetimes" {
+ passed = true
+ }
+ })
+ if !passed {
+ t.Error("edgesFileLoop does not hand its lifetimes to edgesFileWriteClaims")
+ }
+}
+
+// inspectFunc walks the body of fn, declared in file of this package.
+func inspectFunc(t *testing.T, file, fn string, visit func(ast.Node)) {
+ t.Helper()
+ f, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range f.Decls {
+ if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == fn {
+ ast.Inspect(fd.Body, func(n ast.Node) bool { visit(n); return true })
+ return
+ }
+ }
+ t.Fatalf("%s: no func %s", file, fn)
+}