Fix concurrency issues and add seed limit enforcement - #7
Merged
Conversation
… works The virtualizer's scroll element had no height constraint, so it grew to content height, the WindowShell wrapper scrolled instead, TanStack Virtual saw a viewport equal to the full list and rendered every row (re-rendered each 1Hz tick), and the sticky header stuck to the wrong scrollport. Constrain TorrentTable's scroll container with h-full (mirroring CardList) and make the WindowShell wrapper overflow-hidden for the table view so exactly one scrollport exists. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…responses The Overview effect tracked props.detail, which is replaced wholesale every ~1Hz inspector tick — one getTorrentRateLimits request per second while the default tab was open. Key the effect on the torrent id via createEffect(on) and discard responses whose id no longer matches, so a slow reply for torrent A can't populate B's rate-limit inputs. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
The browser transport had no routes for AddTracker/RemoveTracker, so the
trackers tab silently failed outside Wails even though the backend
endpoints (POST/DELETE /api/torrents/{id}/trackers, JSON {url}) exist.
Also surface remove-tracker failures with an error toast instead of
swallowing them.
https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Filters run on Go's RE2 engine, but the form validated with new RegExp — the app's own suggested syntax (?i)ubuntu.*amd64 throws in JS and permanently disabled Save. Only require a non-empty regex client-side; create/update errors from the backend already surface via the submit handlers' toast. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…oard delete extendSelectTo ranged over the raw torrents array (unfiltered, insertion order), so with a filter or sort active a shift-click silently selected hidden torrents — which Delete/Backspace then removed without warning. Row click handlers now pass the filtered+sorted id list they actually render (the table passes its sorted row model, cards the filtered list), and the keyboard delete asks for confirmation before removing more than one torrent, reusing the confirm() pattern of the settings panes' destructive actions. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
- App.tsx onRemove: await store.remove and toast errors instead of firing the success toast early and leaving the rejection unhandled - App.tsx onMoveQueue: only send setQueuePosition for rows whose position actually changed (the backend stores positions verbatim, no reshuffle) - format.ts: floor/carry the minor unit in fmtDuration (no more '1h 60m' / '1d 24h'), clamp fmtPercent below 100 for progress in [0.9995,1), and return an em-dash from fmtBytes/fmtETA on NaN/undefined inputs - store.ts rowToDetail: seed ratio with 0 — bytes_done/total_bytes is progress, not up/down ratio, and flashed wrong in the inspector - AddTorrentModal: key the reset effect on the open transition only, so a late-arriving defaultSavePath no longer wipes in-progress input - ShareTorrentModal: discard refresh() responses that land after the dialog switched to a different torrent https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…back off The reconnect timer was never cancelled and ensureSocket didn't re-check handlers.size, so a timer scheduled before the last unsubscribe could resurrect a socket with zero subscribers and leak it; reconnects also hammered a down server at a fixed 1s forever. Bail out of ensureSocket when no handlers remain, clear the timer when the last handler unsubscribes, and reconnect with capped exponential backoff (1s doubling to 30s, reset on successful open). https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Start closed the previous loop's stop channel but left w.stop/w.done pointing at the closed channels when it early-returned (empty path or stat failure), so a later Stop() — or a concurrent Stop() during the unlocked done-wait — closed an already-closed channel and panicked. Both Start and Stop now swap the fields to nil under the lock before closing, so no code path can observe a closed channel. poll() also re-added every .torrent file on every 5-second tick when delete_after_add is off, re-triggering a verify each time. The run loop now keeps a processed map keyed by path with a size+mtime fingerprint: unchanged files are skipped, changed files count as new, and entries for files that disappear are dropped. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…aused/SetCompletedAt
Torrents.Save's ON CONFLICT clause reset added_at, paused,
queue_position, force_start, category_id, rate limits, sequential and
completed_at on every duplicate add — so an RSS re-match, watch-folder
re-scan, or the user re-dropping the same file silently wiped queue
order, category, pause state and limits. The upsert now refreshes
identity/metadata only (name, magnet when non-empty, metainfo);
save_path stays too since the engine keeps using the original storage
location for an already-known infohash. The dead seedingStartedAt
plumbing in Save is removed — dedicated methods own that column.
New DAOs, styled after SetQueuePosition/SetForceStart/SetSequential:
- SetPaused: lets the service persist user pause/resume so
RestoreOnStartup's paused branch is no longer dead code.
- SetCompletedAt: lets the service record first completion so
missing-files detection can arm on restore.
https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Pause/Resume/PauseAll/ResumeAll only flipped engine state; the DB paused column was written once at add time (false), so the restore branch reading r.Paused never fired and user-paused torrents resumed silently on the next launch. The four methods now also call Torrents.SetPaused, log-don't-fail on DB errors (engine state is already applied). completed_at was likewise never written, so RestoreOnStartup never armed the engine's missing-files detection and the inspector's completed_at stayed empty. BuildTorrentTickSnapshot — the tick choke point both the desktop stream and mosaicd's streamTicks funnel through — now persists it the first time it observes a torrent complete, gated on the record's CompletedAt being nil so it's written exactly once. detailToDTO surfaces the value as DetailDTO.CompletedAt. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
The per-feed seen set was in-memory only, so every restart re-added
every matching item still present in the feed, and markSeen wiped the
whole set once it hit 1000 entries — re-adding up to a feed's worth of
items on the next poll. Seen GUIDs now live in a new rss_seen table
(feed_id FK ON DELETE CASCADE, unique(feed_id, guid)), hydrated into
the in-memory cache at startup; Add prunes the oldest rows beyond
rssSeenCap per feed instead of wiping.
Hygiene in the same area:
- filter regexes are compiled once per poll instead of once per item,
and invalid patterns are logged instead of silently skipped
- feed bodies are parsed through a 10 MB io.LimitReader, matching the
existing torrent-fetch cap
- gofeed.Parser is constructed per Parse call — the shared instance
raced its lazy translator init between run() and PollNow /
GetFeedItems request goroutines
https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Both exits of verifyAndStart (fast-resume and full-hash) reset every file to PiecePriorityNormal, silently overwriting the sequential gradient if the user enabled sequential download while the verify was in flight. New applyPostVerifyPriorities checks a.sequential[id] under pausedMu and re-applies the gradient via applySequentialPriorities instead of resetting when the flag is set. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…face stops http.Server.Shutdown does not touch hijacked WebSocket connections, so disabling (or reconfiguring) the web interface left established clients registered in the hub — bootstrap.StreamTicks kept pushing live torrent frames to them via sendFrameToUser, which bypasses the cancelled bus. Session cookies also stayed valid in the in-memory SessionStore and worked again on re-enable. - Add Hub.DisconnectAll (RevokeUser pattern, StatusGoingAway) and call it from shutdownLocked so every stop/restart path hangs up live WebSockets. - Revoke all sessions on full disable (Apply with Enabled=false) and on Server.Stop. A port/bind reconfigure restart deliberately keeps sessions: the SPA reconnects to the new listener with its still-valid cookie instead of forcing a re-login; disabling the interface is the case where remote credentials must stop working immediately. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…n exit Bus.Subscribe had no counterpart, so every remote.Server (re)start — each of which runs go hub.Run(ctx) — left a dead channel in b.subs forever after the run's context was cancelled. Unsubscribe removes and closes the channel under the write lock (mutually exclusive with Publish's read lock, so no send-on-closed race) and is a no-op after Close, which already closed and dropped every subscriber. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…ions SessionStore.Valid slides expiry forward 12h on every hit, and the WebSocket loop re-validates its session token every 30s — so any open tab renewed its session forever even when fully idle. Add a read-only Peek (validates without sliding) and use it in the recheck; real user activity still extends the window via Valid on each API request. Also fix the stale maxSessions comment claiming oldest-entry eviction; Create actually returns ErrTooManySessions when full. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
torrents.category_id REFERENCES categories(id) with no ON DELETE action and foreign_keys on, so deleting a category still assigned to a torrent failed with an FK violation that surfaced as a generic 500 in the web UI. Categories.Delete now NULLs the torrents' category_id and deletes the category in a single transaction. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Thirteen handlers (ListTorrents, GlobalStats, ListCategories, ListTags, GetDefaultSavePath, GetLimits, ListScheduleRules, ListFeeds, ListFiltersByFeed, RotateAPIKey, CheckForUpdate, InstallUpdate, RefreshBlocklist) wrote the raw internal error text with writeErr(w, 500/502, err), bypassing writeServiceErr's allowlist and leaking SQL strings / file paths / network internals into HTTP responses. All now go through writeServiceErr, which also restores the proper 403/404 mappings for sentinel errors. AddFeedItem additionally returned an implicit 200 with an empty body when decodeJSON failed; it now writes the error like its siblings. The two updater tests move from 500 to 400: "updater disabled" is on writeServiceErr's user-facing validation allowlist, so it surfaces as a 400 with the friendly message. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
The limiter check ran before the service's admin authorization and was keyed by the TARGET user id, sharing buckets with ChangeMyPassword — so any authenticated user could drain another user's password-change bucket by spamming admin resets they weren't allowed to perform. Keying by the acting caller makes the pre-authorization charge land on the abuser's own bucket while still throttling a compromised admin session. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
/api/login was mounted outside the OriginGuard group, so a cross-site form post could silently log a victim's browser into an attacker-controlled account. The guard's existing semantics are preserved unchanged: same-origin Origin (or Referer) passes, mismatched or absent both is rejected, and Bearer-carrying requests skip the check. The SPA is same-origin and always sends Origin on fetch, so legitimate logins are unaffected; tests now set a matching Origin via a shared loginReq helper. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…d refresh The browser transport invoked PollFeedNow but no REST route existed, so the per-feed "Refresh now" button failed outside Wails. Add the route + handler (service enforces CanManageRSS like the other feed mutations) and the matching PollFeedNow entry in the HTTP transport's ROUTES table. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Service.CheckSeedLimits was only wired into the desktop-only streamWailsEvents loop, so the headless daemon never paused torrents that hit their ratio/time limits. Move the 30s seed-check ticker into bootstrap.StreamTicks (svc-level, flavor-agnostic, runs in both entry points) and remove it from streamWailsEvents so the desktop — which runs both loops — doesn't check twice. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
The darwin in-app updater downloads Mosaic-<ver>-darwin-universal.tar.gz and validates it against the SHA256SUMS release asset, but make-checksums.sh never hashed tarballs — so every macOS in-app update hard-failed checksum validation. Add *.tar.gz to the artifact glob. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
When running inside an AppImage, selfupdate.ExecutablePath() resolves to the payload binary on the read-only FUSE mount (/tmp/.mount_*/usr/bin/ mosaic), so every in-app update failed at the write step. The AppImage runtime exports APPIMAGE=<path to the .AppImage file> — the same signal DetectInstallSource already trusts — and the downloaded Linux asset is itself a new AppImage, so swap that file instead. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
compareVersions stripped everything after the numeric core, so v0.8.0-rc1 compared equal to v0.8.0 and rc users were never offered rc2 or the final release. Split off the pre-release identifier (and drop +build metadata), treat a pre-release as lower than the same-numbered release, and compare two pre-releases lexicographically. Add table tests. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
GitHubSource baked the channel into its cached go-selfupdate handle at first use, and main.go only started the Schedule goroutine when the toggle was on at boot — so SetUpdaterConfig changes silently required a restart. Now: - GitHubSource.SetChannel mutates the channel under the lock and lazyInit rebuilds the cached updater (and drops the stale release) when the channel it was built for no longer matches. - Service gains an OnUpdaterConfigChange hook (mirrors OnWebConfigChange / OnDesktopIntegrationChange), fired after SetUpdaterConfig commits. - main.go wires the hook to push the channel into the live source and start/stop the Schedule goroutine (guarded so flapping the toggle doesn't stack goroutines; apt-managed installs stay dormant). https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…sten port A benign second no-args launch (EarlyForwardLaunchArgs only forwards when args exist; macOS never does) runs bootstrap.Init before Wails's SingleInstanceLock can dispatch it. The engine finds the configured port held by the first instance, falls back to an OS-picked ephemeral, and bootstrap persisted that throwaway port into the shared DB — silently breaking the user's router port-forward for the next launch. Persist the actual-bound port only when no port was configured (listen_port 0 = OS-picked), preserving the original first-run intent of pinning an OS-assigned port. A fallback that differs from an explicitly configured port is now log-only; the configured value is kept so the next start retries it. Also refresh main.go's stale comment claiming port-bind failure is fatal. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…rgs, add shutdown grace Four related lifecycle fixes in the Wails shell: - a.ctx was written by startup() and read unsynchronized from the tray, the Linux second-instance listener, and the updater callback. Publish it under a mutex and route every read through a.context(). - HandleLaunchArgs could run before startup() set the context (the second-instance listener binds before Wails runs); AddMagnet with a nil ctx panicked in a bare goroutine and aborted the process. Args that arrive pre-startup are now buffered and drained by startup(), matching the queueing main.go's comment always promised. - Wails never cancels the OnStartup context, so streamWailsEvents and bootstrap.StreamTicks kept calling the engine/DB while main's deferred cleanup tore them down. The tick goroutines now run on a cancellable child context; the new App.shutdown (wired as OnShutdown) cancels it and sleeps a 200ms grace, mirroring mosaicd. - main.go's log.Fatal after wails.Run skipped all deferred teardown (engine/DB never closed); log the error and return instead. Also gate Wails tick emission on window visibility: while hidden in the tray (close-to-tray hide, StartHidden) the 2Hz list + 1Hz stats/detail snapshots are skipped; ShowWindow/ShowSettings flip the flag back before showing. macOS AppKit-level hide isn't observable, so the flag conservatively stays true there. Seed-limit enforcement lives in bootstrap.StreamTicks and is unaffected. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…g home dir - Unknown YAML keys were silently ignored, so a typo'd key (e.g. listen-port) silently fell back to defaults. Decode with KnownFields(true) so typos fail loudly, consistent with corrupt YAML already being fatal. Empty/comment-only files still load defaults. - listen_port accepted any int; validate the merged result (YAML + env) to [0, 65535], mirroring mosaicd's --port flag validation. - defaults() ignored the os.UserHomeDir error, producing a CWD-relative "Downloads" save path. Leave the default empty on error and fail Load with a clear message unless YAML/env supplies default_save_path. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
… race - /tmp fallback dir (/tmp/mosaic-<uid>, used when XDG_RUNTIME_DIR is unset): MkdirAll accepts a pre-existing path, which another local user could have planted to hijack or eavesdrop on the socket. After MkdirAll, Lstat and require a real directory owned by the current UID with no group/other permission bits, else error out. - Stale-socket recovery raced: two simultaneous starters could both probe-fail and Remove, with the bind loser giving up permanently. Loop the bind/probe/remove sequence up to 3 times so the loser re-probes the (now live) winner or binds the freed path. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
If engine/DB shutdown hangs after the first signal, further signals were silently swallowed by the buffered channel, leaving a foreground operator no way out short of SIGKILL. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…x per-peer rate and limiter races Four related anacrolix backend fixes: - Resume and ScheduledPause's re-enable path hardcoded SetMaxEstablishedConns(80), resetting a user's configured MaxPeersPerTorrent on any pause/resume or scheduler churn. The cap now lives in an atomic on the backend (initialized from config, updated by ApplyPerTorrentMaxPeers) and both paths restore it; the stale comment claiming "resuming will pick up the new cap" is fixed. - Per-peer rate sampling in DetailedSnapshot shared one prev-sample map across all consumers, so two back-to-back callers (two web users focused on the same torrent) saw dt of microseconds and zero/spiking rates — the same multi-reader hazard the torrent-level sampler already fixed. Per-peer rates only matter while a Peers tab is focused, so a central sampler would burn cycles when nobody is looking; instead a 250ms minimum-dt guard reuses the previously computed rate and keeps the old sample for the next real tick. - Per-torrent limiter lifecycle is now tracked with an explicit running flag under perLimitMu. Clearing limits to (0,0) and re-setting them within one 500ms tick could spawn a second runPerTorrentLimiter while the first never observed the transient (0,0); the two then fought over Allow/DisallowDataDownload. The goroutine clears the flag under the lock with a limits re-check before exiting, so set-after-clear either reuses the live limiter or deterministically starts a fresh one. - List() spawned a saveSnapshotIfComplete goroutine per completed torrent per tick; with a nil snapshot store or persistent write failures the flag never latched and N goroutines leaked several times a second forever, each taking the client lock. List now skips when the store is nil, and spawnSnapshotSave adds an in-flight guard plus a 30s retry backoff after failures (cleared on Remove/Recheck). https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…tive rules Two schedule-engine tick bugs: - The active-window check (minutes < StartMin || minutes >= EndMin) meant a rule spanning midnight (StartMin > EndMin, e.g. 22:00-06:00) never matched. Window containment now lives in ruleWindowContains: when StartMin > EndMin the window wraps, active when minutes >= StartMin || minutes < EndMin. - Change detection keyed on the active rule's ID only, so editing the currently-active rule's limits was silently ignored until the rule deactivated and reactivated. The applied key now includes the rule's DownKbps/UpKbps/AltOnly values, so an edit re-applies on the next tick. Adds a table test for the window logic (incl. wrap boundaries) and a deterministic tick-driven test for the edit-reapply path. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
… CheckForUpdate
- ListTorrentsFromSnapshot gave a torrent missing its DB record
addedAt = time.Now(), which changed every tick — scrambling the
added_at sort and defeating streamTicks' frame dedup. It now uses the
zero time, and toDTO/detailToDTO serialize timestamps through
unixOrZero so a zero time becomes added_at=0 instead of
time.Time{}.Unix()'s -62135596800 (which DetailDTO emitted whenever
lookupRecordTimes failed).
- CheckForUpdate performed an outbound HTTP check and wrote two settings
rows with no permission check, unlike its siblings InstallUpdate and
SetUpdaterConfig. It now requires CanChangeSettings(); the updater
runtime toggles introduced recently are untouched.
https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…rce single Start - truncate sliced bytes (s[:n-1]) and could split a UTF-8 sequence mid-rune, sending mojibake to the OS notifier for non-ASCII torrent names. It now counts and slices runes. - Stop() blocked forever on <-s.done when Start was never called (nothing ever closes done). It now tracks started/stopped under the existing mutex: a no-op wait before Start, idempotent after. - Start's doc comment claimed calling it twice panics, but it silently double-subscribed to the engine event bus. It now enforces the documented contract. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
CreateFilter/UpdateFilter stored the pattern verbatim; compilation only happens per-poll in rss_poller.go, which logs and skips invalid filters — so a bad regex was accepted silently and simply never matched. Both paths now run regexp.Compile and return a "filter regex is invalid: …" error, registered in userFacingValidationPrefixes so the SPA gets a 400 with the parse error instead of a generic 500. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
Categories.Delete was recently fixed to NULL out torrents.category_id in a transaction, but rss_filters.category_id (migrations/0005_rss.sql) has the same no-ON-DELETE FK — deleting a category referenced by an RSS filter still failed with an FK violation surfaced as a 500. The same transaction now detaches rss_filters too; the in-use delete test covers both references. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
…vive restarts CheckSeedLimits computed ratio from anacrolix's session-scoped BytesUp, which resets every process start — ratio limits restarted from 0 on every launch and effectively never fired across sessions. It also issued one torrents.Get per completed torrent every 30s pass. - Migration 0016 adds total_uploaded/total_downloaded columns on torrents, with a Torrents.AddTransferTotals DAO that increments them. - CheckSeedLimits now bulk-loads records once per pass via the existing list DAO, and checkpoints the engine's session counters into the persisted totals for every persisted torrent: an in-memory map of last-observed counters per infohash yields per-pass deltas; a counter lower than the last observation means an engine restart and the full current value is the delta. Failed writes roll the observation back so the delta is retried next pass; observations for removed torrents are pruned so a re-added infohash starts fresh. - The ratio check now uses the persisted cumulative upload over BytesDone (denominator unchanged — piece completion is already stable across restarts). Covered by DAO tests plus service-level tests for the pause threshold, the counter-reset (restart) accumulation, and download-phase checkpointing; FakeBackend grows a SetSessionStats helper. https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR addresses several critical concurrency bugs and adds seed-limit enforcement for seeding torrents.
Summary
The changes fix race conditions in the app startup sequence, add persistent seed-limit tracking across engine sessions, improve RSS feed deduplication, and enhance the updater with better version comparison and AppImage support.
Key Changes
Concurrency & Startup Fixes:
Appto handle launch args arriving before startup completes (e.g., Linux second-instance listener)windowVisibleflag to gate Wails event emission when the window is hiddenSeed Limit Enforcement:
TotalUploaded/TotalDownloadedcumulative counters toTorrentRecordto survive engine session resetsCheckSeedLimits()to pause torrents exceeding ratio/duration limits using persisted totalssessionCounterstracking to compute deltas between observationsRSS & Feed Improvements:
RSSSeenpersistence layer to track seen feed GUIDs across restarts (fixes re-adding old items on restart)rssBodyCapto limit feed body parsing to 10 MBWatchFolderdouble-close panic by swapping stop/done channels before closingEngine & Limiter Fixes:
maxConnsPerTorrentatomic field to persist user's per-torrent connection cap across pause/resume cycleslimiterRunningmap to prevent spawning duplicate rate-limiter goroutinessnapshotInflightandsnapshotRetryAtto prevent goroutine leaks on persistent snapshot write failuresUpdater Improvements:
OnUpdaterConfigChange()callback to push channel changes to liveGitHubSourcewithout restartcompareVersions()with proper semantic versioning and pre-release handlingAPPIMAGEenv var and swapping the runtime file instead of the squashfs mountCheckForUpdate()to gate outbound HTTP requestsOther Fixes:
unixOrZero()helper to serialize zero timestamps as 0 instead of year-1 datesListTorrentserror handling to usewriteServiceErrfor consistent HTTP status codesverifyPrivateDir()security check for/tmpfallback socket directory on LinuxSubscriberlifecycle to prevent blocking ondonewhenStart()never ranUnsubscribe()to event bus to prevent dead channel leaks on server restartsScheduleEnginechange detection to re-apply limits when edited rule is activeNotable Implementation Details
pendingArgsslice underctxMulock, drained by startup() once context exists(current - lastObserved) + persisted = new totalrss_seentable with cascade delete on feed removallimiterRunningmap to prevent duplicates on rapid limit changessnapshotRetryAtgates retry attemptshttps://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7