feat(daemon): say what to do next on startup, and prove multiple clients agree

Starting the daemon printed 'ready' and nothing else, so the token and the
next step were both somewhere else. It now prints what other commands can do
from here, and when --bind is given, the token itself with the two config
commands a remote client needs. All on stderr, so stdout stays empty and
daemon still composes in a pipeline.

Adds the guarantee daemon mode is for as a test: eight clients connected at
once are each answered correctly, none has its replies crossed with another's,
and the daemon still counts all eight. Plus 250 connect-and-drop cycles, well
past the connection cap, to catch a per-connection resource that is not
released — the shape a daemon that works for a while and then refuses
everybody would take.

A connection the daemon drops before it can be served now logs why at warning
level. A client only ever sees 'the connection closed', so that line is the
only account of the reason.
This commit is contained in:
Michel de Graaf
2026-07-27 14:39:31 +02:00
parent 2a906df1b5
commit 01152d91f6
8 changed files with 226 additions and 66 deletions
+20 -20
View File
@@ -123,21 +123,21 @@ it holds the session while everything else borrows it:
```bash ```bash
soulseek-rs daemon # stays in the foreground; leave it running soulseek-rs daemon # stays in the foreground; leave it running
soulseek-rs search 'aphex twin' # no login, no flags — uses the daemon soulseek-rs search 'aphex twin' # no login, no flags; uses the daemon
soulseek-rs # the TUI attaches to the same session soulseek-rs # the TUI attaches to the same session
``` ```
**Why you want this.** The server allows one session per account. Without a **Why you want this.** The server allows one session per account. Without a
daemon, two commands at once either cut each other off or push you into a daemon, two commands at once either cut each other off, or push you into a
throwaway username per run — account churn on infrastructure other people throwaway username per run. That second option is account churn on
maintain and share. A daemon is one login for as long as you work, and one infrastructure other people maintain and share. A daemon is one login for as
share index instead of a rescan every invocation. It also lets a download long as you work, and one share index instead of a rescan every invocation. It also lets a download
outlive the command that queued it, and keeps collecting chat while nothing is outlive the command that queued it, and keeps collecting chat while nothing is
attached. If you drive this from a script or an agent, start one. attached. If you drive this from a script or an agent, start one.
Commands find it by themselves. It listens on a Unix socket only your user can Commands find it by themselves. It listens on a Unix socket only your user can
open, so there is nothing to configure and no secret to pass around — the same open, so there is nothing to configure and no secret to pass around. Docker and
arrangement Docker and ssh-agent use. With no daemon running, everything ssh-agent make the same arrangement. With no daemon running, everything
behaves exactly as it did before; `--no-daemon` forces that for one run. behaves exactly as it did before; `--no-daemon` forces that for one run.
From a script, start it and *wait* for it: it takes a second or two to log in, From a script, start it and *wait* for it: it takes a second or two to log in,
@@ -162,14 +162,14 @@ Everything that touches the network goes through it. `config`, `skills`,
`completions` and `portmap` stay local, because they are about this machine `completions` and `portmap` stay local, because they are about this machine
rather than a session. `shares add`/`remove` do both: they write the config rather than a session. `shares add`/`remove` do both: they write the config
file *and* update a running daemon, so a folder is served straight away rather file *and* update a running daemon, so a folder is served straight away rather
than at its next start. Files land on the *daemon's* filesystem — `daemon than at its next start. Files land on the *daemon's* filesystem, and `daemon
status` reports where. status` reports where.
#### A download box you drive from your laptop #### A download box you drive from your laptop
This is what daemon mode is really for. Put soulseek-rs on the machine that This is what daemon mode is really for. Put soulseek-rs on the machine that
should be doing the downloading — a home server, a NAS, a Raspberry Pi, a should be doing the downloading: a home server, a NAS, a Raspberry Pi, a
seedbox — and drive it from wherever you happen to be. seedbox. Then drive it from wherever you happen to be.
**On that machine**, run the daemon and let it accept connections from your **On that machine**, run the daemon and let it accept connections from your
network: network:
@@ -189,9 +189,8 @@ soulseek-rs config set daemon nas.local:5030
soulseek-rs config set daemon_token <the token you copied> soulseek-rs config set daemon_token <the token you copied>
``` ```
That is the whole setup. Every command from now on is the normal command — That is the whole setup. Every command from now on is the normal command. Your
your laptop needs no Soulseek account of its own, because it is using the laptop needs no Soulseek account of its own, because it uses the server's:
server's:
```bash ```bash
soulseek-rs search 'aphex twin' --json soulseek-rs search 'aphex twin' --json
@@ -212,7 +211,7 @@ soulseek-rs daemon stop # shuts down the remote daemon
``` ```
One caution: there is no encryption on that TCP port. On a home network that One caution: there is no encryption on that TCP port. On a home network that
is usually fine. Over the internet, don't expose it — reach it through SSH is usually fine. Over the internet, don't expose it. Reach it through SSH
instead, either with a tunnel (`ssh -L 5030:localhost:5030 nas`) or by running instead, either with a tunnel (`ssh -L 5030:localhost:5030 nas`) or by running
the commands over `ssh` on the box itself. the commands over `ssh` on the box itself.
@@ -220,9 +219,10 @@ the commands over `ssh` on the box itself.
The daemon's interface is open and documented, so nothing about it is specific The daemon's interface is open and documented, so nothing about it is specific
to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described
by an [OpenRPC](https://open-rpc.org) document — by an [OpenRPC](https://open-rpc.org) document,
[`docs/openrpc.json`](docs/openrpc.json), which the daemon also serves itself [`docs/openrpc.json`](docs/openrpc.json). The daemon serves that document
via `rpc.discover`, so a client can ask a running daemon what it can do. itself via `rpc.discover`, so a client can ask a running daemon what it can
do.
OpenRPC is the JSON-RPC counterpart to OpenAPI: point a client generator at OpenRPC is the JSON-RPC counterpart to OpenAPI: point a client generator at
that document and you get typed bindings in whatever language you are working that document and you get typed bindings in whatever language you are working
@@ -230,7 +230,7 @@ in. If you want an Android or iOS app that queues downloads on your home
server from the sofa, or a small web page that shows what is transferring, you server from the sofa, or a small web page that shows what is transferring, you
have everything you need and no permission to ask for. Searches, transfers, have everything you need and no permission to ask for. Searches, transfers,
rooms, private messages and shares are all on the same interface this CLI rooms, private messages and shares are all on the same interface this CLI
uses — there is no private back channel it keeps for itself, and live updates uses. There is no private back channel it keeps for itself, and live updates
are pushed to every connected client rather than polled for. are pushed to every connected client rather than polled for.
[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the parts a schema [`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the parts a schema
@@ -459,8 +459,8 @@ soulseek-rs serve --follow # add --upload-slots N to widen the queue
Uploads only show up inside a running `serve`; every other command is Uploads only show up inside a running `serve`; every other command is
short-lived with nothing to serve, so there is no `uploads list`. Managing short-lived with nothing to serve, so there is no `uploads list`. Managing
transfers across commands — pause, resume, a queue that outlives one transfers across commands (pause, resume, a queue that outlives one
invocation — is what [daemon mode](#daemon-mode) is for. invocation) is what [daemon mode](#daemon-mode) is for.
Downloads run under a deadline (`--timeout`, 300s by default) and Downloads run under a deadline (`--timeout`, 300s by default) and
`--max-concurrent-downloads` at a time, so an unattended run always ends. `--max-concurrent-downloads` at a time, so an unattended run always ends.
+13 -13
View File
@@ -2,8 +2,8 @@
`soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so `soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so
several clients can share one Soulseek session. The machine-readable contract several clients can share one Soulseek session. The machine-readable contract
is [`openrpc.json`](openrpc.json) — an [OpenRPC](https://open-rpc.org) is [`openrpc.json`](openrpc.json), an [OpenRPC](https://open-rpc.org)
document, the JSON-RPC counterpart to OpenAPI. Point a client generator at it, document and the JSON-RPC counterpart to OpenAPI. Point a client generator at it,
or at a running daemon via `rpc.discover`, and you have a client in your or at a running daemon via `rpc.discover`, and you have a client in your
language. language.
@@ -30,8 +30,8 @@ once.
The local socket is the Docker and ssh-agent bargain: if the operating system The local socket is the Docker and ssh-agent bargain: if the operating system
let you open it, you are the user who started the daemon, and there is nothing let you open it, you are the user who started the daemon, and there is nothing
further to prove. This is what lets a local client — a script or an agent — further to prove. A local client, whether a script or an agent, works with no
work with no configuration at all. configuration at all.
TCP always requires the token, and a token that *is* offered is always TCP always requires the token, and a token that *is* offered is always
checked, on either transport, so a wrong one never passes. Failed attempts checked, on either transport, so a wrong one never passes. Failed attempts
@@ -62,7 +62,7 @@ the old shape.
## Requests ## Requests
Parameters are by name — a JSON object, never an array. Each method's fields Parameters are by name: a JSON object, never an array. Each method's fields
are published as individual OpenRPC content descriptors, so a generated client are published as individual OpenRPC content descriptors, so a generated client
sends exactly the object the daemon deserializes. `rpc.discover` returns that sends exactly the object the daemon deserializes. `rpc.discover` returns that
same document from the running daemon. same document from the running daemon.
@@ -82,8 +82,8 @@ silence, including when it fails.
``` ```
An error always carries an `id`, explicitly `null` when the request was too An error always carries an `id`, explicitly `null` when the request was too
malformed to have one — so do not read a missing `id` as "this is a malformed to have one. Do not read a missing `id` as "this is a notification"
notification" and drop it. and drop it.
Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown
method, `-32602` bad parameters) mean what they always mean. Application method, `-32602` bad parameters) mean what they always mean. Application
@@ -102,7 +102,7 @@ as it branches on the exit code.
## Events ## Events
The daemon pushes notifications — no `id`, so never answer them: The daemon pushes notifications. They carry no `id`, so never answer them:
```json ```json
{"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}} {"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}}
@@ -110,7 +110,7 @@ The daemon pushes notifications — no `id`, so never answer them:
`event.room`, `event.message`, `event.upload`, `event.download_status`, `event.room`, `event.message`, `event.upload`, `event.download_status`,
`event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`, `event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`,
where they appear as entries marked `x-notification` — OpenRPC has no where they appear as entries marked `x-notification`. OpenRPC has no
first-class notion of a server-pushed message, so that flag is how they are first-class notion of a server-pushed message, so that flag is how they are
distinguished from methods you may call. distinguished from methods you may call.
@@ -129,7 +129,7 @@ later, with one exception: private messages are also kept, and
nobody was attached. nobody was attached.
Every connection gets its own queue, bounded at 1024 events. **A client that Every connection gets its own queue, bounded at 1024 events. **A client that
stops reading its socket is disconnected** once that queue fills — the daemon stops reading its socket is disconnected** once that queue fills. The daemon
will not let one stalled consumer cost the others their events. Read will not let one stalled consumer cost the others their events. Read
continuously; do not treat the socket as request/response only. continuously; do not treat the socket as request/response only.
@@ -139,7 +139,7 @@ matched on `username` + `filename`.
## Behaviour worth knowing ## Behaviour worth knowing
**Files land on the daemon's filesystem.** The directory is the daemon's **Files land on the daemon's filesystem.** The directory is the daemon's
configuration and a client cannot name one — that would be an arbitrary write configuration and a client cannot name one, which would be an arbitrary write
on the daemon's host. `daemon.status` reports the directory in use. There is on the daemon's host. `daemon.status` reports the directory in use. There is
no method to fetch the bytes over the control socket. no method to fetch the bytes over the control socket.
@@ -158,8 +158,8 @@ rejoined on startup.
**Searching is two steps.** `search.start` puts the query on the wire and **Searching is two steps.** `search.start` puts the query on the wire and
returns immediately; peers answer over their own connections for as long as returns immediately; peers answer over their own connections for as long as
you care to wait. Decide the window yourself, then read `search.results`. This you care to wait. Decide the window yourself, then read `search.results`. This
is deliberate — a client's search window must not be time the daemon spends is deliberate: a client's search window must not be time the daemon spends not
not answering everybody else. answering everybody else.
## A session by hand ## A session by hand
+14 -13
View File
@@ -30,15 +30,15 @@ soulseek-rs daemon status --json >/dev/null 2>&1 || {
``` ```
Wait for it, do not just launch it. A daemon takes a second or two to log in, Wait for it, do not just launch it. A daemon takes a second or two to log in,
and `soulseek-rs daemon status … || soulseek-rs daemon &` does **not** do this and `soulseek-rs daemon status … || soulseek-rs daemon &` does **not** do this.
— `&` backgrounds the whole list, so it returns instantly and the next command `&` backgrounds the whole list, so it returns instantly and the next command
races the login it is supposed to be using. A command that arrives too early races the login it is supposed to be using. A command that arrives too early
finds no daemon and quietly opens a session of its own, which is the outcome finds no daemon and quietly opens a session of its own, which is the outcome
this whole section exists to avoid. this whole section exists to avoid.
Then use every other command exactly as documented below. They find the daemon Then use every other command exactly as documented below. They find the daemon
by themselves — it listens on a Unix socket only your user can open, so there by themselves. It listens on a Unix socket only your user can open, so there is
is nothing to configure, no address to pass, and no token to handle. nothing to configure, no address to pass, and no token to handle.
**Why this matters, and why it is not optional for you.** Soulseek is a real **Why this matters, and why it is not optional for you.** Soulseek is a real
network of other people's computers, and the server allows **one session per network of other people's computers, and the server allows **one session per
@@ -46,7 +46,7 @@ account**. Without a daemon, every invocation is its own login, and running two
at once leaves you two bad choices: share a username and they silently cut each at once leaves you two bad choices: share a username and they silently cut each
other off (exit 7), or give each run a throwaway name and *register a new other off (exit 7), or give each run a throwaway name and *register a new
account on a public server for every search you make*. The second is what a account on a public server for every search you make*. The second is what a
naive script does, and it is the antisocial one — it is account churn on naive script does, and it is the antisocial one: account churn on
infrastructure other people maintain and share. infrastructure other people maintain and share.
A daemon is one login for as long as you work, however many commands you run A daemon is one login for as long as you work, however many commands you run
@@ -156,8 +156,9 @@ participant.
**`daemon`** — stay logged in as a service other commands share. Prints **`daemon`** — stay logged in as a service other commands share. Prints
progress on stderr and nothing on stdout; it does not return until stopped. progress on stderr and nothing on stdout; it does not return until stopped.
While it runs, every other command on this machine uses its session While it runs, every other command on this machine uses its session
automatically — no flags, no credentials, no second login. `--bind ADDR` also automatically, with no flags, no credentials, and no second login.
accepts connections from other machines, which need the token. `--bind ADDR` also accepts connections from other machines, which need the
token.
**`daemon status`** — one object: `user`, `server`, `version`, `protocol`, **`daemon status`** — one object: `user`, `server`, `version`, `protocol`,
`listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`, `listening`, `listen_port`, `shared_folders`, `shared_files`, `download_dir`,
`session_loss`, `clients`, `uptime_secs`. Exit 3 when nothing is running. `session_loss`, `clients`, `uptime_secs`. Exit 3 when nothing is running.
@@ -165,8 +166,8 @@ accepts connections from other machines, which need the token.
**`daemon token`** — the secret a client on another machine needs. Local **`daemon token`** — the secret a client on another machine needs. Local
clients never need it. clients never need it.
The daemon can also be on a *different* machine — a home server or NAS doing The daemon can also be on a *different* machine, such as a home server or NAS
the downloading. Point at it once with `config set daemon host:5030` and doing the downloading. Point at it once with `config set daemon host:5030` and
`config set daemon_token <token>`, and every command afterwards drives that `config set daemon_token <token>`, and every command afterwards drives that
machine; files land there, not here. The interface is an open JSON-RPC schema machine; files land there, not here. The interface is an open JSON-RPC schema
(`rpc.discover` serves it), so a purpose-built client is a reasonable thing to (`rpc.discover` serves it), so a purpose-built client is a reasonable thing to
@@ -218,7 +219,7 @@ is the normal way to be selective.
## Running several at once ## Running several at once
With a daemon running this needs no thought — they all share its one session: With a daemon running this needs no thought. They all share its one session:
```bash ```bash
for q in 'gary beck fold' 'marcal steady'; do for q in 'gary beck fold' 'marcal steady'; do
@@ -230,7 +231,7 @@ wait
Without one, the same loop is a bug: the runs share a username, so each login Without one, the same loop is a bug: the runs share a username, so each login
displaces the last and they kill each other's session (exit 7). Giving each run displaces the last and they kill each other's session (exit 7). Giving each run
its own `--username` avoids the collision by registering a fresh account on the its own `--username` avoids the collision by registering a fresh account on the
server for every search, which is worse — do not do it. Start the daemon. server for every search, which is worse. Do not do it. Start the daemon.
The listener port needs no care either way: a run that finds the configured The listener port needs no care either way: a run that finds the configured
port taken binds a free one and tells the server about that one instead. port taken binds a free one and tells the server about that one instead.
@@ -250,8 +251,8 @@ port taken binds a free one and tells the server about that one instead.
- **Exit 4 is not a failure.** A search that finds nothing means the query was - **Exit 4 is not a failure.** A search that finds nothing means the query was
too narrow or misspelled. Widen it; use `whoami` to rule out the connection. too narrow or misspelled. Widen it; use `whoami` to rule out the connection.
- **Exit 7 is not an answer.** The session ended mid-command, so nothing it saw - **Exit 7 is not an answer.** The session ended mid-command, so nothing it saw
counts. It means something else logged in as you — almost always a second counts. Something else logged in as you, almost always a second run of your
run of your own. Start a daemon and route through it rather than hunting for own. Start a daemon and route through it rather than hunting for
an unused name. an unused name.
- **Never put a password in argv** — `ps` shows it to every user on the box. - **Never put a password in argv** — `ps` shows it to every user on the box.
Use `--password-stdin`, the OS keychain, or `SOULSEEK_PASSWORD_CMD`. Use `--password-stdin`, the OS keychain, or `SOULSEEK_PASSWORD_CMD`.
+53 -2
View File
@@ -199,7 +199,7 @@ pub fn run(
downloads: Arc::clone(&downloads), downloads: Arc::clone(&downloads),
download_dir: ctx.download_dir.clone(), download_dir: ctx.download_dir.clone(),
server: ctx.settings.server_address.to_string(), server: ctx.settings.server_address.to_string(),
token, token: token.clone(),
started: Instant::now(), started: Instant::now(),
stop: Arc::clone(&stop), stop: Arc::clone(&stop),
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)), open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
@@ -225,7 +225,7 @@ pub fn run(
"logged in as {}, sharing {files} files in {folders} folders", "logged in as {}, sharing {files} files in {folders} folders",
session.client.username() session.client.username()
)); ));
ctx.out.status("ready"); ready(ctx, bind, &token);
// The listeners run on their own threads; this one only has to notice a // The listeners run on their own threads; this one only has to notice a
// `daemon.stop` and write state back on the way past. // `daemon.stop` and write state back on the way past.
@@ -247,6 +247,57 @@ pub fn run(
Ok(()) Ok(())
} }
/// Say what is now possible, once, on stderr.
///
/// The token goes here rather than only into a file because the moment you
/// need it is the moment you bind a port, and hunting for `daemon token`
/// afterwards is a poor first experience. stdout stays empty either way, so
/// `daemon` still composes in a pipeline.
fn ready(ctx: &Ctx, bind: Option<&str>, token: &str) {
ctx.out.status("");
ctx.out.status("ready — leave this running.");
ctx.out.status("");
ctx.out
.status(" On this machine, nothing else to do. Other commands find");
ctx.out
.status(" it on their own and share this one login:");
ctx.out.status("");
ctx.out.status(" soulseek-rs search 'aphex twin'");
ctx.out
.status(" soulseek-rs (the TUI attaches)");
ctx.out.status(" soulseek-rs daemon status");
ctx.out.status(" soulseek-rs daemon stop");
ctx.out.status("");
let Some(address) = bind else {
ctx.out.status(
" To drive it from another machine, restart with --bind ADDR;",
);
ctx.out
.status(" `soulseek-rs daemon token` prints the secret it needs.");
ctx.out.status("");
return;
};
ctx.out.status(&format!(
" From another machine, point a client at {address} with"
));
ctx.out.status(" this token:");
ctx.out.status("");
ctx.out.status(&format!(" {token}"));
ctx.out.status("");
ctx.out
.status(" soulseek-rs config set daemon <this-host>:PORT");
ctx.out
.status(" soulseek-rs config set daemon_token <token above>");
ctx.out.status("");
ctx.out.status(
" That port has no encryption. Keep it off the open internet and \
reach it over SSH instead.",
);
ctx.out.status("");
}
/// Remove the socket file so the next start does not have to reason about /// Remove the socket file so the next start does not have to reason about
/// whether it is stale. /// whether it is stale.
fn cleanup() { fn cleanup() {
+8 -2
View File
@@ -155,14 +155,20 @@ fn spawn_connection(stream: Stream, daemon: Arc<Daemon>) {
if open > MAX_CONNECTIONS { if open > MAX_CONNECTIONS {
daemon.open.fetch_sub(1, Ordering::Relaxed); daemon.open.fetch_sub(1, Ordering::Relaxed);
soulseek_rs::warn!( soulseek_rs::warn!(
"refusing a control connection: {MAX_CONNECTIONS} already open" "refusing a control connection: {MAX_CONNECTIONS} already open. \
This is a leak unless you really have that many clients — please \
report it with `soulseek-rs daemon status`."
); );
stream.shutdown(); stream.shutdown();
return; return;
} }
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(e) = handle(stream, daemon.as_ref()) { if let Err(e) = handle(stream, daemon.as_ref()) {
soulseek_rs::debug!("control connection ended: {e}"); // Loud, not debug: a client sees only "the connection closed", so
// this line is the only account of why it did.
soulseek_rs::warn!(
"control connection dropped before it could be served: {e}"
);
} }
daemon.open.fetch_sub(1, Ordering::Relaxed); daemon.open.fetch_sub(1, Ordering::Relaxed);
}); });
+102
View File
@@ -565,3 +565,105 @@ fn a_config_driven_remote_client_reports_the_daemons_world() {
let _ = std::fs::remove_dir_all(&config); let _ = std::fs::remove_dir_all(&config);
let _ = std::fs::remove_dir_all(&state); let _ = std::fs::remove_dir_all(&state);
} }
/// Several clients at once, all seeing the same session.
///
/// This is the promise daemon mode makes and the reason the fan-out exists:
/// the server allows one login, so the alternative to sharing is displacing.
/// Each client gets its own connection, its own event queue, and the same
/// answers.
#[test]
fn many_clients_share_one_session_and_agree_about_it() {
let daemon = daemon_or_skip!();
// Held open together, not one after another: the point is concurrency.
let mut clients: Vec<Connection> = (0..8)
.map(|_| {
let mut connection = daemon.connect_tcp().expect("connect");
let reply = connection
.authenticate(Some(&daemon.token()))
.expect("an answer");
assert!(reply.contains("\"result\""), "client rejected: {reply}");
connection
})
.collect();
// Every one of them is told the same thing about the same session.
let mut seen = Vec::new();
for (i, client) in clients.iter_mut().enumerate() {
let reply = client
.call(&format!(
r#"{{"jsonrpc":"2.0","id":{},"method":"daemon.status"}}"#,
100 + i
))
.unwrap_or_else(|| panic!("client {i} lost its connection"));
let status: serde_json::Value =
serde_json::from_str(&reply).expect("valid JSON");
assert_eq!(
status["id"],
serde_json::json!(100 + i),
"replies must not be crossed between clients: {reply}"
);
seen.push((
status["result"]["username"].clone(),
status["result"]["server"].clone(),
));
}
assert!(
seen.windows(2).all(|pair| pair[0] == pair[1]),
"every client should describe the same session: {seen:?}"
);
// And the daemon counts them all, rather than having quietly dropped some.
let reply = clients[0]
.call(r#"{"jsonrpc":"2.0","id":200,"method":"daemon.status"}"#)
.expect("still connected");
let status: serde_json::Value =
serde_json::from_str(&reply).expect("valid JSON");
assert_eq!(
status["result"]["clients"],
serde_json::json!(8),
"all eight should still be attached: {reply}"
);
}
/// Connecting and dropping, over and over, must not degrade the daemon.
///
/// Every command makes at least one throwaway connection while it works out
/// whether a daemon is there, so a per-connection resource that is not
/// released shows up as a daemon that works for a while and then refuses
/// everyone.
#[test]
fn rapid_connect_and_drop_does_not_wear_the_daemon_out() {
let daemon = daemon_or_skip!();
// Comfortably more than the connection cap, so a leak of one slot per
// connection would have exhausted it well before the end.
for _ in 0..200 {
drop(daemon.connect_tcp().expect("connect"));
}
// Half-open ones too: authenticated, then abandoned mid-conversation.
for _ in 0..50 {
let mut connection = daemon.connect_tcp().expect("connect");
connection.authenticate(Some(&daemon.token()));
drop(connection);
}
// Give the daemon a moment to notice the hangups, then prove it is well.
std::thread::sleep(Duration::from_secs(2));
let mut connection = daemon.connect_tcp().expect("connect");
let reply = connection
.authenticate(Some(&daemon.token()))
.expect("the daemon should still be answering");
assert!(reply.contains("\"result\""), "{reply}");
let status = connection
.call(r#"{"jsonrpc":"2.0","id":1,"method":"daemon.status"}"#)
.expect("an answer");
let parsed: serde_json::Value =
serde_json::from_str(&status).expect("valid JSON");
assert_eq!(
parsed["result"]["clients"],
serde_json::json!(1),
"abandoned connections must not still be counted: {status}"
);
}
+14 -14
View File
@@ -228,7 +228,7 @@ const Daemon = () => (
<Section> <Section>
<SectionHead eyebrow="5 · the daemon" title="One login, however many commands."> <SectionHead eyebrow="5 · the daemon" title="One login, however many commands.">
Soulseek allows one session per account. Run a daemon and everything else borrows Soulseek allows one session per account. Run a daemon and everything else borrows
it &mdash; no second login, nothing to configure. it. No second login, nothing to configure.
</SectionHead> </SectionHead>
<Cols start> <Cols start>
<Terminal <Terminal
@@ -237,7 +237,7 @@ const Daemon = () => (
{ t: 'cmd', text: 'soulseek-rs daemon &' }, { t: 'cmd', text: 'soulseek-rs daemon &' },
{ t: 'cm', text: '# once; it holds the session' }, { t: 'cm', text: '# once; it holds the session' },
{ t: 'cmd', text: 'soulseek-rs search "netlabel 2004" --json' }, { t: 'cmd', text: 'soulseek-rs search "netlabel 2004" --json' },
{ t: 'cm', text: '# no login, no flags — it finds the daemon' }, { t: 'cm', text: '# no login, no flags; it finds the daemon' },
{ t: 'cmd', text: 'soulseek-rs' }, { t: 'cmd', text: 'soulseek-rs' },
{ t: 'cm', text: '# the TUI attaches to the same session' }, { t: 'cm', text: '# the TUI attaches to the same session' },
{ t: 'cmd', text: 'soulseek-rs daemon status' }, { t: 'cmd', text: 'soulseek-rs daemon status' },
@@ -251,10 +251,10 @@ const Daemon = () => (
running, everything behaves exactly as it did before. running, everything behaves exactly as it did before.
</p> </p>
<p> <p>
Why bother: the server permits a single session per account, so two one-off Why bother: the server permits a single session per account. Two one-off
commands at once either cut each other off or push you into a throwaway commands at once either cut each other off, or push you into a throwaway
username per run &mdash; account churn on a network other people maintain. A username per run, which is account churn on a network other people maintain.
daemon is one login for as long as you work, one share index instead of a A daemon is one login for as long as you work, one share index instead of a
rescan every invocation, and downloads that outlive the command that queued rescan every invocation, and downloads that outlive the command that queued
them. If you drive this from a script or an agent, start one. them. If you drive this from a script or an agent, start one.
</p> </p>
@@ -293,16 +293,16 @@ const DownloadBox = () => (
/> />
<Prose> <Prose>
<p> <p>
Your laptop needs no Soulseek account of its own &mdash; it is using the box&rsquo;s. Your laptop needs no Soulseek account of its own; it uses the box&rsquo;s. Queue
Queue what you want, then close the lid: the downloads keep going, because they what you want, then close the lid. The downloads keep going, because they
belong to the daemon rather than to the command that asked for them. Come back belong to the daemon rather than to the command that asked for them. Come back
tomorrow, run <Code>daemon status</Code>, and pick up where you left off. The tomorrow, run <Code>daemon status</Code>, and pick up where you left off. The
files are on the box, which is where you wanted them. files are on the box, which is where you wanted them.
</p> </p>
<p> <p>
There is no encryption on that port. On a home network that is usually fine; There is no encryption on that port. On a home network that is usually fine.
over the internet, reach it through SSH instead &mdash;{' '} Over the internet, reach it through SSH instead:{' '}
<Code>ssh -L 5030:localhost:5030 nas</Code>, or just run the commands on the box. <Code>ssh -L 5030:localhost:5030 nas</Code>, or run the commands on the box.
</p> </p>
</Prose> </Prose>
</Cols> </Cols>
@@ -325,9 +325,9 @@ const OpenSchema = () => (
</p> </p>
<p> <p>
OpenRPC is the JSON-RPC counterpart to OpenAPI: point a generator at it and you OpenRPC is the JSON-RPC counterpart to OpenAPI: point a generator at it and you
get typed bindings in whatever language you are working in. An Android or iOS app get typed bindings in whatever language you are working in. Want an Android or
that queues downloads on your home server from the sofa, a small web page showing iOS app that queues downloads on your home server from the sofa, or a web page
what is transferring &mdash; you have everything you need and nobody to ask. showing what is transferring? You have what you need and nobody to ask.
</p> </p>
<p> <p>
Searches, transfers, rooms, private messages and shares are all on the same Searches, transfers, rooms, private messages and shares are all on the same
+2 -2
View File
@@ -66,8 +66,8 @@ const FEATURES = [
cmd: 'soulseek-rs daemon', cmd: 'soulseek-rs daemon',
body: ( body: (
<> <>
Hold one session and let every other command borrow it &mdash; the network allows Hold one session and let every other command borrow it. The network allows one
one login per account, so this is the neighbourly way to run several at once. It login per account, so this is the neighbourly way to run several at once. It
speaks JSON-RPC, so other tools can drive it too. speaks JSON-RPC, so other tools can drive it too.
</> </>
), ),