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>
5 files changed,
+265
−19
internal/host/agent.go+5 −1internal/host/boxaddress_test.go+7 −7internal/host/edgesclaims_test.go+1 −1internal/host/edgesfile.go+47 −10internal/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) |
| +} |