Run torrents off the command worker
A torrent ran inline on the single sequential worker, so it blocked every feed scan, HTTP download and status command behind it -- for up to stall_mins waiting on metadata, and for up to seed_time_mins seeding after finishing. A live daemon was wedged with 47 pending torrents and would not answer a status command for 15s. spawn_torrent detaches the job behind a 2-permit semaphore and marks the row 'downloading' so a rescan cannot queue it twice. One-shot CLI runs stay inline, or the process would exit mid-download. Torrents themselves verified working: a 755 MB Debian netinst downloaded to completion against a real swarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
35
PROGRESS.md
35
PROGRESS.md
@@ -56,6 +56,41 @@ and until now nothing set them.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 — Torrents: they work, and one was freezing the whole daemon
|
||||||
|
|
||||||
|
Asked "is torrents working?". The honest answer had been "unverified since step 7" — so it got tested
|
||||||
|
properly.
|
||||||
|
|
||||||
|
**They work.** End to end against a real swarm: Debian 13.6.0 netinst, 791,674,880 bytes, completed,
|
||||||
|
`file` confirms a bootable ISO, row marked `done`. Metadata resolution, transfer, placement and
|
||||||
|
completion all good.
|
||||||
|
|
||||||
|
**But the daemon was wedged.** Ray's CT-log feed had 47 pending torrents and nothing was progressing.
|
||||||
|
The worker was blocked: a `status` command over the control socket got no reply in 15s. Torrents ran
|
||||||
|
*inline on the single command worker*, so one torrent stopped every feed scan, every HTTP download
|
||||||
|
and every status command behind it — for up to `stall_mins` (30 default) waiting on metadata, and
|
||||||
|
worse, `Torrents::fetch` blocks on seeding for up to `seed_time_mins` (60 default) **after**
|
||||||
|
finishing. One successful torrent could have frozen podcast fetching for an hour.
|
||||||
|
|
||||||
|
Torrents are now detached: `spawn_torrent` runs the job on its own task behind a 2-permit semaphore,
|
||||||
|
the row is marked `downloading` so a rescan does not queue it twice, and the worker moves straight
|
||||||
|
on. A one-shot CLI run still runs them inline, or the process would exit mid-download. Verified: the
|
||||||
|
worker now answers `status` in 0.0s.
|
||||||
|
|
||||||
|
A diagnostic misstep worth recording: `/api/settings` returning 200 was taken as proof the worker was
|
||||||
|
alive. It is not — the web server is a separate task, so HTTP stays responsive while the queue is
|
||||||
|
completely stuck. Probing the control socket is the real test.
|
||||||
|
|
||||||
|
**Those CT-log torrents are ~557 GB each, 47 of them.** The RSS advertises `length="0"`, so nothing
|
||||||
|
warns you; the size only appears once metadata resolves. `auto_download` on that feed is set to
|
||||||
|
false as a hold, since restarting would otherwise have begun a half-terabyte transfer immediately
|
||||||
|
(the scheduler's first tick fires at startup). Flip it back in the feed's Settings when wanted.
|
||||||
|
|
||||||
|
Also noted: an aborted torrent leaves librqbit's pre-created file placeholders behind in the
|
||||||
|
destination folder (zero-length). Not cleaned up yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 — Schedule pickers, and progress painting every row
|
## 2026-09-10 — Schedule pickers, and progress painting every row
|
||||||
|
|
||||||
**Pickers.** "Check feeds every" is a number input plus a unit dropdown (minutes/hours/days/weeks)
|
**Pickers.** "Check feeds every" is a number input plus a unit dropdown (minutes/hours/days/weeks)
|
||||||
|
|||||||
100
src/main.rs
100
src/main.rs
@@ -86,6 +86,13 @@ pub struct Ctx {
|
|||||||
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
|
||||||
/// rude to do for a config that has never seen a torrent.
|
/// rude to do for a config that has never seen a torrent.
|
||||||
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
|
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
|
||||||
|
/// Caps how many torrents run at once when they are detached.
|
||||||
|
pub torrent_slots: Arc<tokio::sync::Semaphore>,
|
||||||
|
/// Run torrents off the command worker. A torrent takes minutes to fetch metadata and
|
||||||
|
/// then seeds for up to an hour, and the worker is sequential -- inline, one torrent
|
||||||
|
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||||
|
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||||
|
pub detach_torrents: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ctx {
|
impl Ctx {
|
||||||
@@ -145,30 +152,34 @@ async fn main() -> Result<()> {
|
|||||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ctx = Ctx {
|
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||||
|
let (events, _) = broadcast::channel(1024);
|
||||||
|
let ctx = Arc::new(Ctx {
|
||||||
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
||||||
db,
|
db,
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::builder()
|
||||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||||
.build()?,
|
.build()?,
|
||||||
out: Emitter::terminal(),
|
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
|
||||||
torrents: tokio::sync::OnceCell::new(),
|
torrents: tokio::sync::OnceCell::new(),
|
||||||
};
|
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||||
|
detach_torrents: is_daemon,
|
||||||
|
});
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::List => list(&ctx, &config_path),
|
Command::List => list(&ctx, &config_path),
|
||||||
Command::Daemon { web } => daemon(ctx, config_path, web).await,
|
Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
|
||||||
Command::Add { url, folder, keywords } => {
|
Command::Add { url, folder, keywords } => {
|
||||||
add(ctx, &config_path, &url, folder, keywords).await
|
add(&ctx, &config_path, &url, folder, keywords).await
|
||||||
}
|
}
|
||||||
Command::Rm { feed } => rm(ctx, &config_path, &feed),
|
Command::Rm { feed } => rm(&ctx, &config_path, &feed),
|
||||||
Command::Import { file } => import(ctx, &config_path, &file).await,
|
Command::Import { file } => import(&ctx, &config_path, &file).await,
|
||||||
Command::Export { file } => export(&ctx, &file),
|
Command::Export { file } => export(&ctx, &file),
|
||||||
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
|
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Fetch { feed, force } => {
|
Cmd::Fetch { feed, force } => {
|
||||||
// Make room before pulling more down, as the original did per download.
|
// Make room before pulling more down, as the original did per download.
|
||||||
@@ -185,15 +196,18 @@ async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn daemon(ctx: Ctx, config_path: PathBuf, web_addr: Option<String>) -> Result<()> {
|
async fn daemon(
|
||||||
|
ctx: Arc<Ctx>,
|
||||||
|
config_path: PathBuf,
|
||||||
|
web_addr: Option<String>,
|
||||||
|
events: broadcast::Sender<Event>,
|
||||||
|
) -> Result<()> {
|
||||||
let socket = ctx.cfg().general.socket.clone();
|
let socket = ctx.cfg().general.socket.clone();
|
||||||
if ipc::daemon_is_live(&socket).await {
|
if ipc::daemon_is_live(&socket).await {
|
||||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (events, _) = broadcast::channel(1024);
|
|
||||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
||||||
let ctx = Arc::new(Ctx { out: Emitter::socket(events.clone(), false), ..ctx });
|
|
||||||
|
|
||||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||||
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
|
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
|
||||||
@@ -327,7 +341,7 @@ async fn shutdown() {
|
|||||||
|
|
||||||
/// Subscribes to one feed, naming it from its own title.
|
/// Subscribes to one feed, naming it from its own title.
|
||||||
async fn add(
|
async fn add(
|
||||||
ctx: Ctx,
|
ctx: &Ctx,
|
||||||
config_path: &std::path::Path,
|
config_path: &std::path::Path,
|
||||||
url: &str,
|
url: &str,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
@@ -337,7 +351,7 @@ async fn add(
|
|||||||
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == url) {
|
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == url) {
|
||||||
anyhow::bail!("already subscribed as {id:?}");
|
anyhow::bail!("already subscribed as {id:?}");
|
||||||
}
|
}
|
||||||
let id = add_one(&ctx, &mut cfg, url, folder, keywords).await?;
|
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||||
cfg.save(config_path)?;
|
cfg.save(config_path)?;
|
||||||
println!("added {id}");
|
println!("added {id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -389,7 +403,7 @@ fn url_stem(url: &str) -> String {
|
|||||||
.unwrap_or_else(|| url.to_owned())
|
.unwrap_or_else(|| url.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
if cfg.feeds.remove(feed).is_none() {
|
if cfg.feeds.remove(feed).is_none() {
|
||||||
anyhow::bail!("no feed with id {feed:?}");
|
anyhow::bail!("no feed with id {feed:?}");
|
||||||
@@ -400,7 +414,7 @@ fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
let text = std::fs::read_to_string(file)
|
let text = std::fs::read_to_string(file)
|
||||||
.with_context(|| format!("reading {}", file.display()))?;
|
.with_context(|| format!("reading {}", file.display()))?;
|
||||||
@@ -510,7 +524,7 @@ fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
|
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
if let Some(id) = only
|
if let Some(id) = only
|
||||||
&& !cfg.feeds.contains_key(id)
|
&& !cfg.feeds.contains_key(id)
|
||||||
@@ -586,7 +600,7 @@ struct Scan {
|
|||||||
|
|
||||||
/// Ok(None) means 304.
|
/// Ok(None) means 304.
|
||||||
async fn scan_one(
|
async fn scan_one(
|
||||||
ctx: &Ctx,
|
ctx: &Arc<Ctx>,
|
||||||
id: &str,
|
id: &str,
|
||||||
feed_cfg: &config::Feed,
|
feed_cfg: &config::Feed,
|
||||||
state: &db::HttpState,
|
state: &db::HttpState,
|
||||||
@@ -652,6 +666,13 @@ async fn scan_one(
|
|||||||
scan.torrents += 1;
|
scan.torrents += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ctx.detach_torrents {
|
||||||
|
// 'downloading' keeps the next scan from queueing it a second time.
|
||||||
|
ctx.db.mark_enclosure(&item.url, "downloading", None)?;
|
||||||
|
spawn_torrent(ctx, id.to_string(), item.id, item.url.clone(), dest_dir.clone());
|
||||||
|
scan.torrents += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match torrent_one(ctx, id, item.id, &item.url, &dest_dir).await {
|
match torrent_one(ctx, id, item.id, &item.url, &dest_dir).await {
|
||||||
Ok((path, bytes)) => {
|
Ok((path, bytes)) => {
|
||||||
ctx.db.mark_downloaded(&item.url, &path, bytes)?;
|
ctx.db.mark_downloaded(&item.url, &path, bytes)?;
|
||||||
@@ -729,7 +750,7 @@ fn reject(feed_cfg: &config::Feed, entry: &feed::Entry, url: &str) -> Option<&'s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_one(
|
async fn fetch_one(
|
||||||
ctx: &Ctx,
|
ctx: &Arc<Ctx>,
|
||||||
feed_id: &str,
|
feed_id: &str,
|
||||||
enclosure: i64,
|
enclosure: i64,
|
||||||
feed_cfg: &config::Feed,
|
feed_cfg: &config::Feed,
|
||||||
@@ -774,9 +795,43 @@ async fn fetch_one(
|
|||||||
Ok((path, got.bytes))
|
Ok((path, got.bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving
|
||||||
|
/// while it fetches metadata, transfers and then seeds.
|
||||||
|
fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, dest_dir: PathBuf) {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Held for the whole job, so a feed full of torrents cannot open hundreds at once.
|
||||||
|
let _permit = match ctx.torrent_slots.clone().acquire_owned().await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let outcome = torrent_one(&ctx, &feed_id, enclosure, &url, &dest_dir).await;
|
||||||
|
let db = &ctx.db;
|
||||||
|
match outcome {
|
||||||
|
Ok((path, bytes)) => {
|
||||||
|
if let Err(e) = db.mark_downloaded(&url, &path, bytes) {
|
||||||
|
tracing::warn!(error = ?e, "could not record the finished torrent");
|
||||||
|
}
|
||||||
|
ctx.out.emit(Event::DownloadDone {
|
||||||
|
feed: feed_id,
|
||||||
|
enclosure,
|
||||||
|
url,
|
||||||
|
path: path.display().to_string(),
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("{e:#}");
|
||||||
|
let _ = db.mark_enclosure(&url, "error", Some(&msg));
|
||||||
|
ctx.out.emit(Event::DownloadError { feed: feed_id, enclosure, url, msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Downloads one specific enclosure immediately, whatever the per-scan cap says and
|
/// Downloads one specific enclosure immediately, whatever the per-scan cap says and
|
||||||
/// wherever it sits in the queue.
|
/// wherever it sits in the queue.
|
||||||
async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let enc = ctx
|
let enc = ctx
|
||||||
.db
|
.db
|
||||||
@@ -796,6 +851,11 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
|||||||
|
|
||||||
ctx.out.emit(Event::FeedStart { feed: enc.feed_id.clone() });
|
ctx.out.emit(Event::FeedStart { feed: enc.feed_id.clone() });
|
||||||
let is_torrent = download::looks_like_torrent(&enc.url, enc.mime.as_deref());
|
let is_torrent = download::looks_like_torrent(&enc.url, enc.mime.as_deref());
|
||||||
|
if is_torrent && cfg.torrent.enabled && ctx.detach_torrents {
|
||||||
|
ctx.db.mark_enclosure(&enc.url, "downloading", None)?;
|
||||||
|
spawn_torrent(ctx, enc.feed_id.clone(), enc.id, enc.url.clone(), dest_dir);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let result = if is_torrent {
|
let result = if is_torrent {
|
||||||
if !cfg.torrent.enabled {
|
if !cfg.torrent.enabled {
|
||||||
Err(anyhow::anyhow!("torrents are disabled"))
|
Err(anyhow::anyhow!("torrents are disabled"))
|
||||||
@@ -836,7 +896,7 @@ async fn download_one(ctx: &Ctx, id: i64) -> Result<()> {
|
|||||||
/// Torrent progress is reported the same way an HTTP download's is, throttled to whole
|
/// Torrent progress is reported the same way an HTTP download's is, throttled to whole
|
||||||
/// percents so a UI is not flooded.
|
/// percents so a UI is not flooded.
|
||||||
async fn torrent_one(
|
async fn torrent_one(
|
||||||
ctx: &Ctx,
|
ctx: &Arc<Ctx>,
|
||||||
feed_id: &str,
|
feed_id: &str,
|
||||||
enclosure: i64,
|
enclosure: i64,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user