Keep two changes to the catalogue made at once from undoing each other (#153)

Each change to the catalogue or the server settings stored the whole thing from a copy it took
earlier, and nothing kept two from overlapping. Adding a feed copied it first and then spent up to
a minute finding and reading the feed, so another person's new feed, an unsubscribe, an admin's
settings, or the scan's own changes (a moved feed's address, a category) made in that minute were
stored over when it finished, with no error. A feed dropped that way left its subscriber's list.

Every change now takes its copy through Ctx::edit_cfg, which holds a lock until store_cfg keeps it,
and store_cfg takes nothing else, so no writer can skip it. Adding a feed takes its copy once it
has found the feed, and before the duplicate check, so two people adding one feed add it once.
reload takes the lock too. `ipx add`, `rm` and `import`, in a process of their own, are outside it,
as the comment on the lock says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:40:17 +00:00
parent d5edead011
commit c426ab6314
3 changed files with 74 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart. - A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow. - On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. - On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it.

View File

@@ -145,6 +145,32 @@ pub struct Ctx {
/// failure that says nothing about the feed, kept off it and out of the database (#143). /// failure that says nothing about the feed, kept off it and out of the database (#143).
/// Forgotten on a restart, which only makes the retry sooner. /// Forgotten on a restart, which only makes the retry sooner.
pub retry: std::sync::Mutex<std::collections::HashMap<String, i64>>, pub retry: std::sync::Mutex<std::collections::HashMap<String, i64>>,
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
/// another add spent a minute fetching was gone once that one stored (#153).
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
// own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>,
}
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
pub struct CfgEdit<'a> {
cfg: config::Config,
_held: tokio::sync::MutexGuard<'a, ()>,
}
impl std::ops::Deref for CfgEdit<'_> {
type Target = config::Config;
fn deref(&self) -> &config::Config {
&self.cfg
}
}
impl std::ops::DerefMut for CfgEdit<'_> {
fn deref_mut(&mut self) -> &mut config::Config {
&mut self.cfg
}
} }
/// How soon a feed whose host could not be looked up is tried again (#143). /// How soon a feed whose host could not be looked up is tried again (#143).
@@ -177,12 +203,18 @@ impl Ctx {
self.cfg.read().unwrap().clone() self.cfg.read().unwrap().clone()
} }
/// Re-reads config.toml into the live snapshot. /// The config as it stands, to change and then keep with `store_cfg`; any other change waits
/// until then.
pub async fn edit_cfg(&self) -> CfgEdit<'_> {
let held = self.cfg_write.lock().await;
CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
}
/// Keeps a changed catalogue or server settings: in the database, and for everything running /// Keeps a changed catalogue or server settings: in the database, and for everything running
/// here from now on. What config.toml's save and reload did, before the database held them. /// here from now on. What config.toml's save and reload did, before the database held them.
pub async fn store_cfg(&self, cfg: config::Config) -> Result<()> { pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
self.db.store_config(&cfg).await?; self.db.store_config(&edit.cfg).await?;
self.set_cfg(cfg); self.set_cfg(edit.cfg);
Ok(()) Ok(())
} }
@@ -308,6 +340,7 @@ async fn main() -> Result<()> {
config_path: config_path.clone(), config_path: config_path.clone(),
detach_torrents: is_daemon, detach_torrents: is_daemon,
retry: Default::default(), retry: Default::default(),
cfg_write: Default::default(),
}); });
let result = match cli.command { let result = match cli.command {
@@ -502,6 +535,7 @@ async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where /// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
/// things are and who gets in. /// things are and who gets in.
async fn reload(ctx: &Ctx) -> Result<()> { async fn reload(ctx: &Ctx) -> Result<()> {
let _held = ctx.cfg_write.lock().await;
let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?; let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
ctx.set_cfg(cfg); ctx.set_cfg(cfg);
Ok(()) Ok(())
@@ -754,7 +788,7 @@ async fn add(
category: Option<String>, category: Option<String>,
list: bool, list: bool,
) -> Result<()> { ) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
let url = &feed::find_feed(&ctx.client, &feed::expand_input(url)).await?; let url = &feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
// Includes feeds derived from an OPML, or the same show could be added twice. // Includes feeds derived from an OPML, or the same show could be added twice.
if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) { if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
@@ -852,7 +886,7 @@ fn url_stem(url: &str) -> String {
} }
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> { async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if cfg.feeds.remove(feed).is_none() { if cfg.feeds.remove(feed).is_none() {
// Derived from an OPML: drop it here, though the subscription will list it again // Derived from an OPML: drop it here, though the subscription will list it again
// on the next read unless the OPML itself goes. // on the next read unless the OPML itself goes.
@@ -902,8 +936,8 @@ pub async fn subscribe_opml(
let mut found = vec![]; let mut found = vec![];
collect_outlines(&doc.body.outlines, &mut found); collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = ctx.edit_cfg().await;
let known = subscriptions(ctx).await?; let known = subscriptions(ctx).await?;
let mut cfg = (*ctx.cfg()).clone();
let mut ids = vec![]; let mut ids = vec![];
let mut grew = false; let mut grew = false;
for (title, url) in found { for (title, url) in found {
@@ -1057,7 +1091,7 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
if stale.is_empty() { if stale.is_empty() {
return Ok(()); return Ok(());
} }
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) { if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
} }
@@ -1441,7 +1475,7 @@ async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it"); tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
return Ok(()); return Ok(());
} }
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) }; let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
let from = std::mem::replace(&mut f.url, to.to_owned()); let from = std::mem::replace(&mut f.url, to.to_owned());
ctx.store_cfg(cfg).await?; ctx.store_cfg(cfg).await?;
@@ -1608,7 +1642,7 @@ async fn scan_one(
{ {
match feed::classify(&ctx.client, &key, &parsed).await { match feed::classify(&ctx.client, &key, &parsed).await {
Ok(category) => { Ok(category) => {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = ctx.edit_cfg().await;
if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) { if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) {
tracing::info!(feed = id, category = %category, "categorised"); tracing::info!(feed = id, category = %category, "categorised");
f.category = Some(category); f.category = Some(category);
@@ -2319,9 +2353,33 @@ mod tests {
config_path: PathBuf::new(), config_path: PathBuf::new(),
detach_torrents: false, detach_torrents: false,
retry: Default::default(), retry: Default::default(),
cfg_write: Default::default(),
} }
} }
#[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
// A slow change, as adding a feed is while it fetches, and a quick one made meanwhile.
let slow = {
let ctx = ctx.clone();
tokio::spawn(async move {
let mut cfg = ctx.edit_cfg().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cfg.feeds.insert("a".into(), feed());
ctx.store_cfg(cfg).await.unwrap();
})
};
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let mut cfg = ctx.edit_cfg().await;
cfg.feeds.insert("b".into(), feed());
ctx.store_cfg(cfg).await.unwrap();
slow.await.unwrap();
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["a", "b"], "neither stored over the other (#153)");
let (_, stored) = ctx.db.stored_config().await.unwrap().unwrap();
assert_eq!(stored.keys().collect::<Vec<_>>(), ["a", "b"]);
}
#[tokio::test] #[tokio::test]
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() { async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
let ctx = test_ctx(config::Config::default()).await; let ctx = test_ctx(config::Config::default()).await;

View File

@@ -1457,12 +1457,13 @@ async fn add_feed(
user: crate::db::User, user: crate::db::User,
Json(body): Json<NewFeed>, Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
// Before the duplicate check, so a site's page finds the feed someone already has. What is // Before the duplicate check, so a site's page finds the feed someone already has. What is
// not a feed and links none is refused here, with why, and nothing is added. // not a feed and links none is refused here, with why, and nothing is added.
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url)) let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
.await .await
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?; .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
// Taken before the duplicate check, so two people adding the same feed at once add it once.
let mut cfg = state.ctx.edit_cfg().await;
// Someone else may already have it. Then adding costs nothing: no second fetch, no // Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed. // second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx).await? if let Some(existing) = crate::subscriptions(&state.ctx).await?
@@ -1600,7 +1601,7 @@ async fn patch_feed(
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them", "the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
)); ));
} }
let mut cfg = (*state.ctx.cfg()).clone(); let mut cfg = state.ctx.edit_cfg().await;
// Derived feeds have no config entry. Editing one is the moment it earns a real // Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else. // entry: promote it, so the config holds your decisions and nothing else.
@@ -1677,7 +1678,7 @@ async fn remove_feed(
// Nobody is left: the feed stops being scanned. Its files and history stay, so if // Nobody is left: the feed stops being scanned. Its files and history stay, so if
// someone subscribes again they do not pull the back catalogue a second time. A feed an // someone subscribes again they do not pull the back catalogue a second time. A feed an
// admin listed stays in the Directory, for the next person. // admin listed stays in the Directory, for the next person.
let mut cfg = (*state.ctx.cfg()).clone(); let mut cfg = state.ctx.edit_cfg().await;
if cfg.feeds.get(&id).is_some_and(|f| f.listed) { if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
return Ok(StatusCode::NO_CONTENT); return Ok(StatusCode::NO_CONTENT);
} }
@@ -2156,7 +2157,7 @@ async fn patch_settings(
if !user.is_admin { if !user.is_admin {
return Err(ApiError::forbidden("only an admin changes these settings")); return Err(ApiError::forbidden("only an admin changes these settings"));
} }
let mut cfg = (*state.ctx.cfg()).clone(); let mut cfg = state.ctx.edit_cfg().await;
if let Some(sched) = body.schedule { if let Some(sched) = body.schedule {
let sched = sched.trim().to_owned(); let sched = sched.trim().to_owned();
if crate::config::parse_interval(&sched).is_none() { if crate::config::parse_interval(&sched).is_none() {