| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | ) |
| 12 | |
| 13 | // Paul's ruling M4 (2026-10-10; spec §7.5, §19 K8): every move close is |
| 14 | // record first, then the claim's release. Each box close checks that nothing |
| 15 | // is owed under the op, clears the record's move into LastMove, and only then |
| 16 | // releases the claim. A crash between the two halves leaves the record closed |
| 17 | // and the claim still under the move; the box pass is driven by records with |
| 18 | // a live move, so it never comes back, and the console's recovery pass |
| 19 | // releases from LastMove (internal/console, TestRecordFirstCloseCrashIsFinishedByThePass). |
| 20 | // |
| 21 | // Each case crashes at "after-close-record", the seam between the halves, |
| 22 | // and asserts the crash state: the record at rest with the outcome, the |
| 23 | // claim still in the move's closing op with nothing owed, and a further |
| 24 | // pass of every box writes nothing. |
| 25 | |
| 26 | // closedButHeld asserts the state between the halves of a record-first |
| 27 | // close: the record closed with outcome, the claim still at kind/phase under |
| 28 | // the move with nothing owed under it. |
| 29 | func closedButHeld(t *testing.T, rec provision.Record, c *hostclaim.Claim, outcome, kind, phase string) { |
| 30 | t.Helper() |
| 31 | if rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != p1ID || rec.LastMove.Outcome != outcome { |
| 32 | t.Fatalf("the record between the halves: move %+v last %+v, want closed %s", rec.Move, rec.LastMove, outcome) |
| 33 | } |
| 34 | if c.Op.ID != p1ID || c.Op.Kind != kind || c.Op.Phase != phase { |
| 35 | t.Fatalf("the claim between the halves: %+v, want %s/%q under %s (the release is second)", c.Op, kind, phase, p1ID) |
| 36 | } |
| 37 | for _, o := range c.Obligations { |
| 38 | if o.Op == p1ID { |
| 39 | t.Fatalf("the record closed while %s %s%s was still owed under the op", o.Kind, o.Region, o.Box) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestBoxClosesAreRecordFirst(t *testing.T) { |
| 45 | t.Parallel() |
| 46 | t.Run("committed close (movesparks.go)", func(t *testing.T) { |
| 47 | h := newCloseHarness(t) |
| 48 | h.land(t) |
| 49 | h.sourcePass(t) |
| 50 | h.clock.Advance(24 * time.Hour) |
| 51 | if err := h.regionalAck(t, h.chore(t, "ie", false)); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | h.landAt = "after-close-record" |
| 55 | h.crashLand(t) |
| 56 | closedButHeld(t, h.read(t, p1Host), h.claim(t), "done", hostclaim.KindLanding, hostclaim.PhaseCommitted) |
| 57 | gen := h.claim(t).Gen |
| 58 | h.freshTarget() |
| 59 | h.land(t) |
| 60 | h.sourcePass(t) |
| 61 | if c := h.claim(t); c.Gen != gen { |
| 62 | t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) |
| 63 | } |
| 64 | }) |
| 65 | t.Run("abort (movesabort.go)", func(t *testing.T) { |
| 66 | h := newAbortHarness(t) |
| 67 | h.abort(t) |
| 68 | h.abAt = "after-abort-record" |
| 69 | for i := 0; i < 6 && !h.abDone; i++ { |
| 70 | movesPass(context.Background(), h.s, h.w()) |
| 71 | h.tPass(t) |
| 72 | if o := h.laneChore(t); o != nil { |
| 73 | h.pastHorizon(t, o) |
| 74 | h.tPass(t) |
| 75 | } |
| 76 | } |
| 77 | if !h.abDone { |
| 78 | t.Fatalf("never reached after-abort-record (%v)", h.abHits) |
| 79 | } |
| 80 | closedButHeld(t, h.read(t, p1Host), h.claim(t), "aborted", hostclaim.KindMoving, hostclaim.PhaseAborting) |
| 81 | h.ownStateGone(t) |
| 82 | gen := h.claim(t).Gen |
| 83 | h.worker1 = nil |
| 84 | h.pass(t) |
| 85 | h.tPass(t) |
| 86 | if c := h.claim(t); c.Gen != gen { |
| 87 | t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) |
| 88 | } |
| 89 | }) |
| 90 | t.Run("recovery close (movesrecover.go)", func(t *testing.T) { |
| 91 | h := newRecoverHarness(t, false) |
| 92 | h.released(t) |
| 93 | h.srcPass(t) |
| 94 | h.opened(t) |
| 95 | h.rcAt = "after-close-record" |
| 96 | h.closeOut(t) |
| 97 | if !h.rcDone { |
| 98 | t.Fatalf("never reached after-close-record (%v)", h.rcHits) |
| 99 | } |
| 100 | closedButHeld(t, h.read(t, p1Host), h.claim(t), "rolled_back", hostclaim.KindRestoring, hostclaim.PhaseOpened) |
| 101 | if n := strings.Count(strings.Join(h.sEvents, " "), "rolled_back"); n != 1 { |
| 102 | t.Fatalf("the terminal event %d times: %v", n, h.sEvents) |
| 103 | } |
| 104 | gen := h.claim(t).Gen |
| 105 | h.sw = nil |
| 106 | h.srcPass(t) |
| 107 | h.rollbackPass(t) |
| 108 | if c := h.claim(t); c.Gen != gen { |
| 109 | t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) |
| 110 | } |
| 111 | }) |
| 112 | t.Run("revert close (movesrevert.go)", func(t *testing.T) { |
| 113 | h := newRevertHarness(t) |
| 114 | h.fromStopped(t) |
| 115 | h.arm(t) |
| 116 | h.pass(t) |
| 117 | h.reopened(t) |
| 118 | h.rvAt = "after-close-record" |
| 119 | o := h.laneChore(t) |
| 120 | h.tPass(t) |
| 121 | h.pastHorizon(t, o) |
| 122 | h.tPass(t) |
| 123 | h.pass(t) |
| 124 | if !h.rvDone { |
| 125 | t.Fatalf("never reached after-close-record (%v)", h.rvHits) |
| 126 | } |
| 127 | closedButHeld(t, h.read(t, p1Host), h.claim(t), "rolled_back", hostclaim.KindReverting, hostclaim.PhaseOpened) |
| 128 | gen := h.claim(t).Gen |
| 129 | h.freshWorker() |
| 130 | h.pass(t) |
| 131 | h.tPass(t) |
| 132 | if c := h.claim(t); c.Gen != gen { |
| 133 | t.Fatalf("a box pass after the record's close wrote the claim (%d to %d): %+v", gen, c.Gen, c.Op) |
| 134 | } |
| 135 | }) |
| 136 | } |
| 137 | |