Skip to content

Commit 5724ec9

Browse files
committed
fix: address third-round review on external-command hooks
Four things turned up on the third pass through the hooks branch. Two of them were latent bugs the earlier reviews missed; two were the kind of write-amplification and resource-leak issues that don't bite anyone in a 100-test suite but get embarrassing in production. The biggest one: a `,r = toggle-read` macro on the Dashboard with "unread only" filtered didn't drop the just-read item from the list. The Dashboard keypress called `apply_filters()` inline; the macro path went through `dispatch_action -> handle_toggle_read_current` and silently skipped it. Two execution paths, two answers for "did the dashboard refresh." Move `apply_filters()` into the helper so every caller -- macro, FeedItems, FeedItemDetail -- agrees. Next: `mark_feed_seen` flipped `feeds_seeded` even when the first fetch returned zero items. So a transiently-empty fetch (server hiccup, parser edge case) silently armed the firehose: the *next* non-empty fetch treated every backlog item as new. The first-fetch suppression existed precisely to prevent this. One-line guard on `!feed.items.is_empty()`. Please don't congratulate yourself for seeding a feed you haven't actually seen. Then: `fire_exec_on_new` ran `save_data()` once per feed arrival. With 50 bookmarks and the hook configured, that's 50 whole-file JSON writes per refresh. Split into `collect_exec_on_new` (in-memory only) and `flush_exec_on_new` (one save, then spawn); the receive loop accumulates argv across the batch and flushes once. AT-MOST-ONCE crash semantics are preserved -- save still happens before any spawn, just at batch granularity now. Finally: `remove_current_feed` never dropped the URL from `feeds_seeded` or its item IDs from `seen_items`. So the persisted JSON grew monotonically across feed churn for absolutely no reason. Drop them before removing the feed itself. Three regression tests added: dashboard filter reapply after macro toggle-read, empty-first-fetch suppression, and feed removal cleanup. 118 tests pass, clippy clean, fmt clean.
1 parent 9f08e31 commit 5724ec9

3 files changed

Lines changed: 164 additions & 23 deletions

File tree

src/app.rs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,16 @@ impl App {
903903
if idx < self.feeds.len() {
904904
let url = self.feeds[idx].url.clone();
905905

906+
// Drop this feed's exec_on_new tracking state before removing
907+
// the feed itself — otherwise the URL would stay in
908+
// feeds_seeded and its item IDs would stay in seen_items
909+
// forever, growing the persisted JSON file monotonically.
910+
self.feeds_seeded.remove(&url);
911+
for item in &self.feeds[idx].items {
912+
let id = make_item_id(&self.feeds[idx], item);
913+
self.seen_items.remove(&id);
914+
}
915+
906916
// Remove from feeds
907917
self.feeds.remove(idx);
908918

@@ -1726,7 +1736,12 @@ pub fn mark_feed_seen(app: &mut App, feed: &Feed) -> Vec<usize> {
17261736
newly_seen.push(idx);
17271737
}
17281738
}
1729-
if is_first_fetch {
1739+
// Only flip the seeded bit on a fetch that actually returned items. A
1740+
// transiently-empty first fetch (server hiccup, parser edge case) would
1741+
// otherwise mark the feed as seeded with no items recorded; the next
1742+
// non-empty fetch would then treat every item as new and firehose
1743+
// exec_on_new for the entire backlog.
1744+
if is_first_fetch && !feed.items.is_empty() {
17301745
app.feeds_seeded.insert(feed.url.clone());
17311746
}
17321747
newly_seen
@@ -2434,6 +2449,71 @@ mod tests {
24342449
assert!(app.feeds_seeded.contains("https://ex.com/b.xml"));
24352450
}
24362451

2452+
#[test]
2453+
fn test_mark_feed_seen_empty_first_fetch_does_not_seed() {
2454+
// Regression: a transiently-empty first fetch must not flip
2455+
// feeds_seeded. If it did, the next non-empty fetch would be treated
2456+
// as "subsequent" and fire exec_on_new for every backlog item — the
2457+
// exact firehose the first-fetch suppression exists to prevent.
2458+
let mut app = App::new();
2459+
let empty = synthetic_feed("https://ex.com/feed.xml", &[]);
2460+
2461+
let newly = mark_feed_seen(&mut app, &empty);
2462+
assert!(newly.is_empty());
2463+
assert!(
2464+
!app.feeds_seeded.contains("https://ex.com/feed.xml"),
2465+
"empty fetch must NOT mark feed as seeded"
2466+
);
2467+
2468+
// The next fetch — now with items — is still the first real fetch
2469+
// and must seed silently.
2470+
let with_items = synthetic_feed("https://ex.com/feed.xml", &["A", "B", "C"]);
2471+
let newly = mark_feed_seen(&mut app, &with_items);
2472+
assert!(
2473+
newly.is_empty(),
2474+
"first non-empty fetch must seed silently, not fire"
2475+
);
2476+
assert!(app.feeds_seeded.contains("https://ex.com/feed.xml"));
2477+
assert_eq!(app.seen_items.len(), 3);
2478+
}
2479+
2480+
#[test]
2481+
fn test_remove_current_feed_cleans_up_tracking_state() {
2482+
// Regression: feed removal used to leave the URL in feeds_seeded and
2483+
// every item ID in seen_items forever, growing the persisted JSON
2484+
// file monotonically across feed churn.
2485+
let mut app = App::new();
2486+
let feed = synthetic_feed("https://ex.com/feed.xml", &["A", "B"]);
2487+
let item_ids: Vec<String> = feed.items.iter().map(|i| make_item_id(&feed, i)).collect();
2488+
2489+
// Seed the tracking state as if exec_on_new had run for this feed.
2490+
mark_feed_seen(&mut app, &feed);
2491+
let second = synthetic_feed("https://ex.com/feed.xml", &["A", "B", "C"]);
2492+
mark_feed_seen(&mut app, &second);
2493+
assert!(app.feeds_seeded.contains("https://ex.com/feed.xml"));
2494+
for id in &item_ids {
2495+
assert!(app.seen_items.contains(id), "precondition: {} seen", id);
2496+
}
2497+
2498+
// Install the feed and trigger removal via the public path.
2499+
app.bookmarks.push("https://ex.com/feed.xml".to_string());
2500+
app.feeds.push(second);
2501+
app.selected_feed = Some(0);
2502+
app.remove_current_feed().unwrap();
2503+
2504+
assert!(
2505+
!app.feeds_seeded.contains("https://ex.com/feed.xml"),
2506+
"URL must be dropped from feeds_seeded"
2507+
);
2508+
for id in &item_ids {
2509+
assert!(
2510+
!app.seen_items.contains(id),
2511+
"item id {} must be dropped from seen_items",
2512+
id
2513+
);
2514+
}
2515+
}
2516+
24372517
#[test]
24382518
fn test_make_item_id_prefers_link() {
24392519
let mut feed = Feed {

src/events.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ fn handle_toggle_read_current(app: &mut App) {
8888
app.error = Some(format!("Failed to toggle read status: {}", e));
8989
}
9090
}
91+
// Re-filter so a `read_status = Some(false)` ("unread only") filter on the
92+
// Dashboard drops the just-read item from the visible list. The Dashboard
93+
// keypress used to do this inline; centralizing it here means macros and
94+
// every other caller stay in sync.
95+
app.apply_filters();
9196
}
9297

9398
// ── Macro engine ───────────────────────────────────────────────────
@@ -2067,4 +2072,41 @@ mod tests {
20672072
);
20682073
assert!(app.error.is_none(), "should not error: {:?}", app.error);
20692074
}
2075+
2076+
#[test]
2077+
fn test_dispatch_toggle_read_reapplies_filters_on_dashboard() {
2078+
// Regression: a `,r = toggle-read` macro on the Dashboard with the
2079+
// "unread only" filter active must remove the just-read item from the
2080+
// visible list. Previously dispatch_action -> handle_toggle_read_current
2081+
// skipped apply_filters(), so the dashboard kept showing stale state
2082+
// until the next filter change.
2083+
let mut app = make_test_app();
2084+
app.read_items.clear();
2085+
app.starred_items.clear();
2086+
app.view = View::Dashboard;
2087+
2088+
// Activate "unread only" filter, then materialize the filtered list.
2089+
app.filter_options.read_status = Some(false);
2090+
app.apply_filters();
2091+
let before = app.active_dashboard_items().len();
2092+
assert!(
2093+
before > 0,
2094+
"precondition: dashboard has unread items to filter on"
2095+
);
2096+
app.selected_item = Some(0);
2097+
2098+
dispatch_action(&mut app, KeyAction::ToggleRead);
2099+
2100+
assert_eq!(
2101+
app.read_items.len(),
2102+
1,
2103+
"item should be marked read via dispatch_action"
2104+
);
2105+
assert_eq!(
2106+
app.active_dashboard_items().len(),
2107+
before - 1,
2108+
"the just-read item must drop out of the unread-only filter"
2109+
);
2110+
assert!(app.error.is_none(), "should not error: {:?}", app.error);
2111+
}
20702112
}

src/tui.rs

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -201,40 +201,50 @@ fn drain_macro_steps<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) {
201201
}
202202
}
203203

204-
/// Diff a freshly-arrived feed against `app.seen_items` and fire the
205-
/// `[hooks].exec_on_new` template (if configured) for each genuinely-new item.
206-
/// On the first successful fetch of a feed (URL not yet in `feeds_seeded`),
207-
/// seed the seen set silently to suppress the firehose.
204+
/// Diff a freshly-arrived feed against `app.seen_items`, mutate the seen
205+
/// state in memory, and return the argv list to spawn for each genuinely-new
206+
/// item. On the first successful fetch of a feed (URL not yet in
207+
/// `feeds_seeded`), seed the seen set silently and return an empty list.
208208
///
209-
/// Persistence order is "mark-seen, persist, spawn" so semantics on crash are
210-
/// AT MOST ONCE: a kill between persist and spawn loses a notification, but a
211-
/// later restart never re-fires the same item. This matters for side-effecting
212-
/// hooks like `wallabag-cli add`.
209+
/// Returns Vec rather than spawning + saving inline so a refresh batch can
210+
/// `save_data` ONCE before spawning ANY child, instead of once per feed
211+
/// arrival (write amplification of N for N bookmarks). Crash semantics still
212+
/// AT-MOST-ONCE: see [`flush_exec_on_new`] for the persistence ordering.
213213
///
214214
/// No-op when the hook is not configured. seen_items / feeds_seeded only
215215
/// exist to drive this hook, so users who never opt in pay neither the
216-
/// memory cost nor the per-feed save_data round-trip.
217-
fn fire_exec_on_new(app: &mut App, feed: &Feed) {
216+
/// memory cost nor any save_data round-trip.
217+
fn collect_exec_on_new(app: &mut App, feed: &Feed) -> Vec<Vec<String>> {
218218
let argv_template = match app.exec_on_new_template.as_ref() {
219219
Some(t) => t.clone(),
220-
None => return,
220+
None => return Vec::new(),
221221
};
222222
let newly_seen_idx = crate::app::mark_feed_seen(app, feed);
223223
if newly_seen_idx.is_empty() {
224+
return Vec::new();
225+
}
226+
newly_seen_idx
227+
.into_iter()
228+
.filter_map(|idx| {
229+
let item = feed.items.get(idx)?;
230+
let ctx = crate::app::article_context_from(feed, item);
231+
Some(crate::app::expand_argv_template(&argv_template, &ctx))
232+
})
233+
.collect()
234+
}
235+
236+
/// Persist the updated seen-set, then spawn all collected exec_on_new
237+
/// children. The "save then spawn" order is the AT-MOST-ONCE guarantee:
238+
/// a kill between save and spawn loses notifications, but a restart never
239+
/// re-fires the same item. Matters for side-effecting hooks like
240+
/// `wallabag-cli add` where a duplicate is actively wrong.
241+
fn flush_exec_on_new(app: &mut App, pending: Vec<Vec<String>>) {
242+
if pending.is_empty() {
224243
return;
225244
}
226-
// Persist the updated seen set before spawning. If we crash before
227-
// spawning, the user misses a notification — preferable to a duplicate
228-
// for side-effecting hooks.
229245
let _ = app.save_data();
230246
let mut first_failure_recorded = false;
231-
for idx in newly_seen_idx {
232-
let item = match feed.items.get(idx) {
233-
Some(i) => i,
234-
None => continue,
235-
};
236-
let ctx = crate::app::article_context_from(feed, item);
237-
let argv = crate::app::expand_argv_template(&argv_template, &ctx);
247+
for argv in pending {
238248
if let Err(e) = spawn_detached(&argv) {
239249
if !first_failure_recorded {
240250
app.error = Some(format!("exec_on_new: {}", e));
@@ -306,9 +316,14 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
306316

307317
// Drain any feeds that arrived from background threads
308318
if pending_count > 0 {
319+
// Accumulate exec_on_new spawns across every feed arriving in this
320+
// tick, so we only `save_data` once before spawning. Saving per
321+
// feed (the previous shape) was N whole-file JSON writes per
322+
// refresh.
323+
let mut pending_exec: Vec<Vec<String>> = Vec::new();
309324
while let Ok((idx, result)) = feed_rx.try_recv() {
310325
if let Ok(feed) = result {
311-
fire_exec_on_new(app, &feed);
326+
pending_exec.extend(collect_exec_on_new(app, &feed));
312327
// Insert at the correct position to maintain bookmark order,
313328
// or append if earlier feeds haven't arrived yet
314329
let insert_pos = app
@@ -349,6 +364,10 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
349364
let _ = app.save_data();
350365
}
351366
}
367+
// Persist seen-set ONCE for the whole batch, then spawn. Done
368+
// outside the per-feed loop so a refresh that brings back items
369+
// for many feeds writes the JSON file once instead of N times.
370+
flush_exec_on_new(app, pending_exec);
352371
}
353372

354373
// If loading, use a shorter timeout for animation

0 commit comments

Comments
 (0)