Skip to content

banman: schedule sweep at ban expiry instead of polling#277

Open
Bortlesboat wants to merge 2 commits intobitcoinknots:29.x-knotsfrom
Bortlesboat:fix-ban-table-refresh
Open

banman: schedule sweep at ban expiry instead of polling#277
Bortlesboat wants to merge 2 commits intobitcoinknots:29.x-knotsfrom
Bortlesboat:fix-ban-table-refresh

Conversation

@Bortlesboat
Copy link
Copy Markdown

@Bortlesboat Bortlesboat commented Mar 6, 2026

The ban table only refreshed on BannedListChanged events (active ban/unban), not when bans expired naturally. This left stale entries visible in the GUI after their ban duration elapsed.

Give BanMan access to CScheduler so it can fire SweepBanned() at the exact moment the next ban expires, following the same StartScheduledTasks() pattern as PeerManager. Ban() compares the new ban's expiry directly against the tracked m_next_sweep_time and only reschedules when moving the time forward (O(1), no loop over all bans).

A generation counter (m_sweep_seq) invalidates previously-queued callbacks when the schedule changes. Stale callbacks check their captured sequence number and bail immediately without locking or sweeping.

Also fixes an off-by-one in SweepBanned(): uses >= instead of > so entries are swept at the exact expiry second, consistent with how IsBanned() checks current_time < nBanUntil.

Fixes #273

Copy link
Copy Markdown

@kwsantiago kwsantiago left a comment

Choose a reason for hiding this comment

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

utACK adbd0a1

@luke-jr
Copy link
Copy Markdown
Collaborator

luke-jr commented Mar 16, 2026

Seems like we should instead trigger BannedListChanged when they expire?

@Bortlesboat
Copy link
Copy Markdown
Author

Yeah I thought about that. The tricky part is BanMan doesn't have any timer infrastructure — it just sweeps lazily whenever IsBanned()/GetBanned() happen to get called. To fire on actual expiry we'd need to hook into CScheduler and recalculate the next-expiry time on every ban add/remove.

Went with the GUI timer since it keeps the change small and matches how PeerTableModel already handles its own refresh. But I can see the argument for making BanMan expiry-aware — would benefit bitcoind too, not just Qt. Happy to rework if you'd prefer that direction.

@luke-jr
Copy link
Copy Markdown
Collaborator

luke-jr commented Mar 16, 2026

Makes more sense than polling IMO

@Bortlesboat Bortlesboat force-pushed the fix-ban-table-refresh branch from adbd0a1 to f22692a Compare March 16, 2026 19:00
@Bortlesboat
Copy link
Copy Markdown
Author

Reworked per feedback. Replaced the GUI-level QTimer polling with event-driven expiry at the BanMan level.

BanMan now takes a CScheduler reference via StartScheduledTasks() (same pattern as PeerManager). When a ban is added or on startup, ScheduleNextSweep() finds the earliest nBanUntil and schedules a one-shot SweepBanned() for that exact time. The sweep fires BannedListChanged which the GUI already listens to.

Multiple outstanding scheduled sweeps are harmless since SweepBanned() is idempotent. No cancellation needed — stale sweeps are no-ops.

Shutdown is safe: scheduler->stop() joins the thread before banman.reset().

@Bortlesboat Bortlesboat changed the title qt: Add periodic refresh to ban table to remove expired bans banman: schedule sweep at ban expiry instead of polling Mar 16, 2026
src/banman.cpp Outdated
if (m_banned[sub_net].nBanUntil < ban_entry.nBanUntil) {
m_banned[sub_net] = ban_entry;
m_is_dirty = true;
ScheduleNextSweep();
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.

Don't we need to cancel/replace the existing schedule? Otherwise we will end up with a scheduled recurring task for every ban we make?

Also, we shouldn't need to loop over all bans here, just determine if we're moving the task forward (compare to previous soonest-expiring).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catches, both fixed. Added m_next_sweep_time to track the currently-scheduled expiry. Ban() now just compares ban_entry.nBanUntil < m_next_sweep_time — only reschedules when moving it forward, no loop. SweepBannedAndSchedule() resets the tracker before finding the next one.

CScheduler doesn't have a cancel API, so a stale callback can still fire if we reschedule earlier, but it's a no-op — SweepBanned() finds nothing to remove and ScheduleNextSweep() sees the existing schedule is already correct.

Also noticed SweepBanned() was using now > while IsBanned() uses now < — off-by-one at the boundary. Changed to >= so the sweep actually removes the entry when the scheduler fires at the exact expiry second.

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.

I don't think this solves the multiple-callback-loop issue.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pushed a rework addressing all three:

  • Removed the dead guard, callers always reset m_next_sweep_time first.
  • Split the >= fix into its own commit.
  • Added m_sweep_seq generation counter. Each schedule increments the counter and the callback captures it. Stale callbacks check expected_seq != m_sweep_seq and bail immediately — no sweep, no reschedule cascade.

@Bortlesboat Bortlesboat force-pushed the fix-ban-table-refresh branch from f22692a to c231d4d Compare March 20, 2026 16:35
src/banman.cpp Outdated
}
}

if (earliest >= m_next_sweep_time) return;
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.

m_next_sweep_time is always std::numeric_limits<int64_t>::max() when we get here

Comment on lines -192 to +239
if (!sub_net.IsValid() || now > ban_entry.nBanUntil) {
if (!sub_net.IsValid() || now >= ban_entry.nBanUntil) {
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.

Suggest giving this its own commit (in this same PR)

IsBanned() considers a ban inactive when current_time >= nBanUntil
(it checks current_time < nBanUntil), but SweepBanned() only removed
entries when now > nBanUntil. This left a one-second window where
a ban was no longer enforced but the entry had not yet been swept.

Use >= so entries are swept at the exact second the ban expires,
consistent with the enforcement check.
@Bortlesboat Bortlesboat force-pushed the fix-ban-table-refresh branch from c231d4d to 87325b4 Compare March 20, 2026 19:15
src/init.cpp Outdated
banman->DumpBanlist();
}, DUMP_BANS_INTERVAL);

banman->StartScheduledTasks(scheduler);
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.

Do we need this outside of the GUI? Maybe defer it until the first time handleBannedListChanged (node/interfaces.cpp) is called? (The first call shouldcheck m_next_sweep_time (which needs to be updated unconditionally) and trigger SweepBanned)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reworked. init.cpp now just stores the scheduler pointer via SetScheduler(). Scheduling activates lazily on first handleBannedListChanged connection through an idempotent EnsureSweepScheduled(). Ban() won't queue sweep callbacks until a GUI consumer has connected. Headless nodes never schedule sweeps.

The ban table only refreshed on BannedListChanged events (active
ban/unban), not when bans expired naturally. This left stale entries
visible in the GUI after their ban duration elapsed.

Give BanMan access to CScheduler so it can fire SweepBanned() at the
exact moment the next ban expires. Ban() compares the new ban's expiry
directly against the tracked m_next_sweep_time and only reschedules
when moving the time forward (O(1), no loop over all bans).

Sweep scheduling is deferred until the first GUI consumer connects via
handleBannedListChanged, so headless nodes never schedule sweeps.
EnsureSweepScheduled() is idempotent — a m_sweep_started flag prevents
duplicate activation from multiple handler connections.

A generation counter (m_sweep_seq) invalidates previously-queued
callbacks when the schedule changes. Stale callbacks check their
captured sequence number and bail immediately without locking or
sweeping.

Fixes bitcoinknots#273
@Bortlesboat Bortlesboat force-pushed the fix-ban-table-refresh branch from 87325b4 to 60244b2 Compare March 29, 2026 02:38
@luke-jr luke-jr added this to the 29.3 (2nd) milestone Apr 3, 2026
Copy link
Copy Markdown
Collaborator

@luke-jr luke-jr left a comment

Choose a reason for hiding this comment

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

ACK 60244b2 (with some minor suggestions)

Comment on lines +45 to +47
SweepBanned();
m_next_sweep_time = std::numeric_limits<int64_t>::max();
ScheduleNextSweep();
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.

Just call SweepBannedAndSchedule?

LOCK(m_banned_mutex);
if (expected_seq != m_sweep_seq) return;
SweepBanned();
m_next_sweep_time = std::numeric_limits<int64_t>::max();
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.

This line feels like it belongs inside ScheduleNextSweep

luke-jr pushed a commit that referenced this pull request Apr 13, 2026
IsBanned() considers a ban inactive when current_time >= nBanUntil
(it checks current_time < nBanUntil), but SweepBanned() only removed
entries when now > nBanUntil. This left a one-second window where
a ban was no longer enforced but the entry had not yet been swept.

Use >= so entries are swept at the exact second the ban expires,
consistent with the enforcement check.

Github-Pull: #277
Rebased-From: 13398b9
luke-jr pushed a commit that referenced this pull request Apr 13, 2026
The ban table only refreshed on BannedListChanged events (active
ban/unban), not when bans expired naturally. This left stale entries
visible in the GUI after their ban duration elapsed.

Give BanMan access to CScheduler so it can fire SweepBanned() at the
exact moment the next ban expires. Ban() compares the new ban's expiry
directly against the tracked m_next_sweep_time and only reschedules
when moving the time forward (O(1), no loop over all bans).

Sweep scheduling is deferred until the first GUI consumer connects via
handleBannedListChanged, so headless nodes never schedule sweeps.
EnsureSweepScheduled() is idempotent — a m_sweep_started flag prevents
duplicate activation from multiple handler connections.

A generation counter (m_sweep_seq) invalidates previously-queued
callbacks when the schedule changes. Stale callbacks check their
captured sequence number and bail immediately without locking or
sweeping.

Fixes #273

Github-Pull: #277
Rebased-From: 60244b2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Banned peer remains after expiration.

5 participants