carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/hostbox"
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/hostwrite"
20 "github.com/carlosframework/platform/internal/moves"
21 "github.com/carlosframework/platform/internal/registry"
22 "github.com/carlosframework/platform/internal/release"
23 "github.com/superfly/ltx"
24)
25
26type plan9D2Attempts struct {
27 calls []string
28 starts []OperationStart
29 again error
30}
31
32func (a *plan9D2Attempts) record(call string, s OperationStart) {
33 a.calls = append(a.calls, call)
34 a.starts = append(a.starts, s)
35}
36func (a *plan9D2Attempts) OpenProbe(_ context.Context, s OperationStart, proposed time.Time) (time.Time, error) {
37 a.record("open", s)
38 return proposed, nil
39}
40func (a *plan9D2Attempts) CurrentAttempt(s OperationStart) hostclaim.WakeRetryAttempt {
41 a.record("current", s)
42 return hostclaim.WakeRetryAttempt{ID: "attempt", InvocationID: "invocation", Session: s.Owner.Session, Box: s.Owner.Box}
43}
44func (a *plan9D2Attempts) Reserve(_ context.Context, s OperationStart, n int) error {
45 a.record(fmt.Sprintf("reserve-%d", n), s)
46 return nil
47}
48func (a *plan9D2Attempts) Failed(_ context.Context, s OperationStart, n int) error {
49 a.record(fmt.Sprintf("failed-%d", n), s)
50 return nil
51}
52func (a *plan9D2Attempts) Healthy(ctx context.Context, s OperationStart, n int, _ *exec.Cmd, again func(context.Context) error) error {
53 a.record(fmt.Sprintf("healthy-%d", n), s)
54 a.again = again(ctx)
55 return a.again
56}
57
58// plan9D2Inputs is the moves Inputs owner's shape for a landing admission.
59type plan9D2Inputs struct{ recoveryInputs }
60
61func (in plan9D2Inputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) {
62 selected, err := in.recoveryInputs.Select(ctx, rt, o, p)
63 h := o.Claim.Holder
64 selected.Admission = hostclaim.Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "box-1", Mode: "cold", NewGeneration: fmt.Sprintf("op-gen-%d", in.f.seq), KeepStamp: h.Stamp > h.Session, Region: "ie", PredecessorRegion: "ie"}
65 selected.ReadyPath = "/ready"
66 return selected, err
67}
68
69// plan9D2Landing turns the recovery fixture's created host into the move
70// op's landing at phase with box-1 executing, and admits one writer there
71// through the real runtime. Moves C1 owners are bound unless bind is false.
72func plan9D2Landing(t *testing.T, phase string, p activator.StartPolicy, bind bool) (*recoveryFixture, *recoveryController, *plan9D2Attempts, *int, hostclaim.Observed) {
73 t.Helper()
74 f := newRecoveryFixture(t)
75 ctx := t.Context()
76 if _, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error {
77 c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: phase, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"to": "ie", "source": "box-0", "target": "box-1"}}
78 return nil
79 }); err != nil {
80 t.Fatal(err)
81 }
82 c := f.controller("boot-1", "controller-1", 4101, "start-1")
83 c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}}
84 ops, probes := &plan9D2Attempts{}, new(int)
85 if bind {
86 c.wake.Continue = hostclaim.SessionContinues
87 c.wake.Operations = ops
88 c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error {
89 *probes++
90 return nil
91 }
92 }
93 live, cancel := context.WithCancel(context.Background())
94 t.Cleanup(cancel)
95 done := make(chan struct{})
96 go func() { <-live.Done(); close(done) }()
97 o := f.read()
98 c.wake.mu.Lock()
99 c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done}
100 c.wake.mu.Unlock()
101 admitted, err := c.runtime.Admit(ctx, registry.Route{Host: recoveryHost}, o, p)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if admitted.Claim.Op.Kind != hostclaim.KindLanding || admitted.Claim.Holder.WriterBox != "box-1" || admitted.Claim.Holder.WakeRetry != nil {
106 t.Fatalf("not a landing admission: %+v", admitted.Claim)
107 }
108 return f, c, ops, probes, admitted
109}
110
111func TestPlan9D2OperationAdmissionPath(t *testing.T) {
112 landing := func(phase, exec string) hostclaim.Observed {
113 return hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: phase, Exec: exec}}}
114 }
115 serving := hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-1"}}}
116 moving := hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "m1", Phase: "probing", Exec: "box-1"}}}
117 probe, wake := activator.StartPolicy{Probe: true}, activator.StartPolicy{}
118 var refusal *ClaimDependencyError
119 // Without the moves C1 owners (either flag off), main's refusal stands.
120 unbound := &ClaimWakeAssembly{}
121 if err := unbound.admitsOperation(serving, wake, "box-1"); err != nil {
122 t.Fatalf("ordinary serving wake: %v", err)
123 }
124 for name, tc := range map[string]struct {
125 o hostclaim.Observed
126 p activator.StartPolicy
127 }{"serving-probe": {serving, probe}, "landing-probe": {landing("probing", "box-1"), probe}, "landing-wake": {landing("committed", "box-1"), wake}} {
128 if err := unbound.admitsOperation(tc.o, tc.p, "box-1"); !errors.As(err, &refusal) {
129 t.Fatalf("unbound %s: %v", name, err)
130 }
131 }
132 bound := &ClaimWakeAssembly{Operations: &plan9D2Attempts{}, ProbeReady: func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }}
133 if err := bound.admitsOperation(landing("probing", "box-1"), probe, "box-1"); err != nil {
134 t.Fatalf("landing probe at probing: %v", err)
135 }
136 if err := bound.admitsOperation(landing("committed", "box-1"), wake, "box-1"); err != nil {
137 t.Fatalf("landing wake at committed: %v", err)
138 }
139 for name, tc := range map[string]struct {
140 o hostclaim.Observed
141 p activator.StartPolicy
142 want error
143 }{
144 "probe-at-committed": {landing("committed", "box-1"), probe, hostbox.ErrChanged},
145 "wake-at-probing": {landing("probing", "box-1"), wake, hostbox.ErrChanged},
146 "probe-at-probed": {landing("probed", "box-1"), probe, hostbox.ErrChanged},
147 "other-executor": {landing("probing", "box-2"), probe, hostbox.ErrChanged},
148 "serving-probe": {serving, probe, hostwrite.ErrUnavailable},
149 "moving-probe": {moving, probe, hostwrite.ErrUnavailable},
150 } {
151 if err := bound.admitsOperation(tc.o, tc.p, "box-1"); !errors.Is(err, tc.want) {
152 t.Fatalf("bound %s: %v, want %v", name, err, tc.want)
153 }
154 }
155 noProbe := &ClaimWakeAssembly{Operations: &plan9D2Attempts{}}
156 if err := noProbe.admitsOperation(landing("probing", "box-1"), probe, "box-1"); !errors.As(err, &refusal) {
157 t.Fatalf("a probe without a probe readiness check: %v", err)
158 }
159}
160
161func TestPlan9D2PrepareBindsTheMovesAttemptsToTheAdmittedProbe(t *testing.T) {
162 probe := activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second, Probe: true}
163 _, c, ops, probes, admitted := plan9D2Landing(t, hostclaim.PhaseLandingProbing, probe, true)
164 ctx := t.Context()
165 s, err := c.runtime.Prepare(ctx, registry.Route{Host: recoveryHost}, admitted, probe)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if s.OpenProbe == nil || s.CurrentAttempt == nil || s.Reserve == nil || s.Failed == nil || s.Healthy == nil || s.Ready == nil {
170 t.Fatal("the probe start is missing its operation bindings")
171 }
172 opened := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
173 if got, err := s.OpenProbe(ctx, opened); err != nil || !got.Equal(opened) {
174 t.Fatalf("open: %v %v", got, err)
175 }
176 if err := s.Reserve(ctx, 0); err != nil {
177 t.Fatal(err)
178 }
179 if a := s.CurrentAttempt(); a.ID != "attempt" || a.Session != admitted.Claim.Holder.Session {
180 t.Fatalf("attempt: %+v", a)
181 }
182 if err := s.Failed(ctx, 1); err != nil {
183 t.Fatal(err)
184 }
185 // Healthy's second readiness request is the probe check, which needs the
186 // started role: before any launch it refuses and the app is never asked.
187 if err := s.Healthy(ctx, 2, exec.Command("/bin/true")); !errors.Is(err, hostwrite.ErrUnavailable) || *probes != 0 {
188 t.Fatalf("healthy before launch: %v probes=%d", err, *probes)
189 }
190 want := recoveryTuple(admitted, c.local)
191 for i, start := range ops.starts {
192 if start.Admitted.ETag != admitted.ETag || start.Owner != want || !start.Policy.Probe || start.Inputs.ReadyPath != "/ready" || start.Inputs.Admission.ObservedOpID != "m1" {
193 t.Fatalf("call %s was bound to %+v", ops.calls[i], start)
194 }
195 }
196 if got := strings.Join(ops.calls, ","); got != "open,reserve-0,current,failed-1,healthy-2" {
197 t.Fatalf("calls: %s", got)
198 }
199}
200
201func TestPlan9D2CommittedWakeUsesTheAttemptsAndServingReadiness(t *testing.T) {
202 wake := activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second}
203 _, c, ops, _, admitted := plan9D2Landing(t, hostclaim.PhaseLandingCommitted, wake, true)
204 s, err := c.runtime.Prepare(t.Context(), registry.Route{Host: recoveryHost}, admitted, wake)
205 if err != nil {
206 t.Fatal(err)
207 }
208 if s.OpenProbe != nil || s.Reserve == nil {
209 t.Fatal("a committed wake is not a probe and still reserves through moves")
210 }
211 if err := s.Reserve(t.Context(), 0); err != nil || len(ops.calls) != 1 || ops.calls[0] != "reserve-0" {
212 t.Fatalf("reserve: %v %v", err, ops.calls)
213 }
214}
215
216// With the attempts unbound (CARLOS_MOVES_PHASE1 off) an operation writer
217// that was admitted anyway cannot be prepared.
218func TestPlan9D2PrepareRefusesAnOperationWithoutTheMovesOwners(t *testing.T) {
219 wake := activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second}
220 _, c, _, _, admitted := plan9D2Landing(t, hostclaim.PhaseLandingCommitted, wake, false)
221 var refusal *ClaimDependencyError
222 if _, err := c.runtime.Prepare(t.Context(), registry.Route{Host: recoveryHost}, admitted, wake); !errors.As(err, &refusal) {
223 t.Fatalf("unbound operation prepare: %v", err)
224 }
225}
226
227// plan9D2Cut is a landing target uk-box admitted at probing over the move's
228// source ie-box, with the sealed cut, its journal and the source's stop
229// evidence. The admitted claim is stored and uk-box's local record is active
230// at the kept stamp.
231type plan9D2Cut struct {
232 handover claimCrossBoxHandover
233 prior, admitted hostclaim.Observed
234 evidence release.Store
235 key string
236 report moves.StoppedReport
237}
238
239func plan9D2CrossBox(t *testing.T, mutate func(*hostclaim.Claim)) *plan9D2Cut {
240 t.Helper()
241 ctx := t.Context()
242 at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
243 inc, err := hostclaim.Incarnation("bab", "jam", recoveryHost, at)
244 if err != nil {
245 t.Fatal(err)
246 }
247 header := ltx.HeaderSize
248 seal := moves.Seal{V: moves.SealVersion, N: 42, FromDB: true, SrcCovered: true, SealedAt: at, LeaseUntil: at.Add(time.Minute),
249 Replica: []moves.SealedObject{{Key: "moves/" + recoveryHost + "/m1/replica/0009/" + ltx.FormatFilename(1, 42), VersionID: "v1", Size: int64(header)}},
250 Files: []moves.SealedObject{{Key: "moves/" + recoveryHost + "/m1/files/archive", VersionID: "v2", Size: 10}}}
251 key := "moves/bab/jam/" + recoveryHost + "/m1/stopped-source.json"
252 journal := map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "15", hostclaim.JournalEvidenceSource: key, hostclaim.JournalSourceStamp: "15", hostclaim.JournalSrcGen: "src"}
253 priorClaim := &hostclaim.Claim{V: 2, Host: recoveryHost, Gen: 19,
254 Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + recoveryHost + ".json", Account: "bab", App: "jam", CreatedAt: at, Incarnation: inc, Location: "instances/" + recoveryHost, Generation: "src", Stamp: 18, Session: 15, WriterBox: "ie-box",
255 Admissions: []hostclaim.AdmissionRecord{{Session: 15, Box: "ie-box", Generation: "src", Predecessor: "older", Region: "ie", PredecessorRegion: "ie", Mode: "cold"}}},
256 Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: hostclaim.PhaseLandingProbing, Exec: "uk-box", Participants: []string{"ie-box", "uk-box"}, Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box"}, Journal: journal}}
257 admittedClaim := *priorClaim
258 h := *priorClaim.Holder
259 h.Session, h.Generation, h.WriterBox = 20, "m1", "uk-box"
260 h.Admissions = append(append([]hostclaim.AdmissionRecord(nil), priorClaim.Holder.Admissions...), hostclaim.AdmissionRecord{Session: 20, Box: "uk-box", Generation: "m1", Predecessor: "src", Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"})
261 admittedClaim.Holder, admittedClaim.Gen = &h, 20
262 if mutate != nil {
263 mutate(&admittedClaim)
264 }
265 if err := admittedClaim.Validate(); err != nil {
266 t.Fatal(err)
267 }
268 claims := release.NewDirStore(t.TempDir())
269 body, err := json.Marshal(admittedClaim)
270 if err != nil {
271 t.Fatal(err)
272 }
273 claimKey, err := hostclaim.Key(recoveryHost)
274 if err != nil {
275 t.Fatal(err)
276 }
277 if _, err := claims.Put(ctx, claimKey, body, ""); err != nil {
278 t.Fatal(err)
279 }
280 admitted, err := hostclaim.Read(ctx, claims, recoveryHost)
281 if err != nil {
282 t.Fatal(err)
283 }
284 data := t.TempDir()
285 guard := hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: claims}}
286 if err := os.MkdirAll(filepath.Join(guard.DataDir, "hostguard"), 0770); err != nil {
287 t.Fatal(err)
288 }
289 if err := guard.Activate(ctx, recoveryHost, "uk-box", nil); err != nil {
290 t.Fatal(err)
291 }
292 report := moves.StoppedReport{V: 1, Role: "source", Box: "ie-box", Incarnation: inc, ClaimGen: 17, Host: recoveryHost, OpID: "m1", FinalTXID: 42, CheckedAt: at,
293 Steps: map[string]moves.Check{moves.StepTree: {OK: true}, moves.StepStray: {OK: true}, moves.StepFDs: {OK: true}, moves.StepLease: {OK: true}, moves.StepReplica: {OK: true}}}
294 evidence := release.NewDirStore(t.TempDir())
295 cut := &plan9D2Cut{handover: claimCrossBoxHandover{Guard: guard, Evidence: evidence, Box: "uk-box"}, prior: hostclaim.Observed{Host: recoveryHost, ETag: "prior", Claim: priorClaim}, admitted: admitted, evidence: evidence, key: key, report: report}
296 cut.publish(t)
297 return cut
298}
299
300func (c *plan9D2Cut) publish(t *testing.T) {
301 t.Helper()
302 body, err := json.Marshal(c.report)
303 if err != nil {
304 t.Fatal(err)
305 }
306 etag := ""
307 if _, current, err := c.evidence.Get(t.Context(), c.key); err == nil {
308 etag = current
309 }
310 if _, err := c.evidence.Put(t.Context(), c.key, body, etag); err != nil {
311 t.Fatal(err)
312 }
313}
314
315func TestPlan9D2CrossBoxHandoverAuthorizesFromTheSealedCut(t *testing.T) {
316 cut := plan9D2CrossBox(t, nil)
317 if err := cut.handover.After(t.Context(), cut.prior, cut.admitted); err != nil {
318 t.Fatal(err)
319 }
320}
321
322func TestPlan9D2CrossBoxHandoverRefusesWithoutExactEvidence(t *testing.T) {
323 t.Parallel()
324 for name, tc := range map[string]struct {
325 claim func(*hostclaim.Claim)
326 prior func(*hostclaim.Claim)
327 report func(*moves.StoppedReport)
328 }{
329 "cut-session": {prior: func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCutSession] = "14" }},
330 "no-evidence": {prior: func(c *hostclaim.Claim) { delete(c.Op.Journal, hostclaim.JournalEvidenceSource) }},
331 "bad-seal": {prior: func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalSeal] = "{}" }},
332 "same-box": {prior: func(c *hostclaim.Claim) { c.Holder.WriterBox = "uk-box" }},
333 "not-probing": {prior: func(c *hostclaim.Claim) { c.Op.Phase = "restored" }},
334 "report-box": {report: func(r *moves.StoppedReport) { r.Box = "other-box" }},
335 "report-op": {report: func(r *moves.StoppedReport) { r.OpID = "m0" }},
336 "report-n": {report: func(r *moves.StoppedReport) { r.FinalTXID = 41 }},
337 "report-role": {report: func(r *moves.StoppedReport) { r.Role = "target" }},
338 "incomplete": {report: func(r *moves.StoppedReport) { delete(r.Steps, moves.StepReplica) }},
339 "coverage": {claim: func(c *hostclaim.Claim) { c.Holder.Admissions[1].Coverage = "" }},
340 "predecessor": {claim: func(c *hostclaim.Claim) { c.Holder.Admissions[1].Predecessor = "older" }},
341 "stamp-changed": {claim: func(c *hostclaim.Claim) { c.Holder.Stamp = 20 }},
342 } {
343 t.Run(name, func(t *testing.T) {
344 cut := plan9D2CrossBox(t, tc.claim)
345 if tc.prior != nil {
346 prior := *cut.prior.Claim
347 holder := *prior.Holder
348 prior.Holder = &holder
349 prior.Op.Journal = map[string]string{}
350 for k, v := range cut.prior.Claim.Op.Journal {
351 prior.Op.Journal[k] = v
352 }
353 tc.prior(&prior)
354 cut.prior.Claim = &prior
355 }
356 if tc.report != nil {
357 tc.report(&cut.report)
358 cut.publish(t)
359 }
360 if err := cut.handover.After(t.Context(), cut.prior, cut.admitted); err == nil {
361 t.Fatalf("%s was authorized", name)
362 }
363 })
364 }
365 // The kept stamp must be this box's active landing installation.
366 cut := plan9D2CrossBox(t, nil)
367 cut.handover.Guard.DataDir = t.TempDir()
368 if err := cut.handover.After(t.Context(), cut.prior, cut.admitted); err == nil {
369 t.Fatal("a box without the landing installation's local record was authorized")
370 }
371}
372
373func TestPlan9D2AdmissionHandoverDispatch(t *testing.T) {
374 var calls []string
375 local := func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
376 calls = append(calls, "local")
377 return nil
378 }
379 cross := func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
380 calls = append(calls, "cross")
381 return nil
382 }
383 prior := func(kind, writer string) hostclaim.Observed {
384 return hostclaim.Observed{Host: recoveryHost, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{WriterBox: writer}, Op: hostclaim.Op{Kind: kind, ID: "m1"}}}
385 }
386 ctx := t.Context()
387 for _, tc := range []struct {
388 hook func(context.Context, hostclaim.Observed, hostclaim.Observed) error
389 prior hostclaim.Observed
390 want string
391 }{
392 {claimAdmissionHandover("uk-box", local, cross), prior(hostclaim.KindLanding, "ie-box"), "cross"},
393 {claimAdmissionHandover("uk-box", local, cross), prior(hostclaim.KindLanding, "uk-box"), "local"},
394 {claimAdmissionHandover("uk-box", local, cross), prior(hostclaim.KindLanding, ""), "local"},
395 {claimAdmissionHandover("uk-box", local, cross), prior(hostclaim.KindServing, "ie-box"), "local"},
396 {claimAdmissionHandover("uk-box", local, nil), prior(hostclaim.KindLanding, "ie-box"), "local"},
397 } {
398 calls = nil
399 if err := tc.hook(ctx, tc.prior, hostclaim.Observed{}); err != nil || len(calls) != 1 || calls[0] != tc.want {
400 t.Fatalf("%+v went to %v (%v), want %s", tc.prior.Claim, calls, err, tc.want)
401 }
402 }
403}
404
405func TestPlan9D2ProductionPredecessorUsesSourceEvidenceBeforeAdmission(t *testing.T) {
406 local, remote := 0, 0
407 w := &ClaimWakeAssembly{Stop: func(context.Context, hostclaim.Observed) error { local++; return nil }}
408 w.BeforeAdmission = claimPredecessorStop(w, "target", func(context.Context, hostclaim.Observed) error { remote++; return hostbox.ErrChanged })
409 o := hostclaim.Observed{Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{WriterBox: "source"}, Op: hostclaim.Op{Kind: hostclaim.KindLanding}}}
410 if err := w.stopPredecessor(t.Context(), o); !errors.Is(err, hostbox.ErrChanged) || local != 0 || remote != 1 {
411 t.Fatalf("source proof did not gate admission: local=%d remote=%d err=%v", local, remote, err)
412 }
413 o.Claim.Holder.WriterBox = "target"
414 if err := w.stopPredecessor(t.Context(), o); err != nil || local != 1 || remote != 1 {
415 t.Fatalf("local predecessor did not use stop: %d %d %v", local, remote, err)
416 }
417}
418