Skip to content

Fix concurrency issues and add seed limit enforcement - #7

Merged
exec merged 37 commits into
mainfrom
claude/code-review-optimization-klzin8
Jun 12, 2026
Merged

Fix concurrency issues and add seed limit enforcement#7
exec merged 37 commits into
mainfrom
claude/code-review-optimization-klzin8

Conversation

@exec

@exec exec commented Jun 11, 2026

Copy link
Copy Markdown
Owner

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:

  • Add mutex-protected context initialization in App to handle launch args arriving before startup completes (e.g., Linux second-instance listener)
  • Buffer pending launch args and drain them once the context exists, preventing nil-context panics
  • Separate tick goroutines into their own cancellable context to ensure clean shutdown
  • Add windowVisible flag to gate Wails event emission when the window is hidden

Seed Limit Enforcement:

  • Add TotalUploaded/TotalDownloaded cumulative counters to TorrentRecord to survive engine session resets
  • Implement CheckSeedLimits() to pause torrents exceeding ratio/duration limits using persisted totals
  • Add sessionCounters tracking to compute deltas between observations
  • Add comprehensive tests covering ratio-based pausing and counter persistence

RSS & Feed Improvements:

  • Add RSSSeen persistence layer to track seen feed GUIDs across restarts (fixes re-adding old items on restart)
  • Replace in-memory gofeed parser with persistent dedup via database
  • Add rssBodyCap to limit feed body parsing to 10 MB
  • Fix WatchFolder double-close panic by swapping stop/done channels before closing

Engine & Limiter Fixes:

  • Add maxConnsPerTorrent atomic field to persist user's per-torrent connection cap across pause/resume cycles
  • Add limiterRunning map to prevent spawning duplicate rate-limiter goroutines
  • Add snapshotInflight and snapshotRetryAt to prevent goroutine leaks on persistent snapshot write failures

Updater Improvements:

  • Add OnUpdaterConfigChange() callback to push channel changes to live GitHubSource without restart
  • Add compareVersions() with proper semantic versioning and pre-release handling
  • Fix AppImage installation by detecting APPIMAGE env var and swapping the runtime file instead of the squashfs mount
  • Add permission check to CheckForUpdate() to gate outbound HTTP requests

Other Fixes:

  • Add unixOrZero() helper to serialize zero timestamps as 0 instead of year-1 dates
  • Fix ListTorrents error handling to use writeServiceErr for consistent HTTP status codes
  • Add verifyPrivateDir() security check for /tmp fallback socket directory on Linux
  • Fix Subscriber lifecycle to prevent blocking on done when Start() never ran
  • Add Unsubscribe() to event bus to prevent dead channel leaks on server restarts
  • Fix ScheduleEngine change detection to re-apply limits when edited rule is active
  • Add rune-aware truncation for non-ASCII torrent names in notifications
  • Add tracker add/remove routes to frontend HTTP transport
  • Improve config validation to reject unknown YAML keys and validate port ranges
  • Add session lifecycle tests for reconfigure vs. full disable transitions

Notable Implementation Details

  • Launch args are buffered in pendingArgs slice under ctxMu lock, drained by startup() once context exists
  • Seed limit checking uses delta computation: (current - lastObserved) + persisted = new total
  • RSS dedup now survives restarts via rss_seen table with cascade delete on feed removal
  • Rate limiter goroutines tracked in limiterRunning map to prevent duplicates on rapid limit changes
  • Snapshot save failures no longer leak goroutines; snapshotRetryAt gates retry attempts

https://claude.ai/code/session_0112xsVQpsrg8TzRqPRqzbZ7

claude added 30 commits June 10, 2026 16:29
… 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
claude added 7 commits June 10, 2026 20:26
…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
@exec
exec merged commit 54cd8a8 into main Jun 12, 2026
2 checks passed
@exec
exec deleted the claude/code-review-optimization-klzin8 branch June 12, 2026 00:22
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.

2 participants