Skip to content

refactor(ipc): serve search from the daemon - #140

Merged
lestex merged 1 commit into
mainfrom
ipc/daemon-search
Oct 15, 2026
Merged

lestex merged 1 commit into
mainfrom
ipc/daemon-search

Conversation

@lestex

@lestex lestex commented Oct 15, 2026

Copy link
Copy Markdown
Owner

find was the only operation that bypassed the daemon. That was an implementation
convenience - it avoided touching the gob protocol - not a decision, and it is the
wrong home for what is coming: making a Cloudflare-protected tracker work needs a
logged-in session, a browser session held open by an external solver, and a result
cache. None of that can live in a TUI you quit and reopen.

So the daemon builds the Searcher and answers Search and SearchSources, and
both clients ask it. The searcher is injected into ipc.Serve the way previewURL
already is, and nil - no indexer configured - is the ordinary case rather than a
failure, answered by naming the keys to set.

Two protocol problems had to be solved first

A slow search would have frozen the TUI. Requests on a connection were answered
strictly in order, and a search can take a minute - a browser page load and a
challenge solve at the far end - so every refresh would have queued behind one.
Search is now answered on its own goroutine. Safe because replies already carry
their sequence number and the client matches on it, and because send already
holds the write mutex the event pump uses.

The test for this fails without the change:

--- FAIL: TestASlowSearchDoesNotBlockOtherCalls (5.01s)
    search_test.go:196: List blocked behind an in-flight search

The 30s client timeout is too short for that worst case. Search gets its own
120s one via a new callFor. Raising the shared constant instead would mean a
genuinely hung daemon took two minutes to report.

A rule bent on purpose

ipc now imports indexer as well as engine, to carry []indexer.Result on
the wire. The rule exists to prevent cycles and keep the daemon lean; indexer is
a stdlib-only leaf like engine, so it does neither, and Response.Snapshot []engine.TorrentSnapshot is the same pattern already. CLAUDE.md records it, along
with the note about the daemon "never linking an HTTP client", which this
deliberately reverses.

Client changes

The TUI takes a narrow local interface rather than indexer.Searcher - all a
client needs is the one call, *ipc.Client satisfies it, and the existing test
double keeps working. What "configured" means moves with it: the client is always
present once attached, so its presence says nothing, and the daemon naming at least
one source is the real test (canFind).

Per-source warnings render under the results, so a source being down does not hide
what the others found. Response.Errs already existed for AddBatch's partial
success and is reused verbatim.

The CLI loses its daemon-free path, which it had and nothing else did.
--check now names what the daemon will search (searching: jackett) rather than
reporting a health probe - the more useful answer, since it separates "nothing
configured" from "searched and found nothing".

Verified beyond the tests

Against a stand-in indexer with a deliberate six-second delay:

  • find through a spawned daemon returned in 6.0s - the full server delay, no
    longer near any timeout.
  • In the TUI: the loading state appeared, esc closed the overlay mid-search
    (so the interface stayed responsive), the reply landing six seconds later
    correctly did not reopen it (the sequence guard, observed live rather than
    only unit-tested), and enter added a torrent that went on to download.

make check, make e2e (18/18) and make docs-build all clean. Next in this
series: per-source [search.*] config with multi-source merge, then
internal/flaresolverr, then the rutracker plugin.

find was the only operation that bypassed the daemon. That was an
implementation convenience - it avoided touching the gob protocol - not a
decision, and it is the wrong home for what is coming: making a
Cloudflare-protected tracker work needs a logged-in session, a browser
session held open by an external solver, and a result cache. None of that
can live in a TUI you quit and reopen. The daemon is the only part that
lives long enough to hold it.

So the daemon builds the Searcher and answers Search and SearchSources,
and both clients ask it. The searcher is injected into ipc.Serve the way
previewURL already is, and nil - no indexer configured - is the ordinary
case rather than a failure, answered by saying which keys to set.

Two things about the protocol had to give first, and both would have been
discovered the hard way otherwise.

A search can take a minute: a browser page load and a challenge solve at
the far end. Requests on a connection were answered strictly in order, so
every refresh the TUI makes would have queued behind one and the
interface would have looked frozen. Search is now answered on its own
goroutine. That is safe because replies already carry their sequence
number and the client matches on it, and because the send closure already
holds the write mutex the event pump uses. The test for it fails without
the change, blocking a concurrent List for the full five seconds.

The client's 30s timeout is also too short for that worst case, so Search
gets its own 120s one. Raising the shared constant instead would mean a
genuinely hung daemon took two minutes to report.

ipc now imports indexer as well as engine, to carry []indexer.Result on
the wire. The rule it bends exists to prevent cycles and keep the daemon
lean; indexer is a stdlib-only leaf like engine, so it does neither, and
Response.Snapshot []engine.TorrentSnapshot is the same pattern already.
CLAUDE.md says so now, along with the note about the daemon not linking
an HTTP client, which this deliberately reverses.

The TUI takes a narrow local interface rather than indexer.Searcher: all
a client needs is the one call, *ipc.Client satisfies it, and the
existing test double keeps working. What "configured" means moves with
it - the client is always present once attached, so it says nothing, and
the daemon naming at least one source is the real test. Per-source
warnings are rendered under the results, so a source being down does not
hide what the others found.

The CLI loses its daemon-free path, which it had and nothing else did.
--check now names what the daemon will search rather than reporting a
health probe, which is the more useful answer: it separates "nothing
configured" from "searched and found nothing".

Verified against a stand-in indexer with a six second delay, not just in
tests: find through a spawned daemon in 6.0s, and in the TUI the loading
state, esc closing the overlay mid-search, the late reply correctly not
reopening it, and enter adding a torrent that went on to download.
@lestex
lestex merged commit fd5ece1 into main Oct 15, 2026
16 checks passed
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.

1 participant