carlos / platform Public

hostbox: reconcile's relocation row cases, and only the source or a probing target wakes under relocating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev bfaa307d5cbe5acda4a067bd44fe268b47310399 parent 53f26ad
5 files changed, +417 −6
  • internal/hostbox/relocationrows.go +79 −0
  • internal/hostbox/relocationrows_test.go +189 −0
  • internal/hostbox/wakepolicy.go +80 −6
  • internal/hostguard/admissionfence.go +31 −0
  • internal/hostguard/admissionfence_test.go +38 −0
diff --git a/internal/hostbox/relocationrows.go b/internal/hostbox/relocationrows.go
new file mode 100644
index 000000000..9858a12b5
--- /dev/null
+++ b/internal/hostbox/relocationrows.go
@@ -0,0 +1,79 @@
+package hostbox
+
+import (
+ "slices"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// RowCase is what reconcile does with one relocatable host's registry row
+// on this box (region HA spec 1 §5.3, claim C4 as amended by L12).
+type RowCase int
+
+const (
+ // RowNone: mint nothing, and there is no row to keep.
+ RowNone RowCase = iota
+ // RowReady: mint the row, or keep it, ready.
+ RowReady
+ // RowHeld: mint the row, or keep it, held with HoldOp.
+ RowHeld
+ // RowRetiring: keep an existing row held; mint none. A park-local chore
+ // this box owes retires it under the host guard (claim P6-D2).
+ RowRetiring
+ // RowUnexplained: the claim gives this box no role, yet a row exists.
+ // Keep it held, never serve it, and alert. Never delete it by hostname.
+ RowUnexplained
+)
+
+// RowDecision is RelocationRow's answer.
+type RowDecision struct {
+ Case RowCase
+ HoldOp string
+}
+
+// RelocationRow decides one row from a fresh claim read. box is this box's
+// reference; exists says whether the registry holds a row for the host;
+// fenceClosed says whether this box's local admission fence for the host is
+// closed (the source's release has begun, §5.8's release sequence step 1).
+//
+// op.exec, serving (Serving's own rule) ready
+// relocating source in starting, or releasing
+// until its admission fence closes ready
+// any other participant of a running operation held, hold_op = op id
+// owner of an open park-local chore for the host retiring (held, no mint)
+// none of these none; an existing row is unexplained
+//
+// A box on the claim's lost list is never ready. A nil or holderless claim
+// (an error is the caller's: claim C6, keep rows and mint none) gives none.
+func RelocationRow(c *hostclaim.Claim, box string, exists, fenceClosed bool) RowDecision {
+ if c == nil || c.Holder == nil || box == "" {
+ return unexplained(exists)
+ }
+ lost := slices.Contains(c.Lost, box)
+ switch {
+ case lost:
+ case Serving(c, Selection{Box: box}) == nil:
+ return RowDecision{Case: RowReady}
+ case c.Op.Kind == hostclaim.KindRelocating && c.Op.Exec == box && c.Op.Intent["source_box"] == box &&
+ (c.Op.Phase == hostclaim.PhaseStarting || (c.Op.Phase == hostclaim.PhaseReleasing && !fenceClosed)):
+ return RowDecision{Case: RowReady}
+ case c.Op.Kind != hostclaim.KindServing && c.Op.ID != "" && slices.Contains(c.Op.Participants, box):
+ return RowDecision{Case: RowHeld, HoldOp: c.Op.ID}
+ }
+ for _, o := range c.Obligations {
+ if o.Kind == "park-local" && o.Box == box {
+ if !exists {
+ return RowDecision{Case: RowNone}
+ }
+ return RowDecision{Case: RowRetiring, HoldOp: o.Op}
+ }
+ }
+ return unexplained(exists)
+}
+
+func unexplained(exists bool) RowDecision {
+ if exists {
+ return RowDecision{Case: RowUnexplained}
+ }
+ return RowDecision{Case: RowNone}
+}
diff --git a/internal/hostbox/relocationrows_test.go b/internal/hostbox/relocationrows_test.go
new file mode 100644
index 000000000..f957f59b8
--- /dev/null
+++ b/internal/hostbox/relocationrows_test.go
@@ -0,0 +1,189 @@
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+)
+
+func relocating(phase, exec string) *hostclaim.Claim {
+ return &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: phase, Exec: exec,
+ Participants: []string{"src", "dst"}, Intent: map[string]string{"source_box": "src", "target_box": "dst", "region": "ie"}}}
+}
+
+func TestRelocationRowCases(t *testing.T) {
+ withChore := func(c *hostclaim.Claim, box string) *hostclaim.Claim {
+ c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "o", Op: "rel-0", Kind: "park-local", Box: box, Owner: "box:" + box, Retirement: "r"})
+ return c
+ }
+ serving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "src", Participants: []string{"src"}}}
+ lostSource := relocating(hostclaim.PhaseStarting, "src")
+ lostSource.Lost = []string{"src"}
+ for _, tc := range []struct {
+ name string
+ c *hostclaim.Claim
+ box string
+ exists, fenceClosed bool
+ want RowDecision
+ }{
+ {"the serving executor", serving, "src", false, false, RowDecision{Case: RowReady}},
+ {"a serving host elsewhere, no row", serving, "dst", false, false, RowDecision{Case: RowNone}},
+ {"a serving host elsewhere, a row", serving, "dst", true, false, RowDecision{Case: RowUnexplained}},
+ {"the source in starting", relocating(hostclaim.PhaseStarting, "src"), "src", true, false, RowDecision{Case: RowReady}},
+ {"the source releasing, fence open", relocating(hostclaim.PhaseReleasing, "src"), "src", true, false, RowDecision{Case: RowReady}},
+ {"the source releasing, fence closed", relocating(hostclaim.PhaseReleasing, "src"), "src", true, true, RowDecision{Case: RowHeld, HoldOp: "rel-1"}},
+ {"the source in pinning", relocating(hostclaim.PhasePinning, "none"), "src", true, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}},
+ {"the target in starting", relocating(hostclaim.PhaseStarting, "src"), "dst", false, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}},
+ {"the target executing in probing", relocating(hostclaim.PhaseProbing, "dst"), "dst", true, false, RowDecision{Case: RowHeld, HoldOp: "rel-1"}},
+ {"a lost source", lostSource, "src", true, false, RowDecision{Case: RowUnexplained}},
+ {"a former executor owing park-local", withChore(serving, "old"), "old", true, false, RowDecision{Case: RowRetiring, HoldOp: "rel-0"}},
+ {"a former executor with no row", withChore(serving, "old"), "old", false, false, RowDecision{Case: RowNone}},
+ {"no claim, a row", nil, "src", true, false, RowDecision{Case: RowUnexplained}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := RelocationRow(tc.c, tc.box, tc.exists, tc.fenceClosed); got != tc.want {
+ t.Fatalf("got %+v, want %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestWakePhaseRelocating(t *testing.T) {
+ for _, tc := range []struct {
+ phase, exec, box string
+ probe, want bool
+ }{
+ {hostclaim.PhaseStarting, "src", "src", false, true},
+ {hostclaim.PhaseReleasing, "src", "src", false, true},
+ {hostclaim.PhaseStarting, "src", "src", true, false},
+ {hostclaim.PhaseInstalling, "dst", "dst", false, false},
+ {hostclaim.PhaseInstalled, "dst", "dst", true, false},
+ {hostclaim.PhaseProbing, "dst", "dst", true, true},
+ {hostclaim.PhaseProbing, "dst", "dst", false, false},
+ {hostclaim.PhaseProbeFailed, "dst", "dst", true, false},
+ {hostclaim.PhasePinning, "none", "src", false, false},
+ } {
+ if got := WakePhase(relocating(tc.phase, tc.exec), tc.box, tc.probe) == nil; got != tc.want {
+ t.Fatalf("%+v: %v", tc, got)
+ }
+ }
+ stuck := relocating(hostclaim.PhaseProbing, "dst")
+ stuck.Op.Journal = map[string]string{"stuck": "operator"}
+ if WakePhase(stuck, "dst", true) == nil {
+ t.Fatal("a stuck operation probed")
+ }
+}
+
+func TestPolicyRoutesRelocationToItsOwner(t *testing.T) {
+ c := relocating(hostclaim.PhaseStarting, "src")
+ held := func(context.Context, string) (string, error) { return "", nil }
+ s := Selection{Host: "x.example", Box: "src", Op: "rel-1", Action: "spawn"}
+ moves := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }
+ if err := (Policy{Held: held, Moves: moves}).Check(context.Background(), c, s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("moves' policy authorized a relocation: %v", err)
+ }
+ called := false
+ reloc := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error {
+ called = true
+ return nil
+ }
+ if err := (Policy{Held: held, Relocation: reloc}).Check(context.Background(), c, s, hostwrite.Identity{}); err != nil || !called {
+ t.Fatalf("relocation policy: %v called=%v", err, called)
+ }
+}
+
+func TestPolicyLetsTheServingExecutorClearAnObsoleteHold(t *testing.T) {
+ serving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}}}
+ obsolete := func(context.Context, string) (string, error) { return "rel-1", nil }
+ reconcile := Selection{Host: "x.example", Box: "dst", Action: "reconcile"}
+ // Region HA off (no Relocation policy): today's refusal, unchanged.
+ if err := (Policy{Held: obsolete}).Check(context.Background(), serving, reconcile, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("with HA off a held serving row was reconciled: %v", err)
+ }
+ policy := Policy{Held: obsolete, Relocation: RelocationPolicy("dst")}
+ if err := policy.Check(context.Background(), serving, reconcile, hostwrite.Identity{}); err != nil {
+ t.Fatalf("the serving executor could not reconcile an obsolete hold away: %v", err)
+ }
+ for name, s := range map[string]Selection{
+ "a spawn under the hold": {Host: "x.example", Box: "dst", Action: "spawn"},
+ "another box's reconcile": {Host: "x.example", Box: "src", Action: "reconcile"},
+ } {
+ if err := policy.Check(context.Background(), serving, s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("%s: %v", name, err)
+ }
+ }
+}
+
+func TestRelocationPolicyGivesEachActionItsRole(t *testing.T) {
+ policy := RelocationPolicy("dst")
+ at := func(phase, exec string) *hostclaim.Claim { return relocating(phase, exec) }
+ for name, tc := range map[string]struct {
+ c *hostclaim.Claim
+ action string
+ want error
+ }{
+ "a participant reconciles": {at(hostclaim.PhaseStarting, "src"), "reconcile", nil},
+ "a non-executor's held config": {at(hostclaim.PhaseStarting, "src"), "held-config", ErrChanged},
+ "the executor's held config": {at(hostclaim.PhaseInstalling, "dst"), "held-config", nil},
+ "the executor installs": {at(hostclaim.PhaseInstalled, "dst"), "install", nil},
+ "the target spawns in installing": {at(hostclaim.PhaseInstalling, "dst"), "spawn", ErrChanged},
+ "the target spawns in probing": {at(hostclaim.PhaseProbing, "dst"), "spawn", ErrChanged}, // only a probe
+ "the target probes in probing": {at(hostclaim.PhaseProbing, "dst"), "probe", nil},
+ "the target probes in installed": {at(hostclaim.PhaseInstalled, "dst"), "probe", ErrChanged},
+ "the source probes": {at(hostclaim.PhaseProbing, "src"), "probe", ErrChanged},
+ "an unknown action": {at(hostclaim.PhaseProbing, "dst"), "purge", ErrChanged},
+ } {
+ if err := policy(context.Background(), tc.c, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: tc.action}, hostwrite.Identity{}); !errors.Is(err, tc.want) {
+ t.Fatalf("%s: %v, want %v", name, err, tc.want)
+ }
+ }
+ if err := policy(context.Background(), at(hostclaim.PhaseStarting, "src"), Selection{Host: "x.example", Box: "src", Op: "rel-1", Action: "reconcile"}, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("another box's selection: %v", err)
+ }
+}
+
+func TestPolicyLetsAHeldParticipantReconcileItsRow(t *testing.T) {
+ held := func(context.Context, string) (string, error) { return "rel-1", nil }
+ reloc := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }
+ policy := Policy{Held: held, Relocation: reloc}
+ row := func(box, op string) Selection {
+ return Selection{Host: "x.example", Box: box, Op: op, Action: "reconcile"}
+ }
+ c := relocating(hostclaim.PhaseStarting, "src")
+ for name, s := range map[string]Selection{"the target, not the executor": row("dst", "rel-1"), "the source": row("src", "rel-1")} {
+ if err := policy.Check(context.Background(), c, s, hostwrite.Identity{}); err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ }
+ lost := relocating(hostclaim.PhaseStarting, "src")
+ lost.Lost = []string{"src"}
+ for name, tc := range map[string]struct {
+ c *hostclaim.Claim
+ s Selection
+ }{
+ "a box with no role": {c, row("zzz", "rel-1")},
+ "another operation": {c, row("dst", "rel-0")},
+ "a lost participant": {lost, row("src", "rel-1")},
+ "a spawn by the target": {c, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "spawn"}},
+ } {
+ if err := policy.Check(context.Background(), tc.c, tc.s, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("%s: %v", name, err)
+ }
+ }
+ // A target probe passes the whole policy; its ordinary spawn does not.
+ probing := relocating(hostclaim.PhaseProbing, "dst")
+ full := Policy{Held: held, Relocation: RelocationPolicy("dst")}
+ if err := full.Check(context.Background(), probing, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "probe"}, hostwrite.Identity{}); err != nil {
+ t.Fatalf("the target's probe: %v", err)
+ }
+ if err := full.Check(context.Background(), probing, Selection{Host: "x.example", Box: "dst", Op: "rel-1", Action: "spawn"}, hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("an ordinary spawn by the probing target: %v", err)
+ }
+ moving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "sealed", Exec: "src", Participants: []string{"src", "dst"}}}
+ if err := (Policy{Held: held, Moves: reloc, Relocation: reloc}).Check(context.Background(), moving, row("dst", "mv-1"), hostwrite.Identity{}); !errors.Is(err, ErrChanged) {
+ t.Fatalf("a move's participant reconciled through the relocation action: %v", err)
+ }
+}
diff --git a/internal/hostbox/wakepolicy.go b/internal/hostbox/wakepolicy.go
index 9c3c00d65..5dec10dfb 100644
--- a/internal/hostbox/wakepolicy.go
+++ b/internal/hostbox/wakepolicy.go
@@ -2,6 +2,7 @@ package hostbox
import (
"context"
+ "slices"
"time"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -27,6 +28,17 @@ func WakePhase(c *hostclaim.Claim, box string, probe bool) error {
if c.Op.ID != "" && ((!probe && c.Op.Phase == "committed") || (probe && c.Op.Phase == "probing")) {
return nil
}
+ case hostclaim.KindRelocating:
+ // Region HA §5.8: the source keeps serving in starting and in
+ // releasing (its local admission fence closes releasing); the
+ // target runs only probes, in probing. Nobody else, ever.
+ if c.Op.ID == "" || c.Op.Journal["stuck"] != "" {
+ break
+ }
+ source := c.Op.Intent["source_box"] == box && (c.Op.Phase == hostclaim.PhaseStarting || c.Op.Phase == hostclaim.PhaseReleasing)
+ if (!probe && source) || (probe && c.Op.Phase == hostclaim.PhaseProbing) {
+ return nil
+ }
case hostclaim.KindReverting, hostclaim.KindRestoring:
if c.Op.ID != "" && ((!probe && (c.Op.Phase == "probed" || c.Op.Phase == "opened")) || (probe && c.Op.Phase == "probing")) {
return nil
@@ -37,10 +49,13 @@ func WakePhase(c *hostclaim.Claim, box string, probe bool) error {
// Policy separates ordinary writes from the moves-owned held actions.
// Moves validates roles, held inputs and phase-specific rights with the same capture.
+// Relocation does the same for a relocating claim (region HA §5.8); with it
+// nil, every relocating action is refused.
type Policy struct {
- Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
- Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
- Held func(context.Context, string) (string, error)
+ Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
+ Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
+ Relocation func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error
+ Held func(context.Context, string) (string, error)
}
func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error {
@@ -61,14 +76,43 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h
}
}
if hold != "" {
- return ErrChanged
+ // A hold of an operation the claim no longer runs is obsolete.
+ // Only the serving executor's reconcile may pass it, and only
+ // to clear it (round 3 finding N4), and only where region HA
+ // is on (Relocation set): with it off, today's refusal.
+ if p.Relocation == nil || s.Action != "reconcile" || hold == c.Op.ID || c.Op.ID != "" {
+ return ErrChanged
+ }
}
return Serving(c, s)
}
- if p.Moves == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box {
+ owner := p.Moves
+ if c.Op.Kind == hostclaim.KindRelocating {
+ owner = p.Relocation
+ }
+ if s.Action == "reconcile" {
+ // Region HA §5.3: reconcile mints or keeps a held row for any
+ // participant of a running relocation, executor or not, never
+ // for a lost box. The row's hold is the operation's; the owner
+ // policy permits the hold write and nothing else for a
+ // non-executor. Other kinds keep refusing the action here.
+ if c.Op.Kind != hostclaim.KindRelocating || owner == nil || s.Op == "" || c.Op.ID != s.Op ||
+ !slices.Contains(c.Op.Participants, s.Box) || slices.Contains(c.Lost, s.Box) {
+ return ErrChanged
+ }
+ return owner(ctx, c, s, id)
+ }
+ if owner == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box {
return ErrChanged
}
switch s.Action {
+ case "probe":
+ // A relocation target's probe spawn (§5.8, R1): selected as its own
+ // action, so the policy can give it probe authority where an
+ // ordinary spawn has none.
+ if c.Op.Kind != hostclaim.KindRelocating || (hold != "" && hold != s.Op) {
+ return ErrChanged
+ }
case "held-config", "held-release", "held-timers":
if hold != s.Op {
return ErrChanged
@@ -84,7 +128,37 @@ func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id h
default:
return ErrChanged
}
- return p.Moves(ctx, c, s, id)
+ return owner(ctx, c, s, id)
+}
+
+// RelocationPolicy is the Policy.Relocation owner for one box: the role
+// and phase rights of each action under a relocating claim, after
+// Policy.Check has matched the operation and the participant. A
+// reconcile is the hold write any participant makes; held inputs are the
+// executor's; execution actions are what WakePhase allows the executor,
+// and "probe" is the target's probe spawn in probing (WakePhase with
+// probe set), which an ordinary spawn never gets. Production sets it with
+// CARLOS_REGION_HA on (Task 10); nil refuses.
+func RelocationPolicy(box string) func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error {
+ return func(_ context.Context, c *hostclaim.Claim, s Selection, _ hostwrite.Identity) error {
+ if box == "" || s.Box != box || c.Op.Kind != hostclaim.KindRelocating {
+ return ErrChanged
+ }
+ switch s.Action {
+ case "reconcile":
+ return nil
+ case "held-config", "held-release", "held-timers", "install", "restore":
+ if c.Op.Exec != box {
+ return ErrChanged
+ }
+ return nil
+ case "spawn", "seed", "writer":
+ return WakePhase(c, box, false)
+ case "probe":
+ return WakePhase(c, box, true)
+ }
+ return ErrChanged
+ }
}
const RequestLimit = 120 * time.Second
diff --git a/internal/hostguard/admissionfence.go b/internal/hostguard/admissionfence.go
new file mode 100644
index 000000000..70a800fdc
--- /dev/null
+++ b/internal/hostguard/admissionfence.go
@@ -0,0 +1,31 @@
+package hostguard
+
+import "github.com/carlosframework/platform/internal/hostclaim"
+
+// AdmissionFenced reports whether the host's durable admission fence is
+// closed for the installation and session captured names on claim c: a
+// stop root began (ActiveFence) or completed (a FenceJournal receipt) for
+// that session. It is the same reading With refuses on, exposed for
+// region HA §5.3's row decision ("the source in releasing until its
+// admission fence closes"). It takes no lock: the record is replaced
+// atomically, and callers already hold the host lock (Activate's reader,
+// WithLocal) or need no lock for one consistent read. The claim is the
+// caller's fresh read. It grants nothing.
+func (g Guard) AdmissionFenced(host string, c *hostclaim.Claim, captured Captured) (bool, error) {
+ if c == nil || c.Holder == nil {
+ return false, ErrNoHolder
+ }
+ r, err := readRecord(g.DataDir, host)
+ if err != nil {
+ return false, err
+ }
+ if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) {
+ return true, nil
+ }
+ for _, receipt := range r.FenceJournal {
+ if receipt.Target.matchesSession(c, captured) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/internal/hostguard/admissionfence_test.go b/internal/hostguard/admissionfence_test.go
new file mode 100644
index 000000000..8260d6632
--- /dev/null
+++ b/internal/hostguard/admissionfence_test.go
@@ -0,0 +1,38 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestAdmissionFencedFollowsTheDurableFence(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ if fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, captured); err != nil || fenced {
+ t.Fatalf("before any fence: %v %v", fenced, err)
+ }
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ // Read inside the host lock, as Activate's reader and WithLocal do:
+ // no second acquisition, no deadlock.
+ err := f.g.WithLocal(t.Context(), target.Host, func() error {
+ fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, captured)
+ if err != nil || !fenced {
+ return errors.New("after BeginFence, under the lock: not fenced")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ other := captured
+ other.Stamp++
+ if fenced, err := f.g.AdmissionFenced(target.Host, f.obs.Claim, other); err != nil || fenced {
+ t.Fatalf("another installation: %v %v", fenced, err)
+ }
+ if _, err := f.g.AdmissionFenced("no.such.host", f.obs.Claim, captured); !errors.Is(err, ErrNoRecord) {
+ t.Fatalf("a host with no record: %v", err)
+ }
+}