Skip to content

fuzz: cover deferred writing in chanmon_consistency#4465

Open
joostjager wants to merge 2 commits into
lightningdevkit:mainfrom
joostjager:chain-mon-internal-deferred-writes-with-fuzz
Open

fuzz: cover deferred writing in chanmon_consistency#4465
joostjager wants to merge 2 commits into
lightningdevkit:mainfrom
joostjager:chain-mon-internal-deferred-writes-with-fuzz

Conversation

@joostjager
Copy link
Copy Markdown
Contributor

@joostjager joostjager commented Mar 6, 2026

Adds fuzz coverage for #4351

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Mar 6, 2026

👋 I see @wpaulino was un-assigned.
If you'd like another reviewer assignment, please click here.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.11%. Comparing base (75ac90a) to head (5eb96ab).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4465      +/-   ##
==========================================
- Coverage   86.12%   86.11%   -0.02%     
==========================================
  Files         157      157              
  Lines      108824   108871      +47     
  Branches   108824   108871      +47     
==========================================
+ Hits        93727    93755      +28     
- Misses      12480    12501      +21     
+ Partials     2617     2615       -2     
Flag Coverage Δ
tests 86.11% <ø> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from 51afc25 to 0aebb10 Compare March 19, 2026 07:51
@joostjager
Copy link
Copy Markdown
Contributor Author

Rebased

@joostjager
Copy link
Copy Markdown
Contributor Author

Will hold off on this until pre-existing fuzz failures #4496 and #4472 are in

@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from 0aebb10 to 6274ba0 Compare March 20, 2026 17:52
@joostjager
Copy link
Copy Markdown
Contributor Author

Prerequisites are in, rebased.

@TheBlueMatt
Copy link
Copy Markdown
Collaborator

This LGTM, why is it draft?

@joostjager
Copy link
Copy Markdown
Contributor Author

I first wanted to do a serious local run, but then it turned out there are so many pre-existing fuzz failures that it is hard to see what's new. I've bisected the failures to the various PRs that introduced them.

@joostjager
Copy link
Copy Markdown
Contributor Author

joostjager commented Mar 22, 2026

Although for this PR I could just see if anything pops up that doesnt repro on main. Will do that.

@joostjager
Copy link
Copy Markdown
Contributor Author

joostjager commented Mar 23, 2026

Several newly introduced fuzz failures to address:

7084a32219ffa61b1919192119197084a32219ff4400001d228012ffab
10fa0040801170704040198040111911191f1f1170bbff1170ffb3b2b6
10000150804211be707040198011191f1119401f401f0aa97210b6ff25
10000150804211707040198011191f11191f1a4040a91f7210b6ff25

@joostjager
Copy link
Copy Markdown
Contributor Author

Zoomed in on one of those sequences, and it seems it is reproducible with another string without deferred mode too.

0270801109191109191f1f10b6ff

@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch 2 times, most recently from c6da16e to d4bf3e0 Compare April 15, 2026 04:36
@joostjager
Copy link
Copy Markdown
Contributor Author

Dependency: #4520

@joostjager joostjager self-assigned this Apr 16, 2026
@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from d4bf3e0 to a798d74 Compare May 6, 2026 11:42
@joostjager
Copy link
Copy Markdown
Contributor Author

joostjager commented May 7, 2026

Interestingly the non-deferred mode reproducer 0270801109191109191f1f10b6ff doesn't fail on main anymore, with #4520 not yet merged. Bisected the fixing PR to #4529 (@tankyleo).

It seems the increased headroom made the bug unobservable to the fuzzer?

I ran the fuzzer on this branch overnight, and no failures were found.

@joostjager
Copy link
Copy Markdown
Contributor Author

I'll try to add a new invariant to the fuzzer so it catches the problem in a more robust way.

@joostjager
Copy link
Copy Markdown
Contributor Author

Invariant addition: #4601

@joostjager joostjager marked this pull request as ready for review May 7, 2026 13:23
@ldk-reviews-bot ldk-reviews-bot requested a review from wpaulino May 7, 2026 13:23
@joostjager
Copy link
Copy Markdown
Contributor Author

Will rebase this after #4571

@joostjager joostjager removed the request for review from wpaulino May 7, 2026 13:23
Comment thread fuzz/src/chanmon_consistency.rs Outdated
Comment on lines +366 to +367
/// This simulates the pattern of snapshotting the pending count, persisting the
/// `ChannelManager`, then flushing the queued monitor writes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The docstring says this "simulates the pattern of snapshotting the pending count, persisting the ChannelManager, then flushing the queued monitor writes." However, in the actual fuzz loop, the flush happens during event processing (called from release_pending_monitor_events), while the ChannelManager is serialized at the end of each loop iteration (lines 3023-3031) — i.e., after the flush, not before.

This means the fuzzer always tests the scenario where the ChannelManager snapshot captures post-flush state. The more interesting crash scenario — serializing the ChannelManager while monitor writes are still queued, then crashing before the flush — is not directly exercised by this ordering. (The fuzzer does partially cover stale-monitor restarts through the use_old_mons selection in reload_node, but that's a different axis.)

Consider either updating the docstring to reflect what actually happens, or restructuring so the ChannelManager is serialized at the flush call-site to match the documented pattern.

Comment thread fuzz/src/chanmon_consistency.rs Outdated
Comment on lines +379 to +386
if persister_res == chain::ChannelMonitorUpdateStatus::Completed {
for (_channel_id, state) in self.latest_monitors.lock().unwrap().iter_mut() {
if let Some((id, data)) = state.pending_monitors.drain(..).last() {
state.persisted_monitor_id = id;
state.persisted_monitor = data;
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When persister_res == Completed, this drains pending_monitors for all channels, not just those whose operations were included in the current flush(count) call. This is correct today because update_ret is constant for the lifetime of a node (set at creation/reload, never mutated by the mon_style fuzz commands at 0x00-0x06). That invariant ensures:

  • If update_ret == Completed: every prior flush also used Completed, so there are no "orphaned" pending entries from an earlier InProgress flush.
  • If update_ret == InProgress: this branch is never taken.

But this is a subtle invariant — if a future change allows update_ret to be toggled mid-run (e.g., fuzz commands updating the live persister), this would silently promote monitors whose corresponding channel_monitor_updated calls were never made by the flush. Consider adding a comment documenting this assumption, or tightening the logic to only promote monitors that correspond to channels with operations in the flushed batch.

@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented May 7, 2026

I've completed a thorough re-review of the entire PR diff, including:

  1. Tracing the full lifecycle of LatestMonitorState (pending_monitors vs pending_monitor_completions)
  2. Verifying the deferred flush mechanism (checkpoint_manager_persistenceflushPersist callbacks)
  3. Checking that assert_eq!(pending_operation_count(), 0) invariants hold at all call sites
  4. Verifying the reload path with deferred + InProgress combinations
  5. Confirming the settle loop (process_all_events) converges correctly with deferred mode
  6. Checking make_channel setup with deferred mode
  7. Reviewing all fuzz command handlers (0x00-0xff) for deferred-mode safety

No new issues found beyond what was already noted in the prior review pass.

Prior review status:

  • Line 367 (docstring accuracy): Still applies but is a documentation nit, not a bug.
  • Line 386 (drain_all for Completed): Still applies as a fragility note. The invariant that update_ret is constant during a node's lifetime ensures correctness.
  • Line 421 (mark_persisted bug claim): Confirmed false positive as noted in the prior summary. pending_monitors and pending_monitor_completions are independent lists, and out-of-order completion works correctly.
  • Line 1179 (update_ret ordering): Confirmed false positive as noted in the prior summary. The fresh persister is created with Completed at line 1108, the flush at line 1176 runs while update_ret is still Completed, and only line 1181 changes it.

No issues found.

@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from a798d74 to 20275f6 Compare May 8, 2026 14:30
Comment thread fuzz/src/chanmon_consistency.rs Outdated
Comment on lines 414 to 421
fn mark_update_completed(
&self, channel_id: ChannelId, monitor_id: u64, serialized_monitor: Vec<u8>,
) {
if let Some(state) = self.latest_monitors.lock().unwrap().get_mut(&channel_id) {
// Once LDK acknowledges update N as completed, any older pending monitor blob is fully
// superseded and must not be offered back on restart.
state.mark_persisted(monitor_id, serialized_monitor);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: mark_persisted removes all pending entries with id <= monitor_id from the shadow's pending_monitors (via retain), but only the single selected entry was acknowledged with ChainMonitor::channel_monitor_updated. The older entries that are removed will never be acknowledged.

ChainMonitor::channel_monitor_updated removes only the specific completed_update_id from its internal pending_monitor_updates list, and only emits MonitorEvent::Completed when all pending updates have been individually acknowledged.

Regression from old code: The old complete_monitor_update only removed the selected entry (via remove(0), remove(1), or pop()) and left other entries intact for future completion. The new code's reuse of mark_persisted here also cleans up older entries that still need ChainMonitor acknowledgment.

Trigger scenario:

  1. persistence_style = InProgress, two updates accumulate: pending_monitors = [(3, data3), (5, data5)]
  2. Fuzz command 0xf2/0xf6/0xfa/0xfe calls complete_monitor_update(Last)
  3. take_pending(Last) removes (5, data5)pending_monitors = [(3, data3)]
  4. channel_monitor_updated(5) — ChainMonitor removes 5, still has 3 pending
  5. mark_persisted(5, data5)retain removes (3, data3)pending_monitors = []
  6. Shadow has no record of update 3, ChainMonitor still has 3 pending forever
  7. MonitorEvent::Completed is never emitted → channel operations blocked → process_all_events panics at 100 iterations

Fix: mark_update_completed should only update persisted_monitor_id/persisted_monitor without removing other pending entries. The retain cleanup in mark_persisted is correct for track_monitor_update (persister callback context) but wrong here (explicit completion context). Consider splitting the logic or adding a dedicated method that doesn't retain-prune.

@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch 2 times, most recently from af7e8ee to 53cd7c9 Compare May 11, 2026 12:58
Comment thread fuzz/src/chanmon_consistency.rs Outdated
@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from 53cd7c9 to e72ec58 Compare May 11, 2026 13:50
Replace the chanmon consistency harness' Watch wrapper with a Persist
implementation backed by HarnessPersister. Monitor writes now flow
through the real ChainMonitor persistence hooks.

Track restart candidates separately from monitor completion callbacks. A
monitor can stop being a valid reload candidate once a newer baseline is
durable, while its callback may still be needed to unblock the live
ChainMonitor.

On reload, choose the durable baseline, first pending snapshot, or last
pending snapshot. Startup monitor registration completes immediately
before the configured persistence style is restored.
Enable deferred ChainMonitor writes in chanmon_consistency.

Checkpoint the ChannelManager before flushing captured monitor writes.

Treat checkpoint-only progress as progress during settle_all.
@joostjager joostjager force-pushed the chain-mon-internal-deferred-writes-with-fuzz branch from e72ec58 to 5eb96ab Compare May 11, 2026 14:19
@joostjager
Copy link
Copy Markdown
Contributor Author

I reworked this PR and based it on a preparatory commit that makes the fuzzer's persistence model much more understandable, in my opinion. The prep work was taken from the force-close fuzzing PR #4381 that is still WIP, so it should be useful there as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants