| 1 | package sibling |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "amadan.net/oficina/suite" |
| 13 | ) |
| 14 | |
| 15 | func askerForTest() suite.Subject { |
| 16 | return suite.Subject{Kind: suite.SubjectAddress, Value: "paul@rslw.com"} |
| 17 | } |
| 18 | |
| 19 | // answerBody writes an S2 answer as it goes on the wire. |
| 20 | // |
| 21 | // The module keeps its response struct unexported — DecodeS2 is the whole |
| 22 | // public surface — so a fixture has to spell the JSON out. That is the |
| 23 | // better fixture anyway: this is the shape a real sibling emits, and a |
| 24 | // field renamed upstream shows up here as a decode failure rather than as |
| 25 | // a struct literal that follows the rename silently. |
| 26 | func answerBody(pairs []suite.ItemRevision, result suite.Authorization) []byte { |
| 27 | rows := make([]map[string]any, 0, len(pairs)) |
| 28 | for _, p := range pairs { |
| 29 | rows = append(rows, map[string]any{ |
| 30 | "item_id": p.ItemID, "revision": p.Revision, "result": string(result), |
| 31 | }) |
| 32 | } |
| 33 | raw, err := json.Marshal(map[string]any{"results": rows}) |
| 34 | if err != nil { |
| 35 | panic(err) |
| 36 | } |
| 37 | return raw |
| 38 | } |
| 39 | |
| 40 | // answering stands up a sibling that pauses, then answers `result` for |
| 41 | // every pair it was asked, counting the calls it received. |
| 42 | func answering(t *testing.T, name string, pause time.Duration, result suite.Authorization, calls *int64) Sibling { |
| 43 | t.Helper() |
| 44 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 45 | atomic.AddInt64(calls, 1) |
| 46 | var req suite.S2Request |
| 47 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 48 | t.Errorf("%s: undecodable S2 request: %v", name, err) |
| 49 | return |
| 50 | } |
| 51 | select { |
| 52 | case <-time.After(pause): |
| 53 | case <-r.Context().Done(): |
| 54 | return |
| 55 | } |
| 56 | _, _ = w.Write(answerBody(req.Pairs, result)) |
| 57 | }) |
| 58 | s.Name = name |
| 59 | return s |
| 60 | } |
| 61 | |
| 62 | // Gate 23: **the deadline and the concurrency, held together**, because |
| 63 | // either alone passes on the other's failure. |
| 64 | // |
| 65 | // §6.5: "One page of results is one round of calls, so the page's floor is |
| 66 | // the slowest sibling, not their sum." A per-call deadline assertion is |
| 67 | // satisfied by three sequential calls of 800 ms each — a page that waits |
| 68 | // 2.4 s, which no per-call test can see. A concurrency assertion alone is |
| 69 | // satisfied by a client that gives up instantly. |
| 70 | // |
| 71 | // **Two fixtures, and the spec is emphatic that one cannot hold both.** |
| 72 | // §10 gate 23 works through why: against the deadline fixture, an overlap |
| 73 | // assertion is either always red or never red depending on whose clock you |
| 74 | // read, so the concurrency half needs its own fixture of comparable |
| 75 | // sub-deadline latencies. |
| 76 | // |
| 77 | // The numbers are the spec's, not this file's: |
| 78 | // |
| 79 | // deadline half 3 siblings — one at 5s, two at 10ms — page under 1.2s |
| 80 | // concurrency half 3 siblings at 300ms each — page under 810ms |
| 81 | // |
| 82 | // 810 ms is 2.7x the concurrent time and 0.9x the sequential one, so it |
| 83 | // separates the two by a wide margin at both ends rather than sitting on |
| 84 | // top of either. An earlier version of this test used 3x400ms/900ms and |
| 85 | // 3s/1.5s, and a review showed what the slack bought: the deadline half |
| 86 | // stayed **green with Deadline widened to 1200ms and to 1400ms**. The |
| 87 | // bound has to be the spec's or the spec is not what is being tested. |
| 88 | // |
| 89 | // **The bounds are literals, not multiples of Deadline.** A test bounded |
| 90 | // against the constant it is testing widens when the constant does, which |
| 91 | // is the failure above in its purest form. §6.5 owes a measurement (§13), |
| 92 | // and the day it arrives this test is where the new number is argued for. |
| 93 | // |
| 94 | // TWO-SIDED throughout: every timing bound is paired with an assertion |
| 95 | // about what came back, so a client that returns instantly with everything |
| 96 | // UNKNOWN fails. |
| 97 | // |
| 98 | // MUTATION-TESTED 2026-09-06, each verified red against BOTH fixtures then |
| 99 | // reverted — per the spec's instruction, since the point of the split is |
| 100 | // that neither fixture sees both faults: |
| 101 | // |
| 102 | // batches run sequentially -> concurrency half red at ~900ms |
| 103 | // the per-call deadline goes -> deadline half red at ~5s |
| 104 | // Deadline widened to 1200ms -> deadline half red (it was green before) |
| 105 | func TestOnePageIsOneRoundOfCalls(t *testing.T) { |
| 106 | t.Run("concurrency: three siblings cost one sibling's time", func(t *testing.T) { |
| 107 | var a, b, c int64 |
| 108 | sheets := answering(t, "Sheets", 300*time.Millisecond, suite.VISIBLE, &a) |
| 109 | docs := answering(t, "Docs", 300*time.Millisecond, suite.VISIBLE, &b) |
| 110 | cal := answering(t, "Calendar", 300*time.Millisecond, suite.VISIBLE, &c) |
| 111 | |
| 112 | cands := []Candidate{ |
| 113 | {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}}, |
| 114 | {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}}, |
| 115 | {cal, suite.ItemRevision{ItemID: "c1", Revision: 1}}, |
| 116 | } |
| 117 | |
| 118 | start := time.Now() |
| 119 | got := newTestClient().Rule(context.Background(), askerForTest(), cands) |
| 120 | elapsed := time.Since(start) |
| 121 | |
| 122 | if elapsed > 810*time.Millisecond { |
| 123 | t.Errorf("three 300ms siblings took %v, over the spec's 810ms: the "+ |
| 124 | "page is paying their sum, not their slowest", elapsed) |
| 125 | } |
| 126 | // The other half. Without it, `return allUnknown` in 0ms passes, |
| 127 | // and so does every upper bound in this file. |
| 128 | for i, d := range got { |
| 129 | if !d.Renders() { |
| 130 | t.Errorf("candidate %d = %v, want Render — a page that answers "+ |
| 131 | "fast by answering nothing is not a fast page", i, d) |
| 132 | } |
| 133 | } |
| 134 | }) |
| 135 | |
| 136 | t.Run("deadline: one sibling at 5s costs the page its deadline", func(t *testing.T) { |
| 137 | var slow, f1, f2 int64 |
| 138 | sheets := answering(t, "Sheets", 5*time.Second, suite.VISIBLE, &slow) |
| 139 | docs := answering(t, "Docs", 10*time.Millisecond, suite.VISIBLE, &f1) |
| 140 | cal := answering(t, "Calendar", 10*time.Millisecond, suite.VISIBLE, &f2) |
| 141 | |
| 142 | cands := []Candidate{ |
| 143 | {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}}, |
| 144 | {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}}, |
| 145 | {cal, suite.ItemRevision{ItemID: "c1", Revision: 1}}, |
| 146 | } |
| 147 | |
| 148 | start := time.Now() |
| 149 | got := newTestClient().Rule(context.Background(), askerForTest(), cands) |
| 150 | elapsed := time.Since(start) |
| 151 | |
| 152 | if elapsed > 1200*time.Millisecond { |
| 153 | t.Errorf("a sibling sleeping 5s cost the page %v, over the spec's "+ |
| 154 | "1.2s: the deadline is not applied, or not per call", elapsed) |
| 155 | } |
| 156 | if got[0].Renders() { |
| 157 | t.Error("the passage from the sibling that never answered rendered") |
| 158 | } |
| 159 | if !got[0].Notices() { |
| 160 | t.Error("a sibling past its deadline was silent: §6.5 makes that " + |
| 161 | "UNKNOWN, which withholds loudly") |
| 162 | } |
| 163 | // Partial pages are the normal case, not an error path. |
| 164 | for _, i := range []int{1, 2} { |
| 165 | if !got[i].Renders() { |
| 166 | t.Errorf("candidate %d was withheld because a DIFFERENT sibling "+ |
| 167 | "was slow", i) |
| 168 | } |
| 169 | } |
| 170 | }) |
| 171 | |
| 172 | t.Run("a sibling inside the deadline is waited for", func(t *testing.T) { |
| 173 | // Without this, a Rule that never waits passes both bounds above: |
| 174 | // it answers in 0ms and every timing assertion is an upper bound. |
| 175 | // This one is a lower bound on the answer rather than on time. |
| 176 | var n int64 |
| 177 | sheets := answering(t, "Sheets", 300*time.Millisecond, suite.VISIBLE, &n) |
| 178 | got := newTestClient().Rule(context.Background(), askerForTest(), |
| 179 | []Candidate{{sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}}}) |
| 180 | if !got[0].Renders() { |
| 181 | t.Error("a sibling answering in 300ms, well inside an 800ms " + |
| 182 | "deadline, was treated as no answer") |
| 183 | } |
| 184 | if atomic.LoadInt64(&n) != 1 { |
| 185 | t.Errorf("the sibling received %d calls, want 1", n) |
| 186 | } |
| 187 | }) |
| 188 | } |
| 189 | |
| 190 | // **Two revisions of one item are two questions.** |
| 191 | // |
| 192 | // The dedupe key is the whole `(item_id, revision)` pair and not the id, |
| 193 | // and nothing proved it: a review keyed the dedupe on ItemID alone and it |
| 194 | // stayed green, because no fixture held two revisions of one document. |
| 195 | // |
| 196 | // What that mutation does in production is the leak §6.1 says STALE exists |
| 197 | // to prevent. memoria holds revision 4 and revision 5 of the same |
| 198 | // document; the sibling answers STALE for 4 and VISIBLE for 5; keyed on |
| 199 | // the id, whichever pair is later in the batch wins for both, and the |
| 200 | // withdrawn revision-4 passage renders on revision 5's permission — |
| 201 | // retraction-by-edit, undone. |
| 202 | // |
| 203 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 204 | // |
| 205 | // dedupe on c.Item.ItemID |
| 206 | // -> one pair on the wire; red |
| 207 | // scatter applies an answer to every position sharing an ItemID |
| 208 | // -> rev 4 renders; red |
| 209 | func TestTwoRevisionsOfOneItemAreTwoQuestions(t *testing.T) { |
| 210 | var asked []suite.ItemRevision |
| 211 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 212 | var req suite.S2Request |
| 213 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 214 | t.Errorf("undecodable S2 request: %v", err) |
| 215 | return |
| 216 | } |
| 217 | rows := make([]map[string]any, 0, len(req.Pairs)) |
| 218 | for _, p := range req.Pairs { |
| 219 | asked = append(asked, p) |
| 220 | // The sibling has moved past revision 4 and stands behind 5. |
| 221 | result := string(suite.VISIBLE) |
| 222 | if p.Revision == 4 { |
| 223 | result = string(suite.STALE) |
| 224 | } |
| 225 | rows = append(rows, map[string]any{ |
| 226 | "item_id": p.ItemID, "revision": p.Revision, "result": result, |
| 227 | }) |
| 228 | } |
| 229 | raw, err := json.Marshal(map[string]any{"results": rows}) |
| 230 | if err != nil { |
| 231 | t.Fatalf("marshal: %v", err) |
| 232 | } |
| 233 | _, _ = w.Write(raw) |
| 234 | }) |
| 235 | s.Name = "Sheets" |
| 236 | |
| 237 | got := newTestClient().Rule(context.Background(), askerForTest(), []Candidate{ |
| 238 | {s, suite.ItemRevision{ItemID: "doc-a", Revision: 4}}, |
| 239 | {s, suite.ItemRevision{ItemID: "doc-a", Revision: 5}}, |
| 240 | }) |
| 241 | |
| 242 | if len(asked) != 2 { |
| 243 | t.Fatalf("%d pairs went on the wire for two revisions of one item, "+ |
| 244 | "want 2 — one question cannot carry two answers", len(asked)) |
| 245 | } |
| 246 | if got[0] != WithholdAndRefetch { |
| 247 | t.Errorf("revision 4 = %v, want WithholdAndRefetch: a passage the "+ |
| 248 | "sibling has moved past must not render on the permission it "+ |
| 249 | "gave a later revision", got[0]) |
| 250 | } |
| 251 | if got[1] != Render { |
| 252 | t.Errorf("revision 5 = %v, want Render", got[1]) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // **A document's ten chunks ask about it once.** |
| 257 | // |
| 258 | // Every chunk of one document carries the same `(item_id, revision)`, so |
| 259 | // the obvious loop asks a ten-chunk document ten times. That is not merely |
| 260 | // wasteful: the module maps a duplicated row to UNKNOWN, so a sibling |
| 261 | // answering a pair once for a question that named it ten times partly |
| 262 | // withholds its own correct answer — a page that goes quiet for a document |
| 263 | // the reader is fully entitled to, on the most ordinary shape there is. |
| 264 | // |
| 265 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 266 | // |
| 267 | // skip the dedupe (append every candidate's pair) |
| 268 | // -> the wire assertion goes red at 10 pairs, and the batch the |
| 269 | // sibling answers honestly comes back UNKNOWN for nine of them |
| 270 | // map answers back to the first index only |
| 271 | // -> nine candidates left at the zero Disposition; red, and it is |
| 272 | // worth noting they fail *closed* — the zero value doing its job |
| 273 | func TestOneDocumentIsAskedAboutOnce(t *testing.T) { |
| 274 | var pairsSeen int64 |
| 275 | var calls int64 |
| 276 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 277 | atomic.AddInt64(&calls, 1) |
| 278 | var req suite.S2Request |
| 279 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 280 | t.Errorf("undecodable S2 request: %v", err) |
| 281 | return |
| 282 | } |
| 283 | atomic.AddInt64(&pairsSeen, int64(len(req.Pairs))) |
| 284 | _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE)) |
| 285 | }) |
| 286 | s.Name = "Sheets" |
| 287 | |
| 288 | var cands []Candidate |
| 289 | for i := 0; i < 10; i++ { |
| 290 | cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-a", Revision: 4}}) |
| 291 | } |
| 292 | // One other document, so "send exactly one pair always" does not pass. |
| 293 | cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-b", Revision: 1}}) |
| 294 | |
| 295 | got := newTestClient().Rule(context.Background(), askerForTest(), cands) |
| 296 | |
| 297 | if pairsSeen != 2 { |
| 298 | t.Errorf("%d pairs went on the wire for 11 chunks of 2 documents, want 2 — "+ |
| 299 | "a duplicated row decodes to UNKNOWN, so asking twice withholds "+ |
| 300 | "a passage the reader is entitled to", pairsSeen) |
| 301 | } |
| 302 | if calls != 1 { |
| 303 | t.Errorf("%d calls, want 1", calls) |
| 304 | } |
| 305 | if len(got) != 11 { |
| 306 | t.Fatalf("%d dispositions for 11 candidates", len(got)) |
| 307 | } |
| 308 | for i, d := range got { |
| 309 | if !d.Renders() { |
| 310 | t.Errorf("candidate %d = %v, want Render: one answer must reach "+ |
| 311 | "every chunk of the document it was about", i, d) |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // A sibling with more candidates than one batch allows gets several calls, |
| 317 | // concurrently, and every pair still gets its own answer. |
| 318 | // |
| 319 | // Gate 19's mixed-provenance artefact is the case that spans batches, and |
| 320 | // `Authorize` refuses an oversized batch outright rather than truncating |
| 321 | // it — so without splitting here, a page over the cap withholds everything |
| 322 | // from that sibling and blames the sibling. |
| 323 | // |
| 324 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 325 | // |
| 326 | // pass the whole slice as one batch |
| 327 | // -> CheckS2Batch refuses it, everything UNKNOWN; red |
| 328 | // batches() drops the final short batch |
| 329 | // -> the tail candidates sit at the zero Disposition; red |
| 330 | func TestASiblingOverTheBatchCapIsSplitNotTruncated(t *testing.T) { |
| 331 | var calls int64 |
| 332 | var seen int64 |
| 333 | s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| 334 | atomic.AddInt64(&calls, 1) |
| 335 | var req suite.S2Request |
| 336 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 337 | t.Errorf("undecodable S2 request: %v", err) |
| 338 | return |
| 339 | } |
| 340 | if len(req.Pairs) > suite.MaxS2Pairs { |
| 341 | t.Errorf("a batch of %d exceeds the cap of %d", len(req.Pairs), suite.MaxS2Pairs) |
| 342 | } |
| 343 | atomic.AddInt64(&seen, int64(len(req.Pairs))) |
| 344 | // Each batch is slow enough that three of them in a row would be |
| 345 | // visible against the bound below, and fast enough that three at |
| 346 | // once are not. |
| 347 | select { |
| 348 | case <-time.After(300 * time.Millisecond): |
| 349 | case <-r.Context().Done(): |
| 350 | return |
| 351 | } |
| 352 | _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE)) |
| 353 | }) |
| 354 | s.Name = "Sheets" |
| 355 | |
| 356 | const n = suite.MaxS2Pairs*2 + 7 // two full batches and a short one |
| 357 | cands := make([]Candidate, n) |
| 358 | for i := range cands { |
| 359 | cands[i] = Candidate{s, suite.ItemRevision{ |
| 360 | ItemID: fmt.Sprintf("doc-%d", i), Revision: int64(i), |
| 361 | }} |
| 362 | } |
| 363 | |
| 364 | start := time.Now() |
| 365 | got := newTestClient().Rule(context.Background(), askerForTest(), cands) |
| 366 | elapsed := time.Since(start) |
| 367 | |
| 368 | if calls != 3 { |
| 369 | t.Errorf("%d calls for %d pairs at a cap of %d, want 3", calls, n, suite.MaxS2Pairs) |
| 370 | } |
| 371 | // **The batches are concurrent, and this is what says so.** The claim |
| 372 | // was in page.go's comment with nothing holding it: a review made one |
| 373 | // goroutine per sibling with its batches sequential inside, and every |
| 374 | // assertion here stayed green because there was no timing at all. The |
| 375 | // bound is the spec's concurrency number for the same reason gate 23 |
| 376 | // uses it — three 300ms calls concurrently is ~300ms, sequentially |
| 377 | // ~900ms, and 810ms separates them at both ends. |
| 378 | if elapsed > 810*time.Millisecond { |
| 379 | t.Errorf("three 300ms batches took %v: a page whose cost grows with "+ |
| 380 | "the number of batches grows with the corpus", elapsed) |
| 381 | } |
| 382 | if seen != n { |
| 383 | t.Errorf("%d pairs reached the sibling, want %d: a batch was truncated, "+ |
| 384 | "and the pairs that fell off the end withhold while looking like "+ |
| 385 | "the sibling's fault", seen, n) |
| 386 | } |
| 387 | for i, d := range got { |
| 388 | if !d.Renders() { |
| 389 | t.Fatalf("candidate %d of %d = %v, want Render", i, n, d) |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | // Totality at the page boundary: no candidate ever goes unanswered, and |
| 395 | // nothing about an empty page is special-cased into a nil the caller then |
| 396 | // ranges over expecting content. |
| 397 | func TestRuleAnswersEveryCandidate(t *testing.T) { |
| 398 | if got := newTestClient().Rule(context.Background(), askerForTest(), nil); len(got) != 0 { |
| 399 | t.Errorf("an empty page produced %d dispositions", len(got)) |
| 400 | } |
| 401 | |
| 402 | t.Run("an unreachable sibling still answers for every candidate", func(t *testing.T) { |
| 403 | dead := Sibling{Name: "Sheets", BaseURL: "http://127.0.0.1:1", Token: "t"} |
| 404 | cands := []Candidate{ |
| 405 | {dead, suite.ItemRevision{ItemID: "a", Revision: 1}}, |
| 406 | {dead, suite.ItemRevision{ItemID: "b", Revision: 1}}, |
| 407 | } |
| 408 | got := newTestClient().Rule(context.Background(), askerForTest(), cands) |
| 409 | if len(got) != 2 { |
| 410 | t.Fatalf("%d dispositions for 2 candidates", len(got)) |
| 411 | } |
| 412 | for i, d := range got { |
| 413 | if d.Renders() || !d.Notices() { |
| 414 | t.Errorf("candidate %d from an unreachable sibling = %v, want "+ |
| 415 | "withheld loudly", i, d) |
| 416 | } |
| 417 | } |
| 418 | }) |
| 419 | } |
| 420 | |
| 421 | // The short-answer arm of `scatter`, which nothing on the wire can reach. |
| 422 | // |
| 423 | // `Authorize` returns exactly one Authorization per pair asked, always, so |
| 424 | // no fixture makes its result short — which is why the fallback was a guard |
| 425 | // that could not fail. A mutation proved it: changing the missing-answer |
| 426 | // value from UNKNOWN to VISIBLE left every test in this package green. |
| 427 | // |
| 428 | // So the mapping is a function and this calls it directly. The property is |
| 429 | // the one §4.1 keeps repeating: **a missing answer is not a permissive one, |
| 430 | // and it is never the silent one.** |
| 431 | // |
| 432 | // MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| 433 | // |
| 434 | // the fallback becomes suite.VISIBLE -> red (this is the one that |
| 435 | // survived before the refactor) |
| 436 | // the fallback becomes suite.NOT_VISIBLE -> red on Notices() |
| 437 | // scatter indexes answers[j] blindly -> panic, red |
| 438 | func TestAShortAnswerSliceWithholds(t *testing.T) { |
| 439 | batch := []suite.ItemRevision{ |
| 440 | {ItemID: "a", Revision: 1}, |
| 441 | {ItemID: "b", Revision: 1}, |
| 442 | {ItemID: "c", Revision: 1}, |
| 443 | } |
| 444 | positions := map[suite.ItemRevision][]int{ |
| 445 | batch[0]: {0}, |
| 446 | batch[1]: {1, 3}, // two chunks of the same document |
| 447 | batch[2]: {2}, |
| 448 | } |
| 449 | out := make([]Disposition, 4) |
| 450 | |
| 451 | // One answer for three pairs: the shape a contract change would produce. |
| 452 | scatter(batch, []suite.Authorization{suite.VISIBLE}, positions, out) |
| 453 | |
| 454 | if !out[0].Renders() { |
| 455 | t.Error("the answer that did arrive was not applied, so this test " + |
| 456 | "would pass for a scatter that withholds unconditionally") |
| 457 | } |
| 458 | for _, i := range []int{1, 2, 3} { |
| 459 | if out[i].Renders() { |
| 460 | t.Errorf("candidate %d rendered on an answer that never came", i) |
| 461 | } |
| 462 | if !out[i].Notices() { |
| 463 | t.Errorf("candidate %d went silent on an answer that never came: a "+ |
| 464 | "page cannot quietly shorten itself because a slice was short", i) |
| 465 | } |
| 466 | } |
| 467 | } |
| 468 | |