Repository navigation
net: IBD leaves a header chain whose block fails consensus - #1112
Conversation
There was a problem hiding this comment.
Summary
The retire path, block.invalid durability, and the time-too-new requeue match the issue: a consensus failure cuts the download path back to the parent, reconsiderblock drops the sidecar, operator invalidation is not written, and a torn destination file is unlikely because the sidecar is a synced temp plus rename. The new peer-chain filter does not stay true for the peer IBD just switched to, and it does not keep a chain longer than one headers reply. Both mistakes send block getdata to a peer whose announced chain does not have the block, which is the stall this PR is fixing.
Issue counts by severity
- bugs: 2
- suggestions: 1
- nits: 0
Review on reardencode#1112: - A recorded split whose height had been confirmed and pruned from the path still counted, so the peer whose chain IBD had just taken was no longer asked for its blocks. A missing path slot no longer counts by itself: before block assignment the split is compared with this node's confirmed block there; the same block clears it, a different one keeps it. - The announced run kept the oldest reply: a continuation was cut off at one reply, the next full reply landed in a gap and was dropped, and a later short reply still set `complete`, so the peer looked finished at a stale top. The newest run is kept now, and `complete` describes it. - With no peer that holds a block by what it announced, the fallback skipped the header peer while another peer was up. It no longer does. - `invalid_tips` starts again at its cap, so the newest refused tip of a long invalid chain is kept, and a full `block.invalid` drops its oldest hash so the newest failure survives restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t does Second review pass on reardencode#1112 found three ways a peer with no record either way for a block could still exclude the reserved header peer that announced it: an announced window that no longer reaches that height after the candidate changed, a split cleared before a later rewind, and a reply that never recorded where the peer's chain ends. Each let a silent peer take the block while the peer that has it waited. Once the header peer's announced chain shows a block (that block, or a path block above it), only another peer whose announced chain shows it keeps the header peer from it. While the header peer has shown nothing, it still stays on headers when another peer may hold the block. A peer with no record may still be asked, as before this branch. `invalid_tips` now drops its oldest tip at the cap instead of starting over, so a branch refused after the oldest tips is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c8297b2 to
4c6a843
Compare
|
@reardencode thanks for the review. All three threads are fixed and resolved:
Rebased onto current master (a89303e). Locally green: A few notes beyond the three threads:
|
There was a problem hiding this comment.
Summary
The three threads from the last review hold on 4c6a8435: a split whose height has left the path is settled against the confirmed header, the retained headers run is the newest one, and both caps drop the oldest entry. Time-too-new stays a requeue, and an operator invalidateblock is not what remember_failed_accept writes. Two holes are still open. Either one brings back the stall in #1082.
Issue counts by severity
- bugs: 2
- suggestions: 0
- nits: 0
Review on reardencode#1112: - A recorded split whose height had been confirmed and pruned from the path still counted, so the peer whose chain IBD had just taken was no longer asked for its blocks. A missing path slot no longer counts by itself: before block assignment the split is compared with this node's confirmed block there; the same block clears it, a different one keeps it. - The announced run kept the oldest reply: a continuation was cut off at one reply, the next full reply landed in a gap and was dropped, and a later short reply still set `complete`, so the peer looked finished at a stale top. The newest run is kept now, and `complete` describes it. - With no peer that holds a block by what it announced, the fallback skipped the header peer while another peer was up. It no longer does. - `invalid_tips` starts again at its cap, so the newest refused tip of a long invalid chain is kept, and a full `block.invalid` drops its oldest hash so the newest failure survives restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t does Second review pass on reardencode#1112 found three ways a peer with no record either way for a block could still exclude the reserved header peer that announced it: an announced window that no longer reaches that height after the candidate changed, a split cleared before a later rewind, and a reply that never recorded where the peer's chain ends. Each let a silent peer take the block while the peer that has it waited. Once the header peer's announced chain shows a block (that block, or a path block above it), only another peer whose announced chain shows it keeps the header peer from it. While the header peer has shown nothing, it still stays on headers when another peer may hold the block. A peer with no record may still be asked, as before this branch. `invalid_tips` now drops its oldest tip at the cap instead of starting over, so a branch refused after the oldest tips is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on reardencode#1112: - `shows` could be true where `left_path_by` was also true: a peer that announced the path above the tip and then sent a batch forking at or below the tip kept its old run. If the header peer also showed the block, that stale `shows` kept the header peer off it while the stale peer was not asked either, and no getdata went out. `shows` is now false wherever the peer left the path, and a reply that moves the split without landing above the tip drops the kept run. - The retire saved `header.adopt` before `block.invalid` was written. A crash or a failed write in between left the retired walk on disk with no mark, so the next start chased the invalid chain again. The failure is written first now; `restore_adopt` already refuses a saved candidate above the tip while the file lists a block. If the write fails, the retire still holds in this process and `header.adopt` is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…expire Third review pass on reardencode#1112: - When `block.invalid` could not be written, the retire still saved `header.adopt` before removing it, and a later walk save could bring it back. A failure that was not on disk was also reported as kept the second time and never written again. The hub now tracks a pending mark: every later mark (and a reconsider prune) tries the write again, and `save_adopt` saves no walk, removing an existing one, until the mark is on disk. The removal syncs the directory. - A `notfound` kept a peer off a block until it announced again, so a block every peer had answered `notfound` was never asked for again. It now lasts one stall window (30 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4c6a843 to
e2914ba
Compare
|
@reardencode both new threads are fixed and resolved:
Rebased onto current master (6740431, after #1115). Locally green: Notes:
|
There was a problem hiding this comment.
Summary
The current head does retire an IBD candidate whose block fails consensus, writes that failure before header.adopt, and keeps peer shows / left_path_by, the announced window, and the invalid-hash caps consistent with the last review. Those five earlier defects are fixed in the code, not only in the replies. What remains is that a headers answer which does not extend above the confirmed tip is stored as "no chain," so that peer is still asked for later path blocks and can be stall-disconnected — the #1082 failure mode for the common empty reply.
Issue counts by severity
- bugs: 2
- suggestions: 0
- nits: 0
|
Two independent reviews. needs changing before merge
worth doing, does not block
nits
|
IBD never sent block getdata to the reserved header peer while another peer was connected, and a peer that answered `notfound` was asked again for the same hash. When only the header peer had a heavier chain, every getdata went to a peer that kept answering `notfound`, and IBD stalled. Each IBD slot now remembers the hashes it answered `notfound` until it announces more (a headers message or a block inv). A peer is not asked for such a hash, and the reserved header peer is asked when no other live peer may have the block. One `may_serve` check in `issue_batch` replaces the blanket reserved-peer exclusion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the most-work header chain held a consensus-invalid block, IBD rejected the block but kept that chain as the walk candidate and the download path. A lighter valid chain another peer served was never fetched, and IBD stayed below the invalid block (issue reardencode#1082). On a consensus-invalid body the walk now drops the path above the block's parent (queue, body queue, confirm plan, densify cursor) and moves the candidate back to that parent. A batch that holds or builds on a rejected block is refused and its peer leaves the walk, so peers are asked again from the parent and a heavier valid chain is adopted, rewinding confirmed blocks when it forks below them. A challenger that forks at or below the parent with more work is promoted. Each IBD slot also remembers where its announced headers left the download path, so a peer is not asked for path blocks above that point and the header peer takes a block only it has. `adopt_heavier` and the retire share `cut_back_to`. The live journey `end_of_ibd_invalid_heavier_fork` stages the split with three miners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Invalid marks were in memory only. After a restart the node forgot that a stored header chain held a consensus-invalid block: the startup scan saw that chain as more work, and the IBD resume seed planted it again, rewinding a valid tip to chase it (issue reardencode#1082). The hub's invalid set stays the one owner. A block that fails connect on a consensus rule (IBD reject, or tip-follow's failed accept) is also written to the `store/block.invalid` sidecar, at most 4096 hashes, and `reconsiderblock` removes it. The hub loads the file at open, IBD seeds its reject set from the hub, so the resume seed and the header walk refuse that chain, and the startup heavier-chain scan excludes those hashes. Mutated bodies, store faults, and operator `invalidateblock` are not written. This is a schema addition without a version bump: a missing or unparseable file is an empty set (SCHEMA.md). IBD also stops treating a header timestamp too far in the future as consensus-invalid. It is requeued, as in tip-follow and Core (BLOCK_TIME_FUTURE), so it is never persisted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A peer that had announced nothing was asked for blocks of any chain. A Core peer serving a lighter chain ignores getdata for blocks it does not have, so it went quiet, was dropped, and cooled down for 10 minutes before the node needed its chain (issue reardencode#1082). Each block peer that has not announced a chain now gets one headers ask, through the block-inv challenge (one peer at a time, never the reserved header peer). Each slot keeps the hashes of its announced chain above the confirmed tip, at most one headers reply. Two chains that differ at one height differ above it, so one comparison at the lower of the asked height and the peer's top tells whether the peer holds a path block. A lighter fork in the reply also becomes the walk's challenger. The retire no longer promotes a challenger. Its headers are not stored and its fork can sit below the confirmed tip; peers are asked again from the parent instead, and a heavier chain comes back as stored headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of this branch found these, each pinned before the fix: - A header batch can be written outside the lock while one of its blocks fails. `on_headers_batch` now refuses a batch that holds or builds on a rejected block at commit too, so a retire is not undone. - `retire_invalid` left no checkpoint at the parent, so `header.adopt` restored an older tip or none. It now records one. - The failure mark was written before the retire saved the walk. The retire runs first, and `restore_adopt` does not bring back a saved candidate above the confirmed tip while invalid blocks are stored. - A peer whose reply was shorter than a full batch has nothing past its top and is not asked above it. Where its chain split from this node's (path or confirmed) is kept when that height leaves the path. When no peer holds a block by what it announced, any peer that did not answer `notfound` may be asked, so a stale record cannot stall a download. - A block refused because it was already marked invalid (an operator `invalidateblock`) is not written to `block.invalid`. - `block.invalid` is written through the store's synced temp-file helper (sync, rename, directory sync). Durability is not observable in-process; the on-disk pins still cover the bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on reardencode#1112: - A recorded split whose height had been confirmed and pruned from the path still counted, so the peer whose chain IBD had just taken was no longer asked for its blocks. A missing path slot no longer counts by itself: before block assignment the split is compared with this node's confirmed block there; the same block clears it, a different one keeps it. - The announced run kept the oldest reply: a continuation was cut off at one reply, the next full reply landed in a gap and was dropped, and a later short reply still set `complete`, so the peer looked finished at a stale top. The newest run is kept now, and `complete` describes it. - With no peer that holds a block by what it announced, the fallback skipped the header peer while another peer was up. It no longer does. - `invalid_tips` starts again at its cap, so the newest refused tip of a long invalid chain is kept, and a full `block.invalid` drops its oldest hash so the newest failure survives restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t does Second review pass on reardencode#1112 found three ways a peer with no record either way for a block could still exclude the reserved header peer that announced it: an announced window that no longer reaches that height after the candidate changed, a split cleared before a later rewind, and a reply that never recorded where the peer's chain ends. Each let a silent peer take the block while the peer that has it waited. Once the header peer's announced chain shows a block (that block, or a path block above it), only another peer whose announced chain shows it keeps the header peer from it. While the header peer has shown nothing, it still stays on headers when another peer may hold the block. A peer with no record may still be asked, as before this branch. `invalid_tips` now drops its oldest tip at the cap instead of starting over, so a branch refused after the oldest tips is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on reardencode#1112: - `shows` could be true where `left_path_by` was also true: a peer that announced the path above the tip and then sent a batch forking at or below the tip kept its old run. If the header peer also showed the block, that stale `shows` kept the header peer off it while the stale peer was not asked either, and no getdata went out. `shows` is now false wherever the peer left the path, and a reply that moves the split without landing above the tip drops the kept run. - The retire saved `header.adopt` before `block.invalid` was written. A crash or a failed write in between left the retired walk on disk with no mark, so the next start chased the invalid chain again. The failure is written first now; `restore_adopt` already refuses a saved candidate above the tip while the file lists a block. If the write fails, the retire still holds in this process and `header.adopt` is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…expire Third review pass on reardencode#1112: - When `block.invalid` could not be written, the retire still saved `header.adopt` before removing it, and a later walk save could bring it back. A failure that was not on disk was also reported as kept the second time and never written again. The hub now tracks a pending mark: every later mark (and a reconsider prune) tries the write again, and `save_adopt` saves no walk, removing an existing one, until the mark is on disk. The removal syncs the directory. - A `notfound` kept a peer off a block until it announced again, so a block every peer had answered `notfound` was never asked for again. It now lasts one stall window (30 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w it Maintainer review: an empty headers reply, or one that ends at or below the confirmed tip, left the peer looking as if it had announced nothing, so it was still asked for path blocks and could be stall-disconnected. - A short reply's top is kept as where the peer's chain ends, also when it is at or below the tip and the kept run is gone. `left_path_by` reads that end with an empty run. - The probe asks from the confirmed chain only. An empty reply then means nothing above the tip, and that peer is not asked for path blocks. A walk ask carries the path top first, so an empty reply to it is a caught-up peer as well as one behind. That peer is probed again rather than marked. - The probe reply goes into the peer's chain record, not the walk. One that shows a header the walk has not seen gives that peer the walk's own ask. A batch that crosses the probe leaves its ask open, and a peer with a walk ask out is not probed. - A probe needs a second live peer. A record only chooses among peers; with one, the probe would take the header lane and its reply would not reach the walk, so the walk would not continue a full fork window. Running the reardencode#1082 repro against Core found two more: - After a retire, a heavier chain that forked below the confirmed tip could become the walk's candidate with nothing planted above the tip. IBD neither rewound nor exited. The main-loop rewind now walks back from that candidate. - A rewind replaced the confirmed blocks a peer's record took as its chain, so a peer on the old chain was asked for the new one. A peer whose record starts past the fork splits there at the rewind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it showed A second review of the probe and rewind change found three gaps, each pinned before the fix: - With nothing left for the walk to ask, the lane was released under an open probe, so it never expired and no other peer was probed. An open probe now keeps the lane, and the header poll asks once its window passes, also with header sync otherwise done, so it can expire. - A peer with an expired walk ask that the lane no longer tracked could be probed, and its late walk reply read as the probe's. A peer with an unanswered expired ask is not probed. - At a rewind, a peer whose record started past the fork was split onto the old chain even when its announced headers were on the new one. The split is now inferred after the new chain is planted, and not for a peer that announced a header on it. A split the peer itself showed at or below the fork is kept, so a peer on a third chain stays off both chains across a rewind and back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: `refuse_invalid_batch` ran before any linkage or proof-of-work check and remembered the last header of every batch it refused. One `headers` message with a header naming a rejected block, followed by an honest header, put that honest header in `invalid_tips`. After that, every batch holding it was refused and its peer left the walk, so in the reardencode#1082 case IBD stayed at the fork as before this branch. A batch is refused, and its last header remembered, only when it is one linked chain that meets its own proof of work. An unlinked batch goes to the usual checks, which drop it. A linked batch that builds on or holds a rejected block cannot end in an honest header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: a recorded split was cleared only by a later reply holding a header at that exact height. A reply that builds on this node's block above the split proves the peer has this chain there, but the split stayed, and once that height was confirmed the peer counted as having left for good. A one-block race near the end of IBD was enough. - A reply whose first header's parent is this node's block clears a split at or below that block. - The kept run is continued only from the parent of the reply's first new header. A reply at the next height that built on another block was appended to the run, so the peer still looked as if it held the stale sibling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: `restore_adopt` refused a saved candidate above the tip whenever `block.invalid` listed any block, also one far below the confirmed tip that cannot be in a candidate whose base matches the confirmed chain. Once one failure was stored, every later restart during IBD skipped the resume until `reconsiderblock`. The resume is refused only when a listed block is above the saved base, or when its height is not found within 64 headers of the best chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e2914ba to
e241cd2
Compare
|
@coreyphillips thanks, every point checked out. Each fix has a test that fails without it. Needs changing
Worth doing
Nits
Rerun of the #1082 repro against Core 31.1 on the new head: bad block at 111 → 160, at 150 → rewinds 149 → 110 inside IBD and reaches 160, at 181 → 180. Restarts stay at 160 and no peer is stall-kicked. |
|
@reardencode both threads from your 14:27Z review are fixed and resolved. coreyphillips' review is answered in the comment below. The branch is rebased onto current master (b0c4c6a) and force-pushed. New since e2914ba:
Notes:
|
Review: this branch made a "timestamp too far in future" reject a requeue, as in tip-follow and Core. But the confirm lookup selects the body again at once, and `apply_cascade_reject` halts IBD after three rejects at the same tip. A stored header that a backward clock correction left more than 2 hours ahead halted IBD within milliseconds. - Such a reject puts a 1 s hold on the confirm feed, and the lookup selects nothing while it holds, so the block is tried about once a second instead of in a tight loop. - It does not count toward the cascade halt. The block connects once the clock catches up. Header acceptance already refuses such a header, so only a clock stepped back can leave one stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: a block peer was eligible for any block it had not ruled out. A peer with no record counted as holding every block, and one whose full probe reply ended below a block stayed eligible above it, so an honest Core peer could still be asked for a fork's blocks, stay silent, and take the 10 minute cooldown in reardencode#1082. The issue's direction is Core's rule: ask a peer only for a block on the chain it announced. - `may_serve` asks a peer only for a block its record shows. With no peer that shows the block, a peer whose chain has not left the path is asked, else any peer outside its `notfound` window, so a stale record cannot stop a download. The header peer stays on headers while another peer can fetch the block, except in that last case. - A probe asks for headers after the download path's top alone. A reply on that top shows every path block below it, up to the 64,000-header queue, so one probe covers a caught-up peer. An empty reply (also a peer below the minimum work), or one from genesis that stops at or below the tip, is asked again from the confirmed tip. - A recorded split whose hash is now on the path shows that block too. - A peer whose record no longer decides the next 4,096 heights is probed again, at most every 10 s. docs/ibd-memory.md lists the per-peer record and `invalid_tips`, and says how block peers are chosen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of d00f2c50: at a rewind, a record that took the rewound blocks as the peer's chain was given an inferred split at the fork, in place of a split the peer had shown. A peer on a third chain that left this chain at 1999, then a rewind to 1997 and one back, looked eligible for blocks above 1999 it did not hold. Further review found that any inferred block can be lost or contradicted across later rewinds and replies. A rewind no longer infers a block. Such a record is stale from the fork up until the peer answers again: the peer is not taken to hold a block there, and it is probed at once. Only a reply that reaches the fork clears it; a full reply that ends below the fork says nothing there, so the peer is asked again. A later rewind keeps the lower fork. The peer's own split is kept as it was. A record whose split is at or below the fork, or whose announced headers are on the new chain, is not stale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e241cd2 to
7be776a
Compare
There was a problem hiding this comment.
Summary
On a consensus-invalid body during IBD, this head retires that header and every header that builds on it, keeps the connected prefix only when it still has more work, and otherwise replants onto the honest challenger and downloads that chain. Block requests go to a peer whose record shows the hash; while the announcer of the heavier chain still shows those blocks, the honest peer is not asked for them and is not stall-cooled. A real connect failure is written to store/block.invalid before header.adopt is saved, so a restart with only the honest peer does not rewind a valid tip onto the stored invalid chain. Mutated bodies, time-too-new, store and engine faults, and operator invalidateblock stay out of that file, and reconsiderblock clears it. The author's fixes for an empty headers reply and for a short reply at or below the tip match the current may_serve, probe, and ends code and the tests that pin them. The last-resort ask of any peer, and an exact multiple of 2000 headers never recording ends, remain, and neither stalls the honest peer on the issue's two-peer timeline while the announcer still shows the blocks.
Issue counts by severity
- bugs: 0
- suggestions: 0
- nits: 0
Closes #1082.
During IBD, when the most-work header chain held a consensus-invalid block, rbitcoin rejected the block but kept that chain as its candidate and download path. It never fetched a lighter valid chain from another peer, asked the honest peer for the bad chain's blocks (a Core peer stays silent and was dropped for a 10 minute cooldown), and after a restart forgot the block was invalid and rewound a valid tip to chase the bad chain again.
This follows the direction in the issue: retire the failed candidate, ask peers only for blocks on a chain they announced, and keep the invalid mark across restart.
Repro (regtest, same setup as the issue: 110 shared blocks, honest V to 160, heavier I to 190 with one coinbase-excess block)
No stall-kick of the honest Core peer in the after runs. (Core 31.1 syncs headers from one peer during IBD; in some runs it picked the split peer and never saw V, ending at the split chain's valid prefix. The Core column is from runs where it did see V.)
Commits
Written against the pre-#1097 layout, then ported onto the
rbitcoin-ibdcrate split and rebased on current master.notfound(until it announces more). Onemay_servecheck inissue_batchreplaces the blanket "reserved header peer gets no blocks" rule: the header peer is asked when no other live peer may have the block. Before this, a heavier chain only the header peer had stalled IBD against rbitcoin seeders, which answernotfound.retire_invaliddrops the download path above the bad block's parent (queue, body queue, confirm plan, densify cursor) and cuts the walk candidate back to that parent (cut_back_to, shared withadopt_heavier). A header batch that holds or builds on a rejected block is refused and its peer leaves the walk, so peers are asked again from the parent; a heavier valid chain is adopted and the existing most-work rewind runs when it forks below the confirmed tip.store/block.invalid;reconsiderblockclears it. IBD seeds its reject set from the hub, so the resume seed and the walk refuse that chain, and the startup heavier-chain scan excludes those hashes. IBD stops classifying "timestamp too far in future" as consensus-invalid (requeued, as tip-follow and Core do), so it is never persisted.invalid_tipsandblock.invalidkeep the newest entry at their caps.invalid_tipsevicts oldest-first.showsis false whereverleft_path_byis true; the failure is written before the retire savesheader.adopt.header.adoptuntil it lands; anotfoundlasts one stall window.invalid_tips. Only a batch that passespow_linkedis refused or remembered.header.adoptis refused only for a failure above the saved base, or one whose height is not found.Schema addition (no version bump)
store/block.invalidis a new sidecar next toheader.adopt: magicrbtcinv1, u32 count (at most 4096), 32-byte hashes. Missing or unparseable is an empty set, so old datadirs open unchanged. Mutated bodies, a future timestamp, store and engine faults, and operatorinvalidateblockare not written (operator marks stay in-process, noted in COMPAT.md). Documented in SCHEMA.md.Tests
end_of_ibd_invalid_heavier_fork(three realP2PNodeminers; the split miner never halves the subsidy, so from height 20 its taller chain is coinbase excess to the syncer): keeps a heavier valid prefix, rewinds a confirmed prefix for a heavier honest chain, survives two restarts without chasing the stored split headers, and finishes on a fork at the invalid block.startup_tip_follow_journey: a heavier stored header listed inblock.invaliddoes not hold startup in catch-up.two_peers_reserve_the_header_walkchapters fornotfound, the probe, announced-chain eligibility, the liveness fallback, and the commit-time refusal;restart_restores_the_checkpoint_skip_and_garbage_does_notchapters for the retired tip and the stored-invalid restore refusal;reconsider_skips_a_branch_that_failspins the sidecar write, the reconsider prune, and that an operator-invalidated block is not written; the classifier table pins time-too-new. Chapters six and seven: the probe asks from the confirmed tip, an empty probe or walk reply, a crossing announcement, an open probe's lane and expiry, and peers' records across a rewind onto a fork below the tip. Units:a_reply_at_or_below_the_tip_ends_the_chain_thereanda_third_chain_split_survives_two_rewinds(peer chain),a_walk_candidate_forked_below_the_tip_is_rewound_to(reorg).Review
An independent review (GPT-6-Astra, high) of commits 1–4 raised seven findings; all seven were real and are fixed in commit 5 with a pin each (the fsync change has no in-process observable): a batch committed after its block failed could undo a retire; a retired tip was not saved to
header.adopt; the failure mark was written before the walk was saved, and a staleheader.adoptcould bring back an invalid candidate; a peer was asked above a short reply's top, and its split was forgotten once that height left the path; an operator invalidation could be persisted through a P2P retry; the sidecar write was not synced.Commits 10 to 17 were reviewed in seven more GPT-6-Astra passes: an open probe that lost its timeout lane, a late walk reply that could be read as a probe reply, a rewind that split a peer off the chain it had announced, a peer's split lost across two rewinds and an inferred block lost or contradicted at later rewinds (now a stale mark and a re-probe), a probe-expiry pin that bypassed the header poll, and a future-time guard the confirm worker bypassed. Each is fixed (in 11, 15, and 17) with a pin that fails without it. Commits 12 to 16 answer coreyphillips' review of e2914ba.
Not in this PR
REWIND_MAX_DEPTH, 1024) or tip-follow's 288-block window (finding 088 F3). The header-thread rewind still does not consider a walk candidate that forks below the confirmed tip; the main-loop rewind does.header_ancestry_invalid's 10,000-step cap, Core'sbad-prevblkmarking at header accept, persisting operatorinvalidateblock, and getheaders to every peer near the tip.MaybePunishNodeForBlock).🤖 Generated with Claude Code