fix(daemon): correct the published contract and harden the control socket

A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.

Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
  arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
  was briefly connectable by any local user between bind and chmod, and on
  that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
  connections are capped, so an unauthenticated peer cannot exhaust memory or
  threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
  subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
  dropped from the fan-out and left connected, working on but blind to every
  event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
  waiting out its full timeout and reporting a misleading "gave up".

Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.

Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
This commit is contained in:
Michel de Graaf
2026-07-27 11:38:46 +02:00
parent a69f52c657
commit 41307d0510
22 changed files with 2528 additions and 1942 deletions
+29 -9
View File
@@ -13,8 +13,13 @@ events behave.
## Framing ## Framing
Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions, Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions,
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply and UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on
the connection stays open. an established connection it stays open, but the *first* line has to be a valid
`auth` request, so a parse error there ends the connection.
Lines are capped at 1 MiB and a connection that has not authenticated within
30 seconds is closed. The daemon serves at most 64 control connections at
once.
## Transports and authentication ## Transports and authentication
@@ -57,9 +62,14 @@ the old shape.
## Requests ## Requests
Parameters are by name — a JSON object, never an array. Every method and its Parameters are by name — a JSON object, never an array. Each method's fields
parameter and result schemas are in `openrpc.json`; `rpc.discover` returns are published as individual OpenRPC content descriptors, so a generated client
that same document from the running daemon. sends exactly the object the daemon deserializes. `rpc.discover` returns that
same document from the running daemon.
An event is the exception: its `params` object *is* the payload, published as
a single `payload` descriptor because several payloads are tagged unions with
no fixed field set.
Requests may be pipelined and are answered out of order, so match replies by Requests may be pipelined and are answered out of order, so match replies by
`id`. A request without an `id` is a notification and is answered with `id`. A request without an `id` is a notification and is answered with
@@ -71,6 +81,10 @@ silence, including when it fails.
{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}} {"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}}
``` ```
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
notification" 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
failures use `-32000` and carry `data.exit`: the CLI exit status the same failures use `-32000` and carry `data.exit`: the CLI exit status the same
@@ -100,6 +114,12 @@ 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.
`message.history` and `event.message` describe the same conversation in
different shapes, deliberately: a live event is the message as the server
delivered it (`id`, `timestamp`, `username`, `message`), while history is a
chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it
includes what this account sent.
**Events are the only way to observe these things, and they are not **Events are the only way to observe these things, and they are not
replayable.** The underlying library hands over its event buffer and empties replayable.** The underlying library hands over its event buffer and empties
it, so the daemon drains once and copies to every attached connection. A it, so the daemon drains once and copies to every attached connection. A
@@ -118,10 +138,10 @@ matched on `username` + `filename`.
## Behaviour worth knowing ## Behaviour worth knowing
**Files land on the daemon's filesystem.** `download_directory` is the **Files land on the daemon's filesystem.** The directory is the daemon's
daemon's configuration; a remote client cannot see that filesystem and does configuration and a client cannot name one — that would be an arbitrary write
not choose. `daemon.status` reports the directory in use. There is no method on the daemon's host. `daemon.status` reports the directory in use. There is
to fetch the bytes over the control socket. no method to fetch the bytes over the control socket.
**Several clients, no locking.** Last write wins. Two clients cancelling the **Several clients, no locking.** Last write wins. Two clients cancelling the
same transfer is two hands on one keyboard, not an error. same transfer is two hands on one keyboard, not an error.
+172 -60
View File
@@ -115,6 +115,7 @@
"type": "string" "type": "string"
}, },
"listen_port": { "listen_port": {
"default": null,
"format": "uint16", "format": "uint16",
"maximum": 65535, "maximum": 65535,
"minimum": 0, "minimum": 0,
@@ -139,7 +140,8 @@
{ {
"type": "null" "type": "null"
} }
] ],
"default": null
}, },
"shared_files": { "shared_files": {
"format": "uint32", "format": "uint32",
@@ -197,9 +199,15 @@
}, },
"metadata": { "metadata": {
"$ref": "#/components/schemas/DownloadMetadataDto", "$ref": "#/components/schemas/DownloadMetadataDto",
"default": {} "default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
}, },
"queue_position": { "queue_position": {
"default": null,
"format": "uint32", "format": "uint32",
"minimum": 0, "minimum": 0,
"type": [ "type": [
@@ -237,6 +245,7 @@
"DownloadMetadataDto": { "DownloadMetadataDto": {
"properties": { "properties": {
"bitrate": { "bitrate": {
"default": null,
"format": "uint32", "format": "uint32",
"minimum": 0, "minimum": 0,
"type": [ "type": [
@@ -245,6 +254,7 @@
] ]
}, },
"length_seconds": { "length_seconds": {
"default": null,
"format": "uint32", "format": "uint32",
"minimum": 0, "minimum": 0,
"type": [ "type": [
@@ -253,6 +263,7 @@
] ]
}, },
"peer_free_slots": { "peer_free_slots": {
"default": null,
"format": "uint8", "format": "uint8",
"maximum": 255, "maximum": 255,
"minimum": 0, "minimum": 0,
@@ -262,6 +273,7 @@
] ]
}, },
"peer_upload_speed": { "peer_upload_speed": {
"default": null,
"format": "uint32", "format": "uint32",
"minimum": 0, "minimum": 0,
"type": [ "type": [
@@ -274,19 +286,17 @@
}, },
"DownloadStartParams": { "DownloadStartParams": {
"properties": { "properties": {
"download_directory": {
"description": "Where the file lands *on the daemon's* filesystem. Defaults to the\ndaemon's configured download directory, which is the only sensible\nchoice for a remote caller that cannot see that filesystem.",
"type": [
"string",
"null"
]
},
"filename": { "filename": {
"type": "string" "type": "string"
}, },
"metadata": { "metadata": {
"$ref": "#/components/schemas/DownloadMetadataDto", "$ref": "#/components/schemas/DownloadMetadataDto",
"default": {} "default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
}, },
"size": { "size": {
"default": 0, "default": 0,
@@ -780,6 +790,7 @@
"Seconds": { "Seconds": {
"properties": { "properties": {
"seconds": { "seconds": {
"default": null,
"format": "uint32", "format": "uint32",
"minimum": 0, "minimum": 0,
"type": [ "type": [
@@ -1031,7 +1042,8 @@
{ {
"type": "null" "type": "null"
} }
] ],
"default": null
}, },
"stats": { "stats": {
"anyOf": [ "anyOf": [
@@ -1041,7 +1053,8 @@
{ {
"type": "null" "type": "null"
} }
] ],
"default": null
}, },
"username": { "username": {
"type": "string" "type": "string"
@@ -1120,7 +1133,8 @@
{ {
"type": "null" "type": "null"
} }
] ],
"default": null
} }
}, },
"type": "object" "type": "object"
@@ -1166,10 +1180,24 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "protocol",
"required": true, "required": false,
"schema": { "schema": {
"$ref": "#/components/schemas/AuthParams" "default": 0,
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
{
"name": "token",
"required": false,
"schema": {
"description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.",
"type": [
"string",
"null"
]
} }
} }
], ],
@@ -1222,10 +1250,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "query",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/QueryParams" "type": "string"
} }
} }
], ],
@@ -1242,10 +1270,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "query",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/QueryParams" "type": "string"
} }
} }
], ],
@@ -1262,10 +1290,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "query",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/QueryParams" "type": "string"
} }
} }
], ],
@@ -1294,10 +1322,40 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/DownloadStartParams" "type": "string"
}
},
{
"name": "metadata",
"required": false,
"schema": {
"$ref": "#/components/schemas/DownloadMetadataDto",
"default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
}
},
{
"name": "size",
"required": false,
"schema": {
"default": 0,
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1326,10 +1384,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/TransferRef" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1346,10 +1411,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/TransferRef" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1366,10 +1438,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/TransferRef" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1386,10 +1465,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/TransferRef" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1418,10 +1504,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "filename",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/TransferRef" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1438,10 +1531,12 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "slots",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/SlotsParams" "format": "uint",
"minimum": 0,
"type": "integer"
} }
} }
], ],
@@ -1494,10 +1589,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "room",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/RoomRef" "type": "string"
} }
} }
], ],
@@ -1514,10 +1609,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "room",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/RoomRef" "type": "string"
} }
} }
], ],
@@ -1534,10 +1629,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "message",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/SayParams" "type": "string"
}
},
{
"name": "room",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1554,10 +1656,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "room",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/RoomRef" "type": "string"
} }
} }
], ],
@@ -1574,10 +1676,17 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "message",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/MessageParams" "type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
} }
} }
], ],
@@ -1606,10 +1715,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "username",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/UserRef" "type": "string"
} }
} }
], ],
@@ -1626,10 +1735,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "username",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/UserRef" "type": "string"
} }
} }
], ],
@@ -1646,10 +1755,10 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "username",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/UserRef" "type": "string"
} }
} }
], ],
@@ -1678,10 +1787,13 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "directories",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/DirectoriesParams" "items": {
"type": "string"
},
"type": "array"
} }
} }
], ],
@@ -1710,7 +1822,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/RoomEventDto" "$ref": "#/components/schemas/RoomEventDto"
@@ -1731,7 +1843,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/UserMessageDto" "$ref": "#/components/schemas/UserMessageDto"
@@ -1752,7 +1864,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/UploadInfoDto" "$ref": "#/components/schemas/UploadInfoDto"
@@ -1773,7 +1885,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/DownloadStatusEvent" "$ref": "#/components/schemas/DownloadStatusEvent"
@@ -1794,7 +1906,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/BrowseEvent" "$ref": "#/components/schemas/BrowseEvent"
@@ -1815,7 +1927,7 @@
"paramStructure": "by-name", "paramStructure": "by-name",
"params": [ "params": [
{ {
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/components/schemas/SessionLossEvent" "$ref": "#/components/schemas/SessionLossEvent"
+13 -11
View File
@@ -39,11 +39,16 @@ pub trait SessionApi: Send + Sync {
fn listen_port(&self) -> Option<u16>; fn listen_port(&self) -> Option<u16>;
fn session_loss(&self) -> Option<SessionLoss>; fn session_loss(&self) -> Option<SessionLoss>;
/// Search and wait out the window. Every implementation is
/// `search_with_cancel` with nothing to cancel on.
fn search( fn search(
&self, &self,
query: &str, query: &str,
timeout: Duration, timeout: Duration,
) -> Result<Vec<SearchResult>>; ) -> Result<Vec<SearchResult>> {
self.search_with_cancel(query, timeout, None)
}
fn search_with_cancel( fn search_with_cancel(
&self, &self,
query: &str, query: &str,
@@ -91,7 +96,8 @@ pub trait SessionApi: Send + Sync {
fn room_members(&self, room: &str) -> Vec<String>; fn room_members(&self, room: &str) -> Vec<String>;
fn take_room_events(&self) -> Vec<RoomEvent>; fn take_room_events(&self) -> Vec<RoomEvent>;
fn send_private_message(&self, username: &str, message: &str) -> Result<()>; fn send_private_message(&self, username: &str, message: &str)
-> Result<()>;
fn take_private_messages(&self) -> Vec<UserMessage>; fn take_private_messages(&self) -> Vec<UserMessage>;
fn browse_user(&self, username: &str) -> Result<()>; fn browse_user(&self, username: &str) -> Result<()>;
@@ -133,14 +139,6 @@ impl SessionApi for Client {
Self::session_loss(self) Self::session_loss(self)
} }
fn search(
&self,
query: &str,
timeout: Duration,
) -> Result<Vec<SearchResult>> {
Self::search(self, query, timeout)
}
fn search_with_cancel( fn search_with_cancel(
&self, &self,
query: &str, query: &str,
@@ -266,7 +264,11 @@ impl SessionApi for Client {
Self::take_room_events(self) Self::take_room_events(self)
} }
fn send_private_message(&self, username: &str, message: &str) -> Result<()> { fn send_private_message(
&self,
username: &str,
message: &str,
) -> Result<()> {
Self::send_private_message(self, username, message) Self::send_private_message(self, username, message)
} }
+3 -1
View File
@@ -6,7 +6,9 @@
//! credentials are demanded. //! credentials are demanded.
use crate::daemon::proto::{DaemonStatus, Method}; use crate::daemon::proto::{DaemonStatus, Method};
use crate::output::{CliError, CliResult, DaemonStatusRecord, Out, TokenRecord}; use crate::output::{
CliError, CliResult, DaemonStatusRecord, Out, TokenRecord,
};
use crate::remote::{Endpoint, RemoteSession}; use crate::remote::{Endpoint, RemoteSession};
/// Print the token a client on another machine needs. /// Print the token a client on another machine needs.
+1 -1
View File
@@ -14,13 +14,13 @@ pub mod social;
pub mod transfer; pub mod transfer;
pub mod wish; pub mod wish;
use crate::api::SessionApi;
use crate::cli::{Commands, MessageCommand, RoomCommand}; use crate::cli::{Commands, MessageCommand, RoomCommand};
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
use crate::port_mapping::{self, PortMapper}; use crate::port_mapping::{self, PortMapper};
use crate::remote::{Endpoint, RemoteSession}; use crate::remote::{Endpoint, RemoteSession};
use soulseek_rs::types::{RoomEvent, RoomInfo}; use soulseek_rs::types::{RoomEvent, RoomInfo};
use soulseek_rs::{Client, ClientSettings, SessionLoss}; use soulseek_rs::{Client, ClientSettings, SessionLoss};
use crate::api::SessionApi;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
+2 -2
View File
@@ -2,14 +2,14 @@
//! `message`. //! `message`.
use super::{Ctx, Session, confirm_flush}; use super::{Ctx, Session, confirm_flush};
use crate::api::SessionApi;
use crate::cli::BrowseArgs; use crate::cli::BrowseArgs;
use crate::output::{ use crate::output::{
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord, BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
RoomEventRecord, RoomMemberRecord, RoomRecord, RoomEventRecord, RoomMemberRecord, RoomRecord,
}; };
use soulseek_rs::types::RoomEvent;
use crate::api::SessionApi;
use soulseek_rs::SharedDirectory; use soulseek_rs::SharedDirectory;
use soulseek_rs::types::RoomEvent;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// How long to wait for the server to acknowledge something we sent. /// How long to wait for the server to acknowledge something we sent.
+1 -1
View File
@@ -1,6 +1,7 @@
//! Finding files and fetching them: `search`, `download`, and `get`. //! Finding files and fetching them: `search`, `download`, and `get`.
use super::{Ctx, Session, social}; use super::{Ctx, Session, social};
use crate::api::SessionApi;
use crate::cli::{ use crate::cli::{
DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey, DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey,
}; };
@@ -8,7 +9,6 @@ use crate::output::{
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord, CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
}; };
use soulseek_rs::utils::path::expand_tilde; use soulseek_rs::utils::path::expand_tilde;
use crate::api::SessionApi;
use soulseek_rs::{DownloadStatus, SearchResult}; use soulseek_rs::{DownloadStatus, SearchResult};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::io::BufRead; use std::io::BufRead;
+1 -1
View File
@@ -6,10 +6,10 @@
//! set — we only decide which queries to send and what to print. //! set — we only decide which queries to send and what to print.
use super::{Ctx, Session, settings::Store, transfer}; use super::{Ctx, Session, settings::Store, transfer};
use crate::api::SessionApi;
use crate::cli::{SortKey, WishRunArgs}; use crate::cli::{SortKey, WishRunArgs};
use crate::output::{CliError, CliResult, Out, WishRecord}; use crate::output::{CliError, CliResult, Out, WishRecord};
use crate::persist::config::FileConfig; use crate::persist::config::FileConfig;
use crate::api::SessionApi;
use soulseek_rs::Client; use soulseek_rs::Client;
use std::time::Instant; use std::time::Instant;
+61 -21
View File
@@ -12,7 +12,7 @@
use super::proto::{ use super::proto::{
BrowseEvent, ChatMessageDto, DownloadStatusEvent, Event, Response, BrowseEvent, ChatMessageDto, DownloadStatusEvent, Event, Response,
SessionLossEvent, UploadInfoDto, UserMessageDto, SessionLossEvent, UploadInfoDto, UploadStatusDto, UserMessageDto,
}; };
use crate::api::SessionApi; use crate::api::SessionApi;
use soulseek_rs::DownloadStatus; use soulseek_rs::DownloadStatus;
@@ -39,6 +39,14 @@ const HISTORY_LIMIT: usize = 500;
struct Subscriber { struct Subscriber {
id: u64, id: u64,
sender: SyncSender<Response>, sender: SyncSender<Response>,
/// How to end this client's connection. A stalled client cannot be
/// reached any other way, and leaving it attached but silently eventless
/// is worse than disconnecting it: it would keep working while missing
/// every room message, transfer update and upload it should have seen.
///
/// A closure rather than the socket itself, so the hub stays ignorant of
/// what a connection is made of.
disconnect: Box<dyn Fn() + Send + Sync>,
} }
/// Fans daemon-side events out to every attached connection. /// Fans daemon-side events out to every attached connection.
@@ -61,12 +69,20 @@ impl Hub {
} }
/// Attach a connection. The returned receiver yields notifications until /// Attach a connection. The returned receiver yields notifications until
/// [`Hub::unsubscribe`] or a full queue drops it. /// [`Hub::unsubscribe`], or until a full queue costs the client its
pub fn subscribe(&self) -> (u64, Receiver<Response>) { /// connection.
pub fn subscribe(
&self,
disconnect: impl Fn() + Send + Sync + 'static,
) -> (u64, Receiver<Response>) {
let id = self.next_id.fetch_add(1, Ordering::Relaxed); let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = sync_channel(QUEUE_DEPTH); let (sender, receiver) = sync_channel(QUEUE_DEPTH);
if let Ok(mut subscribers) = self.subscribers.lock() { if let Ok(mut subscribers) = self.subscribers.lock() {
subscribers.push(Subscriber { id, sender }); subscribers.push(Subscriber {
id,
sender,
disconnect: Box::new(disconnect),
});
} }
(id, receiver) (id, receiver)
} }
@@ -78,7 +94,9 @@ impl Hub {
} }
pub fn subscribers(&self) -> usize { pub fn subscribers(&self) -> usize {
self.subscribers.lock().map_or(0, |subscribers| subscribers.len()) self.subscribers
.lock()
.map_or(0, |subscribers| subscribers.len())
} }
/// Send one event to everyone, dropping any client that has stopped /// Send one event to everyone, dropping any client that has stopped
@@ -93,9 +111,14 @@ impl Hub {
Ok(()) => true, Ok(()) => true,
Err(TrySendError::Full(_)) => { Err(TrySendError::Full(_)) => {
soulseek_rs::warn!( soulseek_rs::warn!(
"control client {} is not reading its events; dropping it", "control client {} is not reading its events; \
disconnecting it",
subscriber.id subscriber.id
); );
// Closing the socket is the point: a client dropped from
// the fan-out but left connected would carry on believing
// it was subscribed, silently blind to everything after.
(subscriber.disconnect)();
false false
} }
Err(TrySendError::Disconnected(_)) => false, Err(TrySendError::Disconnected(_)) => false,
@@ -192,11 +215,8 @@ impl PendingBrowses {
pending.iter().map(|(name, _)| name.clone()).collect() pending.iter().map(|(name, _)| name.clone()).collect()
} }
fn settled(&self, username: &str) { /// Stop waiting on a peer — because it answered, or because its request
self.forget(username); /// never reached the wire.
}
/// Stop waiting on a peer whose request never reached the wire.
pub fn forget(&self, username: &str) { pub fn forget(&self, username: &str) {
if let Ok(mut pending) = self.0.lock() { if let Ok(mut pending) = self.0.lock() {
pending.retain(|(name, _)| name != username); pending.retain(|(name, _)| name != username);
@@ -272,7 +292,11 @@ fn tick(
/// Both sources matter: the drained events carry states that came and went /// Both sources matter: the drained events carry states that came and went
/// between two ticks (a small file served straight out of the queue), and the /// between two ticks (a small file served straight out of the queue), and the
/// live table carries progress. /// live table carries progress.
fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState) { fn publish_uploads(
session: &dyn SessionApi,
hub: &Hub,
state: &mut DrainState,
) {
let sampled = session let sampled = session
.take_upload_events() .take_upload_events()
.iter() .iter()
@@ -285,13 +309,26 @@ fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState)
if state.uploads.get(&key) == Some(&upload) { if state.uploads.get(&key) == Some(&upload) {
continue; continue;
} }
let settled = matches!(
upload.status,
UploadStatusDto::Completed
| UploadStatusDto::Cancelled
| UploadStatusDto::Failed { .. }
);
hub.publish( hub.publish(
Event::Upload, Event::Upload,
serde_json::to_value(&upload).unwrap_or(serde_json::Value::Null), serde_json::to_value(&upload).unwrap_or(serde_json::Value::Null),
); );
// An upload that has finished cannot change again, so remembering it
// would grow this table by one entry per file for the life of the
// daemon.
if settled {
state.uploads.remove(&key);
} else {
state.uploads.insert(key, upload); state.uploads.insert(key, upload);
} }
} }
}
fn publish_browses( fn publish_browses(
session: &dyn SessionApi, session: &dyn SessionApi,
@@ -302,7 +339,7 @@ fn publish_browses(
let Some(directories) = session.take_browse_result(&username) else { let Some(directories) = session.take_browse_result(&username) else {
continue; continue;
}; };
browses.settled(&username); browses.forget(&username);
hub.publish( hub.publish(
Event::Browse, Event::Browse,
serde_json::to_value(BrowseEvent { serde_json::to_value(BrowseEvent {
@@ -358,8 +395,8 @@ mod tests {
// The whole reason the hub exists: two clients must both see an event // The whole reason the hub exists: two clients must both see an event
// that only exists once in the library's buffer. // that only exists once in the library's buffer.
let hub = Hub::new(); let hub = Hub::new();
let (_, first) = hub.subscribe(); let (_, first) = hub.subscribe(|| {});
let (_, second) = hub.subscribe(); let (_, second) = hub.subscribe(|| {});
hub.publish(Event::Room, json!({ "event": "left", "room": "lobby" })); hub.publish(Event::Room, json!({ "event": "left", "room": "lobby" }));
@@ -370,7 +407,7 @@ mod tests {
#[test] #[test]
fn unsubscribing_stops_delivery() { fn unsubscribing_stops_delivery() {
let hub = Hub::new(); let hub = Hub::new();
let (id, receiver) = hub.subscribe(); let (id, receiver) = hub.subscribe(|| {});
hub.unsubscribe(id); hub.unsubscribe(id);
hub.publish(Event::Upload, json!({})); hub.publish(Event::Upload, json!({}));
assert!(drain(&receiver).is_empty()); assert!(drain(&receiver).is_empty());
@@ -380,8 +417,8 @@ mod tests {
#[test] #[test]
fn a_client_that_stops_reading_is_dropped_and_the_others_keep_up() { fn a_client_that_stops_reading_is_dropped_and_the_others_keep_up() {
let hub = Hub::new(); let hub = Hub::new();
let (_, slow) = hub.subscribe(); let (_, slow) = hub.subscribe(|| {});
let (_, healthy) = hub.subscribe(); let (_, healthy) = hub.subscribe(|| {});
// Overrun the slow client's queue while the healthy one keeps reading, // Overrun the slow client's queue while the healthy one keeps reading,
// which is exactly the asymmetry the bound exists to survive. // which is exactly the asymmetry the bound exists to survive.
@@ -408,7 +445,7 @@ mod tests {
#[test] #[test]
fn a_dropped_receiver_removes_its_subscriber() { fn a_dropped_receiver_removes_its_subscriber() {
let hub = Hub::new(); let hub = Hub::new();
let (_, receiver) = hub.subscribe(); let (_, receiver) = hub.subscribe(|| {});
drop(receiver); drop(receiver);
hub.publish(Event::Message, json!({})); hub.publish(Event::Message, json!({}));
assert_eq!(hub.subscribers(), 0); assert_eq!(hub.subscribers(), 0);
@@ -427,7 +464,10 @@ mod tests {
} }
let history = hub.history(); let history = hub.history();
assert_eq!(history.len(), HISTORY_LIMIT); assert_eq!(history.len(), HISTORY_LIMIT);
assert_eq!(history[0].at, 5, "the oldest messages are the ones dropped"); assert_eq!(
history[0].at, 5,
"the oldest messages are the ones dropped"
);
assert_eq!(history[HISTORY_LIMIT - 1].at, HISTORY_LIMIT as i64 + 4); assert_eq!(history[HISTORY_LIMIT - 1].at, HISTORY_LIMIT as i64 + 4);
} }
@@ -460,7 +500,7 @@ mod tests {
pending.expect("bob"); pending.expect("bob");
pending.expect("bob"); pending.expect("bob");
assert_eq!(pending.snapshot(), ["bob"]); assert_eq!(pending.snapshot(), ["bob"]);
pending.settled("bob"); pending.forget("bob");
assert!(pending.snapshot().is_empty()); assert!(pending.snapshot().is_empty());
} }
+9 -6
View File
@@ -190,7 +190,7 @@ pub fn run(
let downloads = Arc::new(Mutex::new(Vec::new())); let downloads = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
restore(ctx, &store, session.client.as_ref(), &hub, &downloads); let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
let daemon = Arc::new(Daemon { let daemon = Arc::new(Daemon {
session: Arc::clone(&session.client), session: Arc::clone(&session.client),
@@ -202,7 +202,8 @@ pub fn run(
token, token,
started: Instant::now(), started: Instant::now(),
stop: Arc::clone(&stop), stop: Arc::clone(&stop),
rooms: Mutex::new(store.load_rooms()), open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
rooms: Mutex::new(rooms),
}); });
let drainer = { let drainer = {
@@ -263,7 +264,7 @@ fn restore(
session: &dyn SessionApi, session: &dyn SessionApi,
hub: &Hub, hub: &Hub,
downloads: &Mutex<Vec<PendingDownload>>, downloads: &Mutex<Vec<PendingDownload>>,
) { ) -> Vec<String> {
hub.restore( hub.restore(
store store
.load_messages() .load_messages()
@@ -277,8 +278,9 @@ fn restore(
.collect(), .collect(),
); );
for room in store.load_rooms() { let rooms = store.load_rooms();
if let Err(e) = session.join_room(&room) { for room in &rooms {
if let Err(e) = session.join_room(room) {
ctx.out.warn(&format!("cannot rejoin {room}: {e}")); ctx.out.warn(&format!("cannot rejoin {room}: {e}"));
} }
} }
@@ -289,7 +291,7 @@ fn restore(
.filter(|download| !download.completed) .filter(|download| !download.completed)
.collect(); .collect();
if unfinished.is_empty() { if unfinished.is_empty() {
return; return rooms;
} }
ctx.out ctx.out
.status(&format!("resuming {} transfers", unfinished.len())); .status(&format!("resuming {} transfers", unfinished.len()));
@@ -314,6 +316,7 @@ fn restore(
.warn(&format!("cannot resume {}: {e}", download.filename)), .warn(&format!("cannot resume {}: {e}", download.filename)),
} }
} }
rooms
} }
/// Write daemon-owned state back. Best-effort: a state file that cannot be /// Write daemon-owned state back. Best-effort: a state file that cannot be
+189 -44
View File
@@ -9,12 +9,12 @@
//! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`. //! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`.
use super::proto::{ use super::proto::{
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DownloadStartParams, Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams,
DownloadStatusEvent, Downloads, DownloadStarted, Event, IntervalSeconds, DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads,
MessageParams, Members, Messages, Method, QueryParams, RoomEventDto, Event, IntervalSeconds, Members, MessageParams, Messages, Method,
RoomRef, SayParams, SearchResults, Seconds, SessionLossEvent, SharesStatus, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Seconds,
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, SessionLossEvent, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
UserResult, DirectoriesParams, Uploads, UserMessageDto, UserRef, UserResult,
}; };
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -117,15 +117,29 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
/// What a method does, for the generated documentation. /// What a method does, for the generated documentation.
fn summary(method: Method) -> &'static str { fn summary(method: Method) -> &'static str {
match method { match method {
Method::Auth => "Authenticate. Must be the first request on a connection.", Method::Auth => {
Method::DaemonStatus => "Who the daemon is logged in as and what its session is doing.", "Authenticate. Must be the first request on a connection."
}
Method::DaemonStatus => {
"Who the daemon is logged in as and what its session is doing."
}
Method::DaemonStop => "Ask the daemon to shut down.", Method::DaemonStop => "Ask the daemon to shut down.",
Method::RpcDiscover => "This document.", Method::RpcDiscover => "This document.",
Method::SearchStart => "Put a search on the wire and return at once; results accumulate.", Method::SearchStart => {
Method::SearchResults => "Everything that has answered a search so far.", "Put a search on the wire and return at once; results accumulate."
Method::SearchWishlist => "Send a query as a wishlist search (server code 103).", }
Method::SearchWishlistInterval => "How long the server wants between wishlist searches.", Method::SearchResults => {
Method::DownloadStart => "Queue a transfer. Progress arrives as event.download_status.", "Everything that has answered a search so far."
}
Method::SearchWishlist => {
"Send a query as a wishlist search (server code 103)."
}
Method::SearchWishlistInterval => {
"How long the server wants between wishlist searches."
}
Method::DownloadStart => {
"Queue a transfer. Progress arrives as event.download_status."
}
Method::DownloadList => "Every transfer the daemon knows about.", Method::DownloadList => "Every transfer the daemon knows about.",
Method::DownloadPause => "Pause a transfer in progress.", Method::DownloadPause => "Pause a transfer in progress.",
Method::DownloadResume => "Resume a paused transfer.", Method::DownloadResume => "Resume a paused transfer.",
@@ -134,17 +148,29 @@ fn summary(method: Method) -> &'static str {
Method::UploadList => "Uploads being served, active first.", Method::UploadList => "Uploads being served, active first.",
Method::UploadCancel => "Stop serving an upload in progress.", Method::UploadCancel => "Stop serving an upload in progress.",
Method::UploadSlots => "How many uploads may run at once.", Method::UploadSlots => "How many uploads may run at once.",
Method::PrivilegesCheck => "Ask the server for this account's privilege time.", Method::PrivilegesCheck => {
Method::PrivilegesOwn => "Privilege seconds left, or null if the server has not answered.", "Ask the server for this account's privilege time."
Method::RoomListRequest => "Ask the server for the public room list; it arrives as event.room.", }
Method::PrivilegesOwn => {
"Privilege seconds left, or null if the server has not answered."
}
Method::RoomListRequest => {
"Ask the server for the public room list; it arrives as event.room."
}
Method::RoomJoin => "Join a chat room.", Method::RoomJoin => "Join a chat room.",
Method::RoomLeave => "Leave a chat room.", Method::RoomLeave => "Leave a chat room.",
Method::RoomSay => "Post a message to a room.", Method::RoomSay => "Post a message to a room.",
Method::RoomMembers => "Who is currently in a room.", Method::RoomMembers => "Who is currently in a room.",
Method::MessageSend => "Send a private message.", Method::MessageSend => "Send a private message.",
Method::MessageHistory => "Private messages the daemon collected, including while nobody was attached.", Method::MessageHistory => {
Method::BrowseUser => "Ask a peer for its share listing; it arrives as event.browse.", "Private messages the daemon collected, including while nobody was attached."
Method::UserRequest => "Ask the server about a user; poll user.info for the answer.", }
Method::BrowseUser => {
"Ask a peer for its share listing; it arrives as event.browse."
}
Method::UserRequest => {
"Ask the server about a user; poll user.info for the answer."
}
Method::UserInfoOf => "What the server has said about a user so far.", Method::UserInfoOf => "What the server has said about a user so far.",
Method::SharesStatusOf => "What the share index currently holds.", Method::SharesStatusOf => "What the share index currently holds.",
Method::SharesSet => "Replace the shared folders and re-index.", Method::SharesSet => "Replace the shared folders and re-index.",
@@ -171,9 +197,60 @@ fn event_summary(event: Event) -> &'static str {
Event::Upload => "An upload changed state.", Event::Upload => "An upload changed state.",
Event::DownloadStatus => "A transfer moved.", Event::DownloadStatus => "A transfer moved.",
Event::Browse => "A peer answered a browse request.", Event::Browse => "A peer answered a browse request.",
Event::SessionLoss => "The server session ended; nothing seen after this means anything.", Event::SessionLoss => {
"The server session ended; nothing seen after this means anything."
} }
} }
}
/// Turn a params object schema into OpenRPC content descriptors — one per
/// field, which is what `by-name` means.
///
/// A single descriptor holding the whole struct would tell a generator to send
/// `{"params": {"params": {…}}}`, and every generated call would come back
/// `-32602`. The daemon deserializes the params object directly into the
/// struct, so each of its fields is a parameter in its own right.
fn descriptors(
params: &Value,
definitions: &serde_json::Map<String, Value>,
) -> Vec<Value> {
let Some(schema) = resolve(params, definitions) else {
return Vec::new();
};
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|names| names.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
schema
.get("properties")
.and_then(Value::as_object)
.map(|properties| {
properties
.iter()
.map(|(name, property)| {
json!({
"name": name,
"required": required.contains(&name.as_str()),
"schema": property,
})
})
.collect()
})
.unwrap_or_default()
}
/// Follow a `$ref` into the generated definitions.
fn resolve<'a>(
schema: &'a Value,
definitions: &'a serde_json::Map<String, Value>,
) -> Option<&'a Value> {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return schema.get("properties").map(|_| schema);
};
definitions.get(reference.strip_prefix(DEFINITIONS_PATH)?)
}
/// Build the whole document. /// Build the whole document.
fn document() -> Value { fn document() -> Value {
@@ -182,56 +259,62 @@ fn document() -> Value {
}); });
let mut generator = SchemaGenerator::new(settings); let mut generator = SchemaGenerator::new(settings);
let methods: Vec<Value> = Method::ALL // Schemas first, definitions second: a params schema is a `$ref` until the
// generator has been asked for everything, and expanding it into
// descriptors needs the definition it points at.
let shapes: Vec<(Method, Value, Value)> = Method::ALL
.iter() .iter()
.map(|&method| { .map(|&method| {
let params = params_schema(method, &mut generator); (
let result = result_schema(method, &mut generator); method,
params_schema(method, &mut generator),
result_schema(method, &mut generator),
)
})
.collect();
let event_shapes: Vec<(Event, Value)> = Event::ALL
.iter()
.map(|&event| (event, event_schema(event, &mut generator)))
.collect();
let definitions: serde_json::Map<String, Value> =
generator.take_definitions(true).into_iter().collect();
let methods = shapes.into_iter().map(|(method, params, result)| {
json!({ json!({
"name": method.wire_name(), "name": method.wire_name(),
"summary": summary(method), "summary": summary(method),
"paramStructure": "by-name", "paramStructure": "by-name",
"params": if params == json!({ "type": "null" }) { "params": descriptors(&params, &definitions),
json!([])
} else {
json!([{
"name": "params",
"required": true,
"schema": params,
}])
},
"result": { "result": {
"name": "result", "name": "result",
"schema": result, "schema": result,
}, },
}) })
}) });
.collect();
// OpenRPC has no first-class notion of a server-pushed notification, so // OpenRPC has no first-class notion of a server-pushed notification, so
// the events are published as methods flagged with an extension field. // the events are published as methods flagged with an extension field.
// A generator still emits their payload types; the delivery semantics are // A generator still emits their payload types; the delivery semantics are
// prose in docs/daemon-protocol.md. // prose in docs/daemon-protocol.md.
let events: Vec<Value> = Event::ALL let events = event_shapes.into_iter().map(|(event, payload)| {
.iter()
.map(|&event| {
json!({ json!({
"name": event.wire_name(), "name": event.wire_name(),
"summary": event_summary(event), "summary": event_summary(event),
"x-notification": true, "x-notification": true,
"paramStructure": "by-name", "paramStructure": "by-name",
// Unlike a method, an event's `params` object *is* the payload,
// and several payloads are tagged enums with no single property
// map to split. One descriptor carrying the whole schema is
// what a generator needs to emit the type.
"params": [{ "params": [{
"name": "params", "name": "payload",
"required": true, "required": true,
"schema": event_schema(event, &mut generator), "schema": payload,
}], }],
"result": { "name": "result", "schema": { "type": "null" } }, "result": { "name": "result", "schema": { "type": "null" } },
}) })
}) });
.collect();
let definitions: serde_json::Map<String, Value> =
generator.take_definitions(true).into_iter().collect();
json!({ json!({
"openrpc": "1.3.2", "openrpc": "1.3.2",
@@ -306,6 +389,68 @@ fn every_method_and_event_is_published() {
assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len()); assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len());
} }
/// The published parameters must be the keys the daemon actually reads.
///
/// This is the shape a generated client sends. Publishing the params struct as
/// one descriptor named `params` type-checks and generates cleanly, and every
/// call it produces fails `-32602`, because the client then sends
/// `{"params": {"params": {…}}}` where the daemon expects the inner object.
#[test]
fn published_parameters_are_the_keys_the_daemon_reads() {
use super::proto::{QueryParams, RoomRef, TransferRef};
let document = document();
let published = |name: &str| -> Vec<String> {
document["methods"]
.as_array()
.expect("methods is an array")
.iter()
.find(|method| method["name"] == name)
.unwrap_or_else(|| panic!("{name} is published"))["params"]
.as_array()
.expect("params is an array")
.iter()
.map(|p| p["name"].as_str().expect("a name").to_string())
.collect()
};
let keys = |value: &Value| -> Vec<String> {
value
.as_object()
.expect("params serialize as an object")
.keys()
.cloned()
.collect()
};
assert_eq!(
published("room.join"),
keys(
&serde_json::to_value(RoomRef {
room: "lobby".into()
})
.unwrap()
)
);
assert_eq!(
published("search.results"),
keys(&serde_json::to_value(QueryParams { query: "x".into() }).unwrap())
);
assert_eq!(
published("download.pause"),
keys(
&serde_json::to_value(TransferRef {
username: "bob".into(),
filename: "a.mp3".into()
})
.unwrap()
)
);
assert!(
published("daemon.status").is_empty(),
"a method that takes nothing publishes no parameters"
);
}
#[test] #[test]
fn a_wire_name_round_trips_through_its_enum() { fn a_wire_name_round_trips_through_its_enum() {
for method in Method::ALL { for method in Method::ALL {
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
//! The data types that cross the wire.
//!
//! Library types never travel as themselves. Each has an explicit DTO and a
//! mechanical conversion, so the published shape is versioned independently of
//! `soulseek-rs-lib` — a refactor there cannot silently change what a
//! third-party script receives, and the compiler points here when one would.
use serde::{Deserialize, Serialize};
use soulseek_rs::types::{
Download, DownloadMetadata, UserPresence, UserStats, UserStatus,
};
use soulseek_rs::{
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss,
SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage,
};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct AuthParams {
/// Required on TCP. Omitted on a Unix socket, where the socket's own
/// permissions already prove the caller is this user.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default)]
pub protocol: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct AuthResult {
pub protocol: u32,
pub daemon_version: String,
/// The Soulseek account the daemon is logged in as.
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct FileDto {
pub name: String,
pub size: u64,
/// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds).
pub attribs: HashMap<u32, u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SearchResultDto {
pub token: u32,
pub username: String,
pub slots: u8,
pub speed: u32,
pub files: Vec<FileDto>,
}
impl From<&SearchResult> for SearchResultDto {
fn from(result: &SearchResult) -> Self {
Self {
token: result.token,
username: result.username.clone(),
slots: result.slots,
speed: result.speed,
files: result
.files
.iter()
.map(|file| FileDto {
name: file.name.clone(),
size: file.size,
attribs: file.attribs.clone(),
})
.collect(),
}
}
}
impl From<SearchResultDto> for SearchResult {
fn from(dto: SearchResultDto) -> Self {
Self {
token: dto.token,
slots: dto.slots,
speed: dto.speed,
files: dto
.files
.into_iter()
.map(|file| File {
username: dto.username.clone(),
name: file.name,
size: file.size,
attribs: file.attribs,
})
.collect(),
username: dto.username,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DownloadStatusDto {
Queued,
InProgress {
bytes_downloaded: u64,
total_bytes: u64,
speed_bytes_per_sec: f64,
},
Paused {
bytes_downloaded: u64,
total_bytes: u64,
},
Completed,
Failed {
reason: Option<String>,
},
TimedOut,
}
impl From<&DownloadStatus> for DownloadStatusDto {
fn from(status: &DownloadStatus) -> Self {
match status {
DownloadStatus::Queued => Self::Queued,
DownloadStatus::InProgress {
bytes_downloaded,
total_bytes,
speed_bytes_per_sec,
} => Self::InProgress {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
speed_bytes_per_sec: *speed_bytes_per_sec,
},
DownloadStatus::Paused {
bytes_downloaded,
total_bytes,
} => Self::Paused {
bytes_downloaded: *bytes_downloaded,
total_bytes: *total_bytes,
},
DownloadStatus::Completed => Self::Completed,
DownloadStatus::Failed(reason) => Self::Failed {
reason: reason.clone(),
},
DownloadStatus::TimedOut => Self::TimedOut,
}
}
}
impl From<DownloadStatusDto> for DownloadStatus {
fn from(dto: DownloadStatusDto) -> Self {
match dto {
DownloadStatusDto::Queued => Self::Queued,
DownloadStatusDto::InProgress {
bytes_downloaded,
total_bytes,
speed_bytes_per_sec,
} => Self::InProgress {
bytes_downloaded,
total_bytes,
speed_bytes_per_sec,
},
DownloadStatusDto::Paused {
bytes_downloaded,
total_bytes,
} => Self::Paused {
bytes_downloaded,
total_bytes,
},
DownloadStatusDto::Completed => Self::Completed,
DownloadStatusDto::Failed { reason } => Self::Failed(reason),
DownloadStatusDto::TimedOut => Self::TimedOut,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DownloadMetadataDto {
#[serde(default)]
pub bitrate: Option<u32>,
#[serde(default)]
pub length_seconds: Option<u32>,
#[serde(default)]
pub peer_upload_speed: Option<u32>,
#[serde(default)]
pub peer_free_slots: Option<u8>,
}
impl From<&DownloadMetadata> for DownloadMetadataDto {
fn from(metadata: &DownloadMetadata) -> Self {
Self {
bitrate: metadata.bitrate,
length_seconds: metadata.length_seconds,
peer_upload_speed: metadata.peer_upload_speed,
peer_free_slots: metadata.peer_free_slots,
}
}
}
impl From<DownloadMetadataDto> for DownloadMetadata {
fn from(dto: DownloadMetadataDto) -> Self {
Self {
bitrate: dto.bitrate,
length_seconds: dto.length_seconds,
peer_upload_speed: dto.peer_upload_speed,
peer_free_slots: dto.peer_free_slots,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DownloadDto {
pub username: String,
pub filename: String,
pub token: u32,
pub size: u64,
pub download_directory: String,
pub status: DownloadStatusDto,
#[serde(default)]
pub queue_position: Option<u32>,
#[serde(default)]
pub metadata: DownloadMetadataDto,
}
impl From<&Download> for DownloadDto {
fn from(download: &Download) -> Self {
Self {
username: download.username.clone(),
filename: download.filename.clone(),
token: download.token,
size: download.size,
download_directory: download.download_directory.clone(),
status: DownloadStatusDto::from(&download.status),
queue_position: download.queue_position,
metadata: DownloadMetadataDto::from(&download.metadata),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum UploadStatusDto {
/// Waiting for a slot, at this 1-based place in the queue.
Queued {
place: u32,
},
InProgress,
Completed,
Cancelled,
Failed {
reason: String,
},
}
impl From<&UploadStatus> for UploadStatusDto {
fn from(status: &UploadStatus) -> Self {
match status {
UploadStatus::Queued(place) => Self::Queued { place: *place },
UploadStatus::InProgress => Self::InProgress,
UploadStatus::Completed => Self::Completed,
UploadStatus::Cancelled => Self::Cancelled,
UploadStatus::Failed(reason) => Self::Failed {
reason: reason.clone(),
},
}
}
}
impl From<UploadStatusDto> for UploadStatus {
fn from(dto: UploadStatusDto) -> Self {
match dto {
UploadStatusDto::Queued { place } => Self::Queued(place),
UploadStatusDto::InProgress => Self::InProgress,
UploadStatusDto::Completed => Self::Completed,
UploadStatusDto::Cancelled => Self::Cancelled,
UploadStatusDto::Failed { reason } => Self::Failed(reason),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UploadInfoDto {
pub username: String,
pub filename: String,
pub size: u64,
pub bytes_sent: u64,
pub status: UploadStatusDto,
pub speed_bytes_per_sec: f64,
}
impl From<&UploadInfo> for UploadInfoDto {
fn from(upload: &UploadInfo) -> Self {
Self {
username: upload.username.clone(),
filename: upload.filename.clone(),
size: upload.size,
bytes_sent: upload.bytes_sent,
status: UploadStatusDto::from(&upload.status),
speed_bytes_per_sec: upload.speed_bytes_per_sec,
}
}
}
impl From<UploadInfoDto> for UploadInfo {
fn from(dto: UploadInfoDto) -> Self {
Self {
username: dto.username,
filename: dto.filename,
size: dto.size,
bytes_sent: dto.bytes_sent,
status: dto.status.into(),
speed_bytes_per_sec: dto.speed_bytes_per_sec,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct RoomInfoDto {
pub name: String,
pub user_count: u32,
}
impl From<&RoomInfo> for RoomInfoDto {
fn from(room: &RoomInfo) -> Self {
Self {
name: room.name.clone(),
user_count: room.user_count,
}
}
}
impl From<RoomInfoDto> for RoomInfo {
fn from(dto: RoomInfoDto) -> Self {
Self {
name: dto.name,
user_count: dto.user_count,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum RoomEventDto {
List {
rooms: Vec<RoomInfoDto>,
},
Joined {
room: String,
users: Vec<String>,
},
Left {
room: String,
},
Message {
room: String,
username: String,
message: String,
},
UserJoined {
room: String,
username: String,
},
UserLeft {
room: String,
username: String,
},
}
impl From<&RoomEvent> for RoomEventDto {
fn from(event: &RoomEvent) -> Self {
match event {
RoomEvent::List(rooms) => Self::List {
rooms: rooms.iter().map(RoomInfoDto::from).collect(),
},
RoomEvent::Joined { room, users } => Self::Joined {
room: room.clone(),
users: users.clone(),
},
RoomEvent::Left { room } => Self::Left { room: room.clone() },
RoomEvent::Message {
room,
username,
message,
} => Self::Message {
room: room.clone(),
username: username.clone(),
message: message.clone(),
},
RoomEvent::UserJoined { room, username } => Self::UserJoined {
room: room.clone(),
username: username.clone(),
},
RoomEvent::UserLeft { room, username } => Self::UserLeft {
room: room.clone(),
username: username.clone(),
},
}
}
}
impl From<RoomEventDto> for RoomEvent {
fn from(dto: RoomEventDto) -> Self {
match dto {
RoomEventDto::List { rooms } => {
Self::List(rooms.into_iter().map(RoomInfo::from).collect())
}
RoomEventDto::Joined { room, users } => {
Self::Joined { room, users }
}
RoomEventDto::Left { room } => Self::Left { room },
RoomEventDto::Message {
room,
username,
message,
} => Self::Message {
room,
username,
message,
},
RoomEventDto::UserJoined { room, username } => {
Self::UserJoined { room, username }
}
RoomEventDto::UserLeft { room, username } => {
Self::UserLeft { room, username }
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserPresenceDto {
/// `online`, `away`, or `offline`.
pub status: String,
pub privileged: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserStatsDto {
pub average_speed: u32,
pub shared_files: u32,
pub shared_folders: u32,
}
/// Each half is `None` until its reply lands, so a consumer can tell "the
/// server said offline" from "the server has not answered yet".
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserInfoDto {
pub username: String,
#[serde(default)]
pub presence: Option<UserPresenceDto>,
#[serde(default)]
pub stats: Option<UserStatsDto>,
}
impl From<&UserInfo> for UserInfoDto {
fn from(info: &UserInfo) -> Self {
Self {
username: info.username.clone(),
presence: info.presence.map(|presence| UserPresenceDto {
status: presence.status.to_string(),
privileged: presence.privileged,
}),
stats: info.stats.map(|stats| UserStatsDto {
average_speed: stats.average_speed,
shared_files: stats.shared_files,
shared_folders: stats.shared_folders,
}),
}
}
}
impl From<UserInfoDto> for UserInfo {
fn from(dto: UserInfoDto) -> Self {
let mut info = Self::pending(dto.username);
info.presence = dto.presence.map(|presence| UserPresence {
status: match presence.status.as_str() {
"online" => UserStatus::Online,
"away" => UserStatus::Away,
_ => UserStatus::Offline,
},
privileged: presence.privileged,
});
info.stats = dto.stats.map(|stats| UserStats {
average_speed: stats.average_speed,
shared_files: stats.shared_files,
shared_folders: stats.shared_folders,
});
info
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SharedDirectoryDto {
pub name: String,
/// Basename and size; join with the directory to get a downloadable path.
pub files: Vec<(String, u64)>,
}
impl From<&SharedDirectory> for SharedDirectoryDto {
fn from(directory: &SharedDirectory) -> Self {
Self {
name: directory.name.clone(),
files: directory.files.clone(),
}
}
}
impl From<SharedDirectoryDto> for SharedDirectory {
fn from(dto: SharedDirectoryDto) -> Self {
Self {
name: dto.name,
files: dto.files,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserMessageDto {
pub id: u32,
pub timestamp: u32,
pub username: String,
pub message: String,
pub new_message: bool,
}
impl From<&UserMessage> for UserMessageDto {
fn from(message: &UserMessage) -> Self {
Self {
id: message.id(),
timestamp: message.timestamp(),
username: message.username().to_string(),
message: message.message().to_string(),
new_message: message.is_new(),
}
}
}
impl From<UserMessageDto> for UserMessage {
fn from(dto: UserMessageDto) -> Self {
Self::new(
dto.id,
dto.timestamp,
dto.username,
dto.message,
dto.new_message,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SessionLossDto {
Displaced,
Disconnected,
}
impl From<SessionLoss> for SessionLossDto {
fn from(loss: SessionLoss) -> Self {
match loss {
SessionLoss::Displaced => Self::Displaced,
SessionLoss::Disconnected => Self::Disconnected,
}
}
}
impl From<SessionLossDto> for SessionLoss {
fn from(dto: SessionLossDto) -> Self {
match dto {
SessionLossDto::Displaced => Self::Displaced,
SessionLossDto::Disconnected => Self::Disconnected,
}
}
}
/// What the daemon is and what its session is doing, from `daemon.status`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DaemonStatus {
pub username: String,
pub server: String,
pub daemon_version: String,
pub protocol: u32,
#[serde(default)]
pub listen_port: Option<u16>,
pub shared_folders: u32,
pub shared_files: u32,
pub download_dir: String,
#[serde(default)]
pub session_loss: Option<SessionLossDto>,
/// Control connections currently attached.
pub clients: usize,
pub uptime_secs: u64,
}
/// What the share index holds, from `shares.status` / `shares.reindex`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SharesStatus {
pub folders: u32,
pub files: u32,
pub directories: Vec<String>,
}
+150
View File
@@ -0,0 +1,150 @@
//! JSON-RPC 2.0 framing: what a request, a reply, and an error look like on
//! the wire, independent of the methods they carry.
use crate::output::Exit;
use serde::{Deserialize, Serialize};
/// A request from a client. `id` is absent for a notification, which by
/// JSON-RPC rule gets no reply.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Request {
#[serde(default = "jsonrpc_version")]
pub jsonrpc: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<serde_json::Value>,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request,
/// and is absent on a daemon-initiated notification.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Response {
#[serde(default = "jsonrpc_version")]
pub jsonrpc: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<RpcError>,
}
fn jsonrpc_version() -> String {
"2.0".to_string()
}
/// A failure, carrying the exit code the same command would have produced
/// locally so a remote run exits exactly as a local one does.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RpcError {
pub code: i32,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<ErrorData>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ErrorData {
/// The `Exit` discriminant this failure maps to.
pub exit: u8,
}
/// Application errors all share one code; the exit status in `data` is what a
/// caller branches on.
pub const CODE_APPLICATION: i32 = -32000;
pub const CODE_PARSE: i32 = -32700;
pub const CODE_INVALID_REQUEST: i32 = -32600;
pub const CODE_METHOD_NOT_FOUND: i32 = -32601;
pub const CODE_INVALID_PARAMS: i32 = -32602;
impl RpcError {
#[must_use]
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
data: None,
}
}
/// An application failure that should reproduce `exit` on the client.
#[must_use]
pub fn application(exit: Exit, message: impl Into<String>) -> Self {
Self {
code: CODE_APPLICATION,
message: message.into(),
data: Some(ErrorData { exit: exit.code() }),
}
}
/// The exit status this error asks the client to produce, defaulting to a
/// generic failure when the daemon did not say.
#[must_use]
pub fn exit(&self) -> Exit {
match self.data.as_ref().map(|data| data.exit) {
Some(0) => Exit::Ok,
Some(2) => Exit::Usage,
Some(3) => Exit::Connection,
Some(4) => Exit::NoResults,
Some(5) => Exit::Timeout,
Some(6) => Exit::Transfer,
Some(7) => Exit::SessionLost,
_ => Exit::Failure,
}
}
}
impl Response {
#[must_use]
pub fn result(
id: Option<serde_json::Value>,
value: serde_json::Value,
) -> Self {
Self {
jsonrpc: jsonrpc_version(),
id,
method: None,
params: None,
result: Some(value),
error: None,
}
}
/// A failed reply.
///
/// An error always carries an `id` member, explicitly `null` when the
/// request was too malformed to have one. JSON-RPC requires it, and
/// without it a client following the "no id means a notification" rule
/// would drop the error and wait out its timeout for a reply that is
/// already there.
#[must_use]
pub fn failure(id: Option<serde_json::Value>, error: RpcError) -> Self {
Self {
jsonrpc: jsonrpc_version(),
id: Some(id.unwrap_or(serde_json::Value::Null)),
method: None,
params: None,
result: None,
error: Some(error),
}
}
/// A daemon-initiated event: no id, so no reply is expected.
#[must_use]
pub fn notification(method: &str, params: serde_json::Value) -> Self {
Self {
jsonrpc: jsonrpc_version(),
id: None,
method: Some(method.to_string()),
params: Some(params),
result: None,
error: None,
}
}
}
+176
View File
@@ -0,0 +1,176 @@
//! What each method takes and returns, and what each pushed event carries.
use super::dto::{
DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto,
SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto,
};
use serde::{Deserialize, Serialize};
/// A void result. JSON-RPC allows a null result, but an object leaves room to
/// add a field later without changing the shape a client already parses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Ack {
pub ok: bool,
}
impl Ack {
pub const OK: Self = Self { ok: true };
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct QueryParams {
pub query: String,
}
/// Names one transfer, which is how both directions are addressed.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct TransferRef {
pub username: String,
pub filename: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct RoomRef {
pub room: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserRef {
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SayParams {
pub room: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct MessageParams {
pub username: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DownloadStartParams {
pub username: String,
pub filename: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub metadata: DownloadMetadataDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SlotsParams {
pub slots: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DirectoriesParams {
pub directories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SearchResults {
pub results: Vec<SearchResultDto>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Downloads {
pub downloads: Vec<DownloadDto>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DownloadStarted {
pub download: DownloadDto,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Uploads {
pub uploads: Vec<UploadInfoDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Members {
pub users: Vec<String>,
}
/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a
/// direction, because a conversation the daemon collected includes what this
/// account sent as well as what it received.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct ChatMessageDto {
/// The other party, whichever way the message went.
pub peer: String,
pub outgoing: bool,
pub text: String,
/// Unix seconds.
pub at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Messages {
pub messages: Vec<ChatMessageDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct UserResult {
#[serde(default)]
pub user: Option<UserInfoDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct Seconds {
#[serde(default)]
pub seconds: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct IntervalSeconds {
pub seconds: u64,
}
/// A peer answered a browse request. Carried whole rather than as a "go ask
/// for it" nudge, because the daemon's own copy is consumed when it drains.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct BrowseEvent {
pub username: String,
pub directories: Vec<SharedDirectoryDto>,
}
/// One step in a transfer this client started.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DownloadStatusEvent {
pub username: String,
pub filename: String,
pub status: DownloadStatusDto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct SessionLossEvent {
pub loss: SessionLossDto,
}
+402
View File
@@ -0,0 +1,402 @@
//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it.
//!
//! Library types never travel as themselves. Every payload is a DTO defined
//! here with an explicit conversion, so the wire format is versioned
//! independently of the library: a refactor inside `soulseek-rs-lib` cannot
//! silently change what a third-party script receives, and the compiler points
//! at this file when one would.
//!
//! The schemas published in `docs/openrpc.json` are derived from these types
//! (see the tests at the bottom), which is what keeps the advertised contract
//! and the running daemon in agreement.
/// Bumped when a change would break a client written against the old shape.
/// Sent in the `auth` reply so a mismatch is caught at connect time rather
/// than as a confusing failure three calls later.
pub const PROTOCOL_VERSION: u32 = 1;
/// The OpenRPC document, served by `rpc.discover` so a generator can point at
/// a running daemon instead of needing this repository.
pub const OPENRPC: &str = include_str!("../../../../docs/openrpc.json");
mod dto;
mod envelope;
mod method;
pub use dto::*;
pub use envelope::*;
pub use method::*;
/// Declare a wire vocabulary once: the enum, the complete list, and the
/// name mapping in both directions.
///
/// Everything that consumes one of these matches on the enum, so the match is
/// exhaustive and adding a line here is a compile error everywhere the new
/// member still has to be handled — the dispatcher, the published schema, the
/// remote client. A `&[&str]` table checked by a test would only fail after
/// the fact, and only if someone remembered to write the test.
macro_rules! wire_vocabulary {
($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum $name {
$($variant),*
}
impl $name {
/// Every member, in published order.
pub const ALL: &'static [Self] = &[$(Self::$variant),*];
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
$(Self::$variant => $wire),*
}
}
#[must_use]
pub fn from_wire(name: &str) -> Option<Self> {
match name {
$($wire => Some(Self::$variant),)*
_ => None,
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.wire_name())
}
}
};
}
wire_vocabulary! {
/// Every method the daemon answers.
Method {
Auth => "auth",
DaemonStatus => "daemon.status",
DaemonStop => "daemon.stop",
RpcDiscover => "rpc.discover",
SearchStart => "search.start",
SearchResults => "search.results",
SearchWishlist => "search.wishlist",
SearchWishlistInterval => "search.wishlist_interval",
DownloadStart => "download.start",
DownloadList => "download.list",
DownloadPause => "download.pause",
DownloadResume => "download.resume",
DownloadRemove => "download.remove",
DownloadRemoveQueued => "download.remove_queued",
UploadList => "upload.list",
UploadCancel => "upload.cancel",
UploadSlots => "upload.slots",
PrivilegesCheck => "privileges.check",
PrivilegesOwn => "privileges.own",
RoomListRequest => "room.list_request",
RoomJoin => "room.join",
RoomLeave => "room.leave",
RoomSay => "room.say",
RoomMembers => "room.members",
MessageSend => "message.send",
MessageHistory => "message.history",
BrowseUser => "browse.user",
UserRequest => "user.request",
UserInfoOf => "user.info",
SharesStatusOf => "shares.status",
SharesSet => "shares.set",
SharesReindex => "shares.reindex",
}
}
wire_vocabulary! {
/// Events the daemon pushes without being asked.
///
/// These exist because the library's `take_*` accessors are destructive:
/// with two clients attached, whichever drained first would steal the
/// others' events. The daemon is the single drainer and fans out.
Event {
Room => "event.room",
Message => "event.message",
Upload => "event.upload",
DownloadStatus => "event.download_status",
Browse => "event.browse",
SessionLoss => "event.session_loss",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::Exit;
use soulseek_rs::types::{UserPresence, UserStats, UserStatus};
use soulseek_rs::{
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult,
SharedDirectory, UploadStatus, UserInfo, UserMessage,
};
use std::collections::HashMap;
#[test]
fn a_search_result_survives_the_round_trip() {
let mut attribs = HashMap::new();
attribs.insert(0, 320);
attribs.insert(1, 210);
let original = SearchResult {
token: 7,
slots: 2,
speed: 900,
username: "bob".into(),
files: vec![File {
username: "bob".into(),
name: "@@x\\a.mp3".into(),
size: 4096,
attribs,
}],
};
let json = serde_json::to_string(&SearchResultDto::from(&original))
.expect("a DTO serializes");
let back: SearchResult = serde_json::from_str::<SearchResultDto>(&json)
.unwrap()
.into();
assert_eq!(back.token, original.token);
assert_eq!(back.username, original.username);
assert_eq!(back.slots, original.slots);
assert_eq!(back.speed, original.speed);
assert_eq!(back.files.len(), 1);
assert_eq!(back.files[0].name, "@@x\\a.mp3");
assert_eq!(back.files[0].size, 4096);
assert_eq!(back.files[0].attribs.get(&0), Some(&320));
// The per-file username is carried by the parent result, so it has to
// be restored rather than lost.
assert_eq!(back.files[0].username, "bob");
}
#[test]
fn every_download_status_survives_the_round_trip() {
let statuses = [
DownloadStatus::Queued,
DownloadStatus::InProgress {
bytes_downloaded: 10,
total_bytes: 100,
speed_bytes_per_sec: 1.5,
},
DownloadStatus::Paused {
bytes_downloaded: 10,
total_bytes: 100,
},
DownloadStatus::Completed,
DownloadStatus::Failed(Some("gone".into())),
DownloadStatus::Failed(None),
DownloadStatus::TimedOut,
];
for status in &statuses {
let dto = DownloadStatusDto::from(status);
let json = serde_json::to_string(&dto).unwrap();
let back: DownloadStatusDto = serde_json::from_str(&json).unwrap();
assert_eq!(dto, back, "{status:?} must survive the wire");
assert_eq!(
DownloadStatusDto::from(&DownloadStatus::from(back)),
dto,
"{status:?} must convert back to the same library value"
);
}
}
#[test]
fn every_upload_status_survives_the_round_trip() {
let statuses = [
UploadStatus::Queued(3),
UploadStatus::InProgress,
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".into()),
];
for status in &statuses {
let dto = UploadStatusDto::from(status);
let json = serde_json::to_string(&dto).unwrap();
let back: UploadStatus =
serde_json::from_str::<UploadStatusDto>(&json)
.unwrap()
.into();
assert_eq!(&back, status);
}
}
#[test]
fn every_room_event_survives_the_round_trip() {
let events = [
RoomEvent::List(vec![RoomInfo {
name: "lobby".into(),
user_count: 42,
}]),
RoomEvent::Joined {
room: "lobby".into(),
users: vec!["bob".into()],
},
RoomEvent::Left {
room: "lobby".into(),
},
RoomEvent::Message {
room: "lobby".into(),
username: "bob".into(),
message: "hi".into(),
},
RoomEvent::UserJoined {
room: "lobby".into(),
username: "sue".into(),
},
RoomEvent::UserLeft {
room: "lobby".into(),
username: "sue".into(),
},
];
for event in &events {
let json =
serde_json::to_string(&RoomEventDto::from(event)).unwrap();
let back: RoomEvent =
serde_json::from_str::<RoomEventDto>(&json).unwrap().into();
assert_eq!(&back, event);
}
}
#[test]
fn a_partly_answered_user_stays_partly_answered() {
// "the server has not said" must not arrive as "offline, shares
// nothing": that is the difference between waiting and giving up.
let pending = UserInfo::pending("bob".into());
let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap();
let back: UserInfo =
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
assert_eq!(back.username, "bob");
assert!(back.presence.is_none());
assert!(back.stats.is_none());
assert!(!back.is_complete());
}
#[test]
fn a_complete_user_survives_the_round_trip() {
let mut info = UserInfo::pending("bob".into());
info.presence = Some(UserPresence {
status: UserStatus::Away,
privileged: true,
});
info.stats = Some(UserStats {
average_speed: 500,
shared_files: 10,
shared_folders: 2,
});
let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap();
let back: UserInfo =
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
assert_eq!(back, info);
}
#[test]
fn a_private_message_survives_the_round_trip() {
let message =
UserMessage::new(9, 1234, "bob".into(), "hi".into(), true);
let json =
serde_json::to_string(&UserMessageDto::from(&message)).unwrap();
let back: UserMessage = serde_json::from_str::<UserMessageDto>(&json)
.unwrap()
.into();
assert_eq!(back.id(), 9);
assert_eq!(back.timestamp(), 1234);
assert_eq!(back.username(), "bob");
assert_eq!(back.message(), "hi");
}
#[test]
fn a_shared_listing_survives_the_round_trip() {
let directory = SharedDirectory {
name: "@@x\\Music".into(),
files: vec![("a.mp3".into(), 4096)],
};
let json = serde_json::to_string(&SharedDirectoryDto::from(&directory))
.unwrap();
let back: SharedDirectory =
serde_json::from_str::<SharedDirectoryDto>(&json)
.unwrap()
.into();
assert_eq!(back.name, directory.name);
assert_eq!(back.files, directory.files);
}
#[test]
fn an_error_carries_the_exit_code_a_local_run_would_have_produced() {
for exit in [
Exit::Usage,
Exit::Connection,
Exit::NoResults,
Exit::Timeout,
Exit::Transfer,
Exit::SessionLost,
] {
let error = RpcError::application(exit, "nope");
let json = serde_json::to_string(&error).unwrap();
let back: RpcError = serde_json::from_str(&json).unwrap();
assert_eq!(back.exit(), exit);
}
}
#[test]
fn an_error_without_an_exit_code_is_a_generic_failure() {
let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method");
assert_eq!(error.exit(), Exit::Failure);
}
#[test]
fn a_request_parses_without_optional_fields() {
let request: Request = serde_json::from_str(
r#"{"jsonrpc":"2.0","method":"daemon.status"}"#,
)
.expect("params and id are optional");
assert_eq!(request.method, "daemon.status");
assert!(request.id.is_none());
assert!(request.params.is_null());
}
#[test]
fn an_error_always_carries_an_id_even_when_there_was_none() {
// JSON-RPC requires it, and without it a client following the
// documented "no id means a notification" rule drops the error and
// waits out its timeout for a reply that already arrived.
let json = serde_json::to_string(&Response::failure(
None,
RpcError::new(CODE_PARSE, "unparseable"),
))
.expect("a response is plain data");
assert!(json.contains("\"id\":null"), "{json}");
}
#[test]
fn a_notification_carries_no_id_so_no_reply_is_expected() {
let notification =
Response::notification("event.room", serde_json::json!({"a": 1}));
let json = serde_json::to_string(&notification).unwrap();
assert!(!json.contains("\"id\""), "{json}");
assert!(json.contains("\"method\":\"event.room\""), "{json}");
}
#[test]
fn a_response_serializes_exactly_one_of_result_and_error() {
let ok = serde_json::to_string(&Response::result(
Some(serde_json::json!(1)),
serde_json::json!({}),
))
.unwrap();
assert!(ok.contains("\"result\""));
assert!(!ok.contains("\"error\""));
let failed = serde_json::to_string(&Response::failure(
Some(serde_json::json!(1)),
RpcError::application(Exit::NoResults, "nothing"),
))
.unwrap();
assert!(failed.contains("\"error\""));
assert!(!failed.contains("\"result\""));
}
}
@@ -7,12 +7,10 @@
use super::hub::{Hub, PendingBrowses, PendingDownload}; use super::hub::{Hub, PendingBrowses, PendingDownload};
use super::proto::{ use super::proto::{
Ack, AuthParams, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto,
ChatMessageDto, DaemonStatus, DirectoriesParams, DownloadDto, DownloadStartParams,
CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE, DaemonStatus, DownloadStarted, Downloads, IntervalSeconds, Members, MessageParams,
DirectoriesParams, DownloadDto, DownloadStartParams, DownloadStarted, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, RoomRef,
Downloads, IntervalSeconds, MessageParams, Members, Messages, Method,
OPENRPC, PROTOCOL_VERSION, QueryParams, Request, Response, RoomRef,
RpcError, SayParams, SearchResultDto, SearchResults, Seconds, SharesStatus, RpcError, SayParams, SearchResultDto, SearchResults, Seconds, SharesStatus,
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef, SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef,
UserResult, UserResult,
@@ -21,21 +19,10 @@ use crate::api::SessionApi;
use crate::output::Exit; use crate::output::Exit;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// How long a failed authentication costs the caller. Not a serious defence —
/// the token is 256 bits — but it keeps a wrong-token loop off the CPU.
const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1);
/// Replies waiting to go out on one connection. Bounded, so a client that has
/// stopped reading cannot make the daemon buffer without limit.
const WRITE_QUEUE_DEPTH: usize = 1024;
/// Everything a connection needs to answer a request. /// Everything a connection needs to answer a request.
pub struct Daemon { pub struct Daemon {
pub session: Arc<dyn SessionApi>, pub session: Arc<dyn SessionApi>,
@@ -49,6 +36,9 @@ pub struct Daemon {
pub token: String, pub token: String,
pub started: Instant, pub started: Instant,
pub stop: Arc<AtomicBool>, pub stop: Arc<AtomicBool>,
/// Connections currently being served, capped so an unauthenticated peer
/// cannot spawn threads without bound.
pub open: Arc<std::sync::atomic::AtomicUsize>,
/// Rooms this session has joined, so a restart can rejoin them. /// Rooms this session has joined, so a restart can rejoin them.
pub rooms: Mutex<Vec<String>>, pub rooms: Mutex<Vec<String>>,
} }
@@ -59,17 +49,27 @@ impl Daemon {
/// The match is exhaustive over [`Method`], so a new method cannot be added /// The match is exhaustive over [`Method`], so a new method cannot be added
/// to the protocol without being implemented here. /// to the protocol without being implemented here.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn call(&self, method: Method, params: Value) -> Result<Value, RpcError> { pub fn call(
&self,
method: Method,
params: Value,
) -> Result<Value, RpcError> {
match method { match method {
// Handled during the handshake; reaching here means a second auth. // Handled during the handshake; reaching here means a second
Method::Auth => ok(Ack::OK), // `auth` on an established connection, which is answered with the
// shape the contract publishes rather than a bare acknowledgement.
Method::Auth => ok(self.identify()),
Method::DaemonStatus => ok(self.status()), Method::DaemonStatus => ok(self.status()),
Method::DaemonStop => { Method::DaemonStop => {
self.stop.store(true, Ordering::Relaxed); self.stop.store(true, Ordering::Relaxed);
ok(Ack::OK) ok(Ack::OK)
} }
Method::RpcDiscover => serde_json::from_str(OPENRPC) Method::RpcDiscover => serde_json::from_str(OPENRPC).map_err(|e| {
.map_err(|e| internal(format!("the contract is unreadable: {e}"))), RpcError::new(
CODE_APPLICATION,
format!("the contract is unreadable: {e}"),
)
}),
Method::SearchStart => { Method::SearchStart => {
let query: QueryParams = parse(params)?; let query: QueryParams = parse(params)?;
@@ -78,7 +78,12 @@ impl Daemon {
// what keeps one client's search from blocking the daemon. // what keeps one client's search from blocking the daemon.
self.session self.session
.search_with_cancel(&query.query, Duration::ZERO, None) .search_with_cancel(&query.query, Duration::ZERO, None)
.map_err(|e| failed(Exit::Connection, format!("search failed: {e}")))?; .map_err(|e| {
RpcError::application(
Exit::Connection,
format!("search failed: {e}"),
)
})?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::SearchResults => { Method::SearchResults => {
@@ -94,9 +99,9 @@ impl Daemon {
} }
Method::SearchWishlist => { Method::SearchWishlist => {
let query: QueryParams = parse(params)?; let query: QueryParams = parse(params)?;
self.session self.session.start_wishlist_search(&query.query).map_err(
.start_wishlist_search(&query.query) |e| RpcError::application(Exit::Connection, e.to_string()),
.map_err(|e| failed(Exit::Connection, e.to_string()))?; )?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::SearchWishlistInterval => ok(IntervalSeconds { Method::SearchWishlistInterval => ok(IntervalSeconds {
@@ -123,17 +128,19 @@ impl Daemon {
Method::DownloadResume => { Method::DownloadResume => {
let transfer: TransferRef = parse(params)?; let transfer: TransferRef = parse(params)?;
ok(Ack { ok(Ack {
ok: self ok: self.session.resume_download(
.session &transfer.username,
.resume_download(&transfer.username, &transfer.filename), &transfer.filename,
),
}) })
} }
Method::DownloadRemove => { Method::DownloadRemove => {
let transfer: TransferRef = parse(params)?; let transfer: TransferRef = parse(params)?;
ok(Ack { ok(Ack {
ok: self ok: self.session.remove_download(
.session &transfer.username,
.remove_download(&transfer.username, &transfer.filename), &transfer.filename,
),
}) })
} }
Method::DownloadRemoveQueued => { Method::DownloadRemoveQueued => {
@@ -169,9 +176,9 @@ impl Daemon {
} }
Method::PrivilegesCheck => { Method::PrivilegesCheck => {
self.session self.session.check_privileges().map_err(|e| {
.check_privileges() RpcError::application(Exit::Connection, e.to_string())
.map_err(|e| failed(Exit::Connection, e.to_string()))?; })?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::PrivilegesOwn => ok(Seconds { Method::PrivilegesOwn => ok(Seconds {
@@ -179,32 +186,32 @@ impl Daemon {
}), }),
Method::RoomListRequest => { Method::RoomListRequest => {
self.session self.session.request_room_list().map_err(|e| {
.request_room_list() RpcError::application(Exit::Connection, e.to_string())
.map_err(|e| failed(Exit::Connection, e.to_string()))?; })?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::RoomJoin => { Method::RoomJoin => {
let room: RoomRef = parse(params)?; let room: RoomRef = parse(params)?;
self.session self.session.join_room(&room.room).map_err(|e| {
.join_room(&room.room) RpcError::application(Exit::Connection, e.to_string())
.map_err(|e| failed(Exit::Connection, e.to_string()))?; })?;
self.joined(&room.room, true); self.joined(&room.room, true);
ok(Ack::OK) ok(Ack::OK)
} }
Method::RoomLeave => { Method::RoomLeave => {
let room: RoomRef = parse(params)?; let room: RoomRef = parse(params)?;
self.session self.session.leave_room(&room.room).map_err(|e| {
.leave_room(&room.room) RpcError::application(Exit::Connection, e.to_string())
.map_err(|e| failed(Exit::Connection, e.to_string()))?; })?;
self.joined(&room.room, false); self.joined(&room.room, false);
ok(Ack::OK) ok(Ack::OK)
} }
Method::RoomSay => { Method::RoomSay => {
let say: SayParams = parse(params)?; let say: SayParams = parse(params)?;
self.session self.session.say_in_room(&say.room, &say.message).map_err(
.say_in_room(&say.room, &say.message) |e| RpcError::application(Exit::Connection, e.to_string()),
.map_err(|e| failed(Exit::Connection, e.to_string()))?; )?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::RoomMembers => { Method::RoomMembers => {
@@ -218,7 +225,9 @@ impl Daemon {
let message: MessageParams = parse(params)?; let message: MessageParams = parse(params)?;
self.session self.session
.send_private_message(&message.username, &message.message) .send_private_message(&message.username, &message.message)
.map_err(|e| failed(Exit::Connection, e.to_string()))?; .map_err(|e| {
RpcError::application(Exit::Connection, e.to_string())
})?;
// Record our own half of the conversation: the drainer only // Record our own half of the conversation: the drainer only
// ever sees incoming messages, so without this a client that // ever sees incoming messages, so without this a client that
// attaches later reads a one-sided chat. // attaches later reads a one-sided chat.
@@ -241,15 +250,18 @@ impl Daemon {
self.browses.expect(&user.username); self.browses.expect(&user.username);
if let Err(e) = self.session.browse_user(&user.username) { if let Err(e) = self.session.browse_user(&user.username) {
self.browses.forget(&user.username); self.browses.forget(&user.username);
return Err(failed(Exit::Connection, e.to_string())); return Err(RpcError::application(
Exit::Connection,
e.to_string(),
));
} }
ok(Ack::OK) ok(Ack::OK)
} }
Method::UserRequest => { Method::UserRequest => {
let user: UserRef = parse(params)?; let user: UserRef = parse(params)?;
self.session self.session.request_user_info(&user.username).map_err(
.request_user_info(&user.username) |e| RpcError::application(Exit::Connection, e.to_string()),
.map_err(|e| failed(Exit::Connection, e.to_string()))?; )?;
ok(Ack::OK) ok(Ack::OK)
} }
Method::UserInfoOf => { Method::UserInfoOf => {
@@ -268,13 +280,17 @@ impl Daemon {
let directories: DirectoriesParams = parse(params)?; let directories: DirectoriesParams = parse(params)?;
self.session self.session
.set_shared_directories(directories.directories) .set_shared_directories(directories.directories)
.map_err(|e| failed(Exit::Usage, e.to_string()))?; .map_err(|e| {
RpcError::application(Exit::Usage, e.to_string())
})?;
ok(self.shares()) ok(self.shares())
} }
Method::SharesReindex => { Method::SharesReindex => {
self.session self.session
.set_shared_directories(self.session.shared_directories()) .set_shared_directories(self.session.shared_directories())
.map_err(|e| failed(Exit::Usage, e.to_string()))?; .map_err(|e| {
RpcError::application(Exit::Usage, e.to_string())
})?;
ok(self.shares()) ok(self.shares())
} }
} }
@@ -303,9 +319,11 @@ impl Daemon {
&self, &self,
params: DownloadStartParams, params: DownloadStartParams,
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
let directory = params // The daemon's own directory, always. Letting a caller name a path
.download_directory // would hand any authenticated client an arbitrary write on this
.unwrap_or_else(|| self.download_dir.clone()); // host's filesystem, and a remote one cannot see that filesystem to
// choose sensibly anyway.
let directory = self.download_dir.clone();
let (download, updates) = self let (download, updates) = self
.session .session
.download_with_metadata( .download_with_metadata(
@@ -315,7 +333,12 @@ impl Daemon {
directory, directory,
params.metadata.into(), params.metadata.into(),
) )
.map_err(|e| failed(Exit::Transfer, format!("cannot start: {e}")))?; .map_err(|e| {
RpcError::application(
Exit::Transfer,
format!("cannot start: {e}"),
)
})?;
if let Ok(mut pending) = self.downloads.lock() { if let Ok(mut pending) = self.downloads.lock() {
pending.push(PendingDownload { pending.push(PendingDownload {
@@ -338,6 +361,14 @@ impl Daemon {
} }
} }
fn identify(&self) -> AuthResult {
AuthResult {
protocol: PROTOCOL_VERSION,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
username: self.session.username(),
}
}
fn status(&self) -> DaemonStatus { fn status(&self) -> DaemonStatus {
let (folders, files) = self.session.shared_counts(); let (folders, files) = self.session.shared_counts();
DaemonStatus { DaemonStatus {
@@ -357,8 +388,12 @@ impl Daemon {
} }
fn ok<T: serde::Serialize>(value: T) -> Result<Value, RpcError> { fn ok<T: serde::Serialize>(value: T) -> Result<Value, RpcError> {
serde_json::to_value(value) serde_json::to_value(value).map_err(|e| {
.map_err(|e| internal(format!("cannot serialize the reply: {e}"))) RpcError::new(
CODE_APPLICATION,
format!("cannot serialize the reply: {e}"),
)
})
} }
fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> { fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> {
@@ -366,14 +401,6 @@ fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> {
.map_err(|e| RpcError::new(CODE_INVALID_PARAMS, e.to_string())) .map_err(|e| RpcError::new(CODE_INVALID_PARAMS, e.to_string()))
} }
fn failed(exit: Exit, message: impl Into<String>) -> RpcError {
RpcError::application(exit, message)
}
fn internal(message: impl Into<String>) -> RpcError {
RpcError::new(CODE_APPLICATION, message)
}
/// Wall-clock seconds, to stamp a message the way the server stamps the ones /// Wall-clock seconds, to stamp a message the way the server stamps the ones
/// it delivers. A clock before the epoch reads as zero rather than panicking. /// it delivers. A clock before the epoch reads as zero rather than panicking.
fn now_seconds() -> i64 { fn now_seconds() -> i64 {
@@ -382,336 +409,27 @@ fn now_seconds() -> i64 {
.map_or(0, |since| since.as_secs() as i64) .map_or(0, |since| since.as_secs() as i64)
} }
// --------------------------------------------------------------------------- mod transport;
// Transports
// ---------------------------------------------------------------------------
/// Where control connections arrive.
///
/// The Unix socket is the local path — its file permissions are the
/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in
/// and always needs the token.
pub enum Listener {
#[cfg(unix)] #[cfg(unix)]
Unix(std::os::unix::net::UnixListener), pub use transport::bind_unix;
Tcp(TcpListener), pub use transport::{Listener, bind_tcp};
}
pub enum Stream {
#[cfg(unix)]
Unix(std::os::unix::net::UnixStream),
Tcp(TcpStream),
}
impl Stream {
fn try_clone(&self) -> std::io::Result<Self> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.try_clone().map(Self::Unix),
Self::Tcp(stream) => stream.try_clone().map(Self::Tcp),
}
}
/// Whether the transport itself proves who is calling.
const fn trusted_by_transport(&self) -> bool {
match self {
#[cfg(unix)]
Self::Unix(_) => true,
Self::Tcp(_) => false,
}
}
}
impl std::io::Read for Stream {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.read(buffer),
Self::Tcp(stream) => stream.read(buffer),
}
}
}
impl Write for Stream {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.write(buffer),
Self::Tcp(stream) => stream.write(buffer),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.flush(),
Self::Tcp(stream) => stream.flush(),
}
}
}
impl Listener {
/// Accept forever, handing each connection to its own thread.
pub fn serve(self, daemon: Arc<Daemon>) {
match self {
#[cfg(unix)]
Self::Unix(listener) => {
for stream in listener.incoming() {
match stream {
Ok(stream) => spawn_connection(
Stream::Unix(stream),
Arc::clone(&daemon),
),
Err(e) => {
soulseek_rs::warn!("control accept failed: {e}");
}
}
}
}
Self::Tcp(listener) => {
for stream in listener.incoming() {
match stream {
Ok(stream) => spawn_connection(
Stream::Tcp(stream),
Arc::clone(&daemon),
),
Err(e) => {
soulseek_rs::warn!("control accept failed: {e}");
}
}
}
}
}
}
}
fn spawn_connection(stream: Stream, daemon: Arc<Daemon>) {
std::thread::spawn(move || {
if let Err(e) = handle(stream, daemon) {
soulseek_rs::debug!("control connection ended: {e}");
}
});
}
/// Serve one connection: authenticate, then answer requests until it closes.
fn handle(stream: Stream, daemon: Arc<Daemon>) -> std::io::Result<()> {
let reader = BufReader::new(stream.try_clone()?);
let trusted = stream.trusted_by_transport();
let (out, outbox) = sync_channel::<Response>(WRITE_QUEUE_DEPTH);
let writer = std::thread::spawn(move || write_loop(stream, &outbox));
let mut lines = reader.lines();
let authenticated = authenticate(&mut lines, &daemon, trusted, &out);
if !authenticated {
drop(out);
let _ = writer.join();
return Ok(());
}
// Only now does this connection get to see events: an unauthenticated
// socket must learn nothing at all.
let (subscription, events) = daemon.hub.subscribe();
let pump = {
let out = out.clone();
std::thread::spawn(move || {
for event in events {
if out.send(event).is_err() {
break;
}
}
})
};
// Requests are answered on this thread, in order. A method slow enough to
// matter (re-indexing a large share) delays only the connection that asked
// for it, which is the right person to wait — and every other client has a
// connection of its own. Spawning per request would instead churn a thread
// for each of the handful of polls a TUI makes every frame.
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Some(response) = answer(&line, &daemon)
&& out.send(response).is_err()
{
break;
}
}
daemon.hub.unsubscribe(subscription);
drop(out);
let _ = pump.join();
let _ = writer.join();
Ok(())
}
/// The first request has to be `auth`, and has to be good.
fn authenticate(
lines: &mut std::io::Lines<BufReader<Stream>>,
daemon: &Daemon,
trusted: bool,
out: &SyncSender<Response>,
) -> bool {
let Some(Ok(line)) = lines.next() else {
return false;
};
let request: Request = match serde_json::from_str(&line) {
Ok(request) => request,
Err(e) => {
let _ = out.send(Response::failure(
None,
RpcError::new(CODE_PARSE, e.to_string()),
));
return false;
}
};
if Method::from_wire(&request.method) != Some(Method::Auth) {
let _ = out.send(Response::failure(
request.id,
RpcError::new(
CODE_INVALID_REQUEST,
"the first request on a connection must be `auth`",
),
));
return false;
}
let params: AuthParams =
serde_json::from_value(request.params).unwrap_or_default();
if !accepts(daemon, trusted, params.token.as_deref()) {
std::thread::sleep(AUTH_FAILURE_DELAY);
let _ = out.send(Response::failure(
request.id,
RpcError::application(Exit::Connection, "authentication failed"),
));
return false;
}
let result = serde_json::to_value(AuthResult {
protocol: PROTOCOL_VERSION,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
username: daemon.session.username(),
})
.unwrap_or(Value::Null);
let _ = out.send(Response::result(request.id, result));
true
}
/// A Unix socket has already proved the caller is this user, so a token is
/// optional there and mandatory everywhere else. A token that *is* offered is
/// always checked, so a wrong one never passes.
fn accepts(daemon: &Daemon, trusted: bool, token: Option<&str>) -> bool {
match token {
Some(token) => constant_time_eq(token, &daemon.token),
None => trusted,
}
}
/// Compare without leaking the position of the first difference through timing.
fn constant_time_eq(left: &str, right: &str) -> bool {
if left.len() != right.len() {
return false;
}
left.bytes()
.zip(right.bytes())
.fold(0u8, |difference, (a, b)| difference | (a ^ b))
== 0
}
/// Turn one request line into the reply it deserves, or `None` for a
/// notification, which by JSON-RPC rule is answered with silence.
fn answer(line: &str, daemon: &Daemon) -> Option<Response> {
let request: Request = match serde_json::from_str(line) {
Ok(request) => request,
Err(e) => {
return Some(Response::failure(
None,
RpcError::new(CODE_PARSE, e.to_string()),
));
}
};
let id = request.id.clone();
let Some(method) = Method::from_wire(&request.method) else {
return id.is_some().then(|| {
Response::failure(
request.id,
RpcError::new(
CODE_METHOD_NOT_FOUND,
format!("no such method: {}", request.method),
),
)
});
};
let outcome = daemon.call(method, request.params);
id.map(|id| match outcome {
Ok(result) => Response::result(Some(id), result),
Err(error) => Response::failure(Some(id), error),
})
}
fn write_loop(stream: Stream, outbox: &Receiver<Response>) {
let mut stream = stream;
for response in outbox {
let Ok(line) = serde_json::to_string(&response) else {
continue;
};
if writeln!(stream, "{line}").is_err() || stream.flush().is_err() {
return;
}
}
}
/// Bind the TCP control port, refusing to come up rather than silently
/// listening somewhere unintended.
pub fn bind_tcp(address: &str) -> std::io::Result<Listener> {
TcpListener::bind(address).map(Listener::Tcp)
}
/// Bind the Unix control socket with permissions that make it the local
/// authentication: only this user can open it.
#[cfg(unix)]
pub fn bind_unix(path: &std::path::Path) -> std::io::Result<Listener> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// A socket file outlives the process that made it, so a previous crash
// would otherwise make the daemon unstartable.
if path.exists() {
remove_stale_socket(path)?;
}
let listener = std::os::unix::net::UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(Listener::Unix(listener))
}
/// Remove a socket file left by a dead daemon — but never one a live daemon is
/// still serving, which would silently steal its clients.
#[cfg(unix)]
fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> {
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("a daemon is already listening on {}", path.display()),
)),
Err(_) => std::fs::remove_file(path),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::proto::{CODE_METHOD_NOT_FOUND, CODE_PARSE};
use super::transport::{accepts, answer, constant_time_eq};
use super::*; use super::*;
use serde_json::json; use serde_json::json;
use std::sync::mpsc::Receiver;
#[test] #[test]
fn a_unix_socket_needs_no_token_but_tcp_does() { fn a_unix_socket_needs_no_token_but_tcp_does() {
let daemon = daemon_with_token("secret"); let daemon = daemon_with_token("secret");
assert!(accepts(&daemon, true, None), "socket permissions are the auth"); assert!(
accepts(&daemon, true, None),
"socket permissions are the auth"
);
assert!(!accepts(&daemon, false, None), "TCP must demand a token"); assert!(!accepts(&daemon, false, None), "TCP must demand a token");
} }
@@ -784,7 +502,8 @@ mod tests {
assert!( assert!(
document["methods"] document["methods"]
.as_array() .as_array()
.is_some_and(|methods| methods.len() == Method::ALL.len() .is_some_and(|methods| methods.len()
== Method::ALL.len()
+ super::super::proto::Event::ALL.len()), + super::super::proto::Event::ALL.len()),
"discover must describe every method and event" "discover must describe every method and event"
); );
@@ -815,6 +534,7 @@ mod tests {
token: token.to_string(), token: token.to_string(),
started: Instant::now(), started: Instant::now(),
stop: Arc::new(AtomicBool::new(false)), stop: Arc::new(AtomicBool::new(false)),
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
rooms: Mutex::new(Vec::new()), rooms: Mutex::new(Vec::new()),
} }
} }
@@ -859,7 +579,10 @@ mod tests {
) -> Option<Vec<soulseek_rs::SearchResult>> { ) -> Option<Vec<soulseek_rs::SearchResult>> {
None None
} }
fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { fn start_wishlist_search(
&self,
_query: &str,
) -> soulseek_rs::Result<()> {
Ok(()) Ok(())
} }
fn wishlist_interval(&self) -> Duration { fn wishlist_interval(&self) -> Duration {
@@ -972,7 +695,10 @@ mod tests {
) -> Option<Vec<soulseek_rs::SharedDirectory>> { ) -> Option<Vec<soulseek_rs::SharedDirectory>> {
None None
} }
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { fn request_user_info(
&self,
_username: &str,
) -> soulseek_rs::Result<()> {
Ok(()) Ok(())
} }
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> { fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
+429
View File
@@ -0,0 +1,429 @@
//! Getting bytes to and from a control client: who may connect, how a request
//! is framed, and how replies and pushed events share one socket.
//!
//! One thread per connection, which is noise next to the thousand-odd peer
//! threads the client already runs. Each connection owns a writer thread fed by
//! a channel, so request replies and events interleave without a lock and
//! neither can block the other.
use super::super::proto::{
AuthParams, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE,
Method, Request, Response, RpcError,
};
use super::Daemon;
use crate::output::Exit;
use serde_json::Value;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::time::Duration;
/// How long a failed authentication costs the caller. Not a serious defence —
/// the token is 256 bits — but it keeps a wrong-token loop off the CPU.
const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1);
/// Replies waiting to go out on one connection. Bounded, so a client that has
/// stopped reading cannot make the daemon buffer without limit.
const WRITE_QUEUE_DEPTH: usize = 1024;
/// The longest request line the daemon will read. Generous next to the largest
/// real request (a `shares.set` naming every folder) and small enough that an
/// unauthenticated peer cannot make the daemon allocate without bound.
const MAX_LINE: u64 = 1 << 20;
/// How long an unauthenticated connection may stay silent. Applies only until
/// `auth` succeeds — an established client is expected to idle, holding its
/// connection open for events without sending anything.
const AUTH_TIMEOUT: Duration = Duration::from_secs(30);
/// How many control connections may be open at once. Far above any real use
/// (a TUI, a few scripts, an agent), and low enough that an unauthenticated
/// peer cannot exhaust the daemon's threads.
const MAX_CONNECTIONS: usize = 64;
/// Where control connections arrive.
///
/// The Unix socket is the local path — its file permissions are the
/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in
/// and always needs the token.
pub enum Listener {
#[cfg(unix)]
Unix(std::os::unix::net::UnixListener),
Tcp(TcpListener),
}
pub enum Stream {
#[cfg(unix)]
Unix(std::os::unix::net::UnixStream),
Tcp(TcpStream),
}
impl Stream {
fn try_clone(&self) -> std::io::Result<Self> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.try_clone().map(Self::Unix),
Self::Tcp(stream) => stream.try_clone().map(Self::Tcp),
}
}
fn set_read_timeout(&self, timeout: Option<Duration>) {
let _ = match self {
#[cfg(unix)]
Self::Unix(stream) => stream.set_read_timeout(timeout),
Self::Tcp(stream) => stream.set_read_timeout(timeout),
};
}
/// End the connection from another thread. This is what makes "a client
/// that stops reading is disconnected" true rather than aspirational: the
/// hub can only reach a stalled client by closing its socket.
pub(super) fn shutdown(&self) {
let _ = match self {
#[cfg(unix)]
Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both),
Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both),
};
}
/// Whether the transport itself proves who is calling.
const fn trusted_by_transport(&self) -> bool {
match self {
#[cfg(unix)]
Self::Unix(_) => true,
Self::Tcp(_) => false,
}
}
}
impl std::io::Read for Stream {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.read(buffer),
Self::Tcp(stream) => stream.read(buffer),
}
}
}
impl Write for Stream {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.write(buffer),
Self::Tcp(stream) => stream.write(buffer),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
#[cfg(unix)]
Self::Unix(stream) => stream.flush(),
Self::Tcp(stream) => stream.flush(),
}
}
}
impl Listener {
/// Accept forever, handing each connection to its own thread.
pub fn serve(self, daemon: Arc<Daemon>) {
loop {
match self.accept() {
Ok(stream) => spawn_connection(stream, Arc::clone(&daemon)),
Err(e) => soulseek_rs::warn!("control accept failed: {e}"),
}
}
}
fn accept(&self) -> std::io::Result<Stream> {
match self {
#[cfg(unix)]
Self::Unix(listener) => {
listener.accept().map(|(stream, _)| Stream::Unix(stream))
}
Self::Tcp(listener) => {
listener.accept().map(|(stream, _)| Stream::Tcp(stream))
}
}
}
}
fn spawn_connection(stream: Stream, daemon: Arc<Daemon>) {
let open = daemon.open.fetch_add(1, Ordering::Relaxed) + 1;
if open > MAX_CONNECTIONS {
daemon.open.fetch_sub(1, Ordering::Relaxed);
soulseek_rs::warn!(
"refusing a control connection: {MAX_CONNECTIONS} already open"
);
stream.shutdown();
return;
}
std::thread::spawn(move || {
if let Err(e) = handle(stream, daemon.as_ref()) {
soulseek_rs::debug!("control connection ended: {e}");
}
daemon.open.fetch_sub(1, Ordering::Relaxed);
});
}
/// Serve one connection: authenticate, then answer requests until it closes.
fn handle(stream: Stream, daemon: &Daemon) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let trusted = stream.trusted_by_transport();
let closer = stream.try_clone()?;
// A peer that connects and says nothing must not hold a thread forever.
stream.set_read_timeout(Some(AUTH_TIMEOUT));
let (out, outbox) = sync_channel::<Response>(WRITE_QUEUE_DEPTH);
let writer = std::thread::spawn(move || write_loop(stream, &outbox));
if !authenticate(&mut reader, daemon, trusted, &out) {
closer.shutdown();
drop(out);
let _ = writer.join();
return Ok(());
}
// An established client is expected to idle, holding the connection open
// for events without sending anything.
closer.set_read_timeout(None);
// Only now does this connection get to see events: an unauthenticated
// socket must learn nothing at all.
let (subscription, events) = {
let closer = closer.try_clone()?;
daemon.hub.subscribe(move || closer.shutdown())
};
let pump = {
let out = out.clone();
std::thread::spawn(move || {
for event in events {
if out.send(event).is_err() {
break;
}
}
})
};
// Requests are answered on this thread, in order. A method slow enough to
// matter (re-indexing a large share) delays only the connection that asked
// for it, which is the right person to wait — and every other client has a
// connection of its own. Spawning per request would instead churn a thread
// for each of the handful of polls a TUI makes every frame.
//
// Errors break rather than propagate: returning here would skip the
// unsubscribe below and leak a hub subscriber plus two threads for the
// life of the daemon.
loop {
match read_line(&mut reader) {
Ok(Some(line)) => {
if line.trim().is_empty() {
continue;
}
if let Some(response) = answer(&line, daemon)
&& out.send(response).is_err()
{
break;
}
}
Ok(None) => break,
Err(e) => {
soulseek_rs::debug!("control connection ended: {e}");
break;
}
}
}
daemon.hub.unsubscribe(subscription);
closer.shutdown();
drop(out);
let _ = pump.join();
let _ = writer.join();
Ok(())
}
/// Read one newline-delimited request, refusing a line long enough to be an
/// attack rather than a request. `Ok(None)` means the peer hung up.
fn read_line(
reader: &mut BufReader<Stream>,
) -> std::io::Result<Option<String>> {
let mut line = String::new();
let read = reader.by_ref().take(MAX_LINE).read_line(&mut line)?;
if read == 0 {
return Ok(None);
}
if read as u64 == MAX_LINE && !line.ends_with('\n') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request line exceeds the maximum size",
));
}
Ok(Some(line))
}
/// The first request has to be `auth`, and has to be good.
fn authenticate(
reader: &mut BufReader<Stream>,
daemon: &Daemon,
trusted: bool,
out: &SyncSender<Response>,
) -> bool {
let Ok(Some(line)) = read_line(reader) else {
return false;
};
let request: Request = match serde_json::from_str(&line) {
Ok(request) => request,
Err(e) => {
let _ = out.send(Response::failure(
None,
RpcError::new(CODE_PARSE, e.to_string()),
));
return false;
}
};
if Method::from_wire(&request.method) != Some(Method::Auth) {
let _ = out.send(Response::failure(
request.id,
RpcError::new(
CODE_INVALID_REQUEST,
"the first request on a connection must be `auth`",
),
));
return false;
}
let params: AuthParams =
serde_json::from_value(request.params).unwrap_or_default();
if !accepts(daemon, trusted, params.token.as_deref()) {
std::thread::sleep(AUTH_FAILURE_DELAY);
let _ = out.send(Response::failure(
request.id,
RpcError::application(Exit::Connection, "authentication failed"),
));
return false;
}
let result = serde_json::to_value(daemon.identify()).unwrap_or(Value::Null);
let _ = out.send(Response::result(request.id, result));
true
}
/// A Unix socket has already proved the caller is this user, so a token is
/// optional there and mandatory everywhere else. A token that *is* offered is
/// always checked, so a wrong one never passes.
pub(super) fn accepts(
daemon: &Daemon,
trusted: bool,
token: Option<&str>,
) -> bool {
match token {
Some(token) => constant_time_eq(token, &daemon.token),
None => trusted,
}
}
/// Compare without leaking the position of the first difference through timing.
pub(super) fn constant_time_eq(left: &str, right: &str) -> bool {
if left.len() != right.len() {
return false;
}
left.bytes()
.zip(right.bytes())
.fold(0u8, |difference, (a, b)| difference | (a ^ b))
== 0
}
/// Turn one request line into the reply it deserves, or `None` for a
/// notification, which by JSON-RPC rule is answered with silence.
pub(super) fn answer(line: &str, daemon: &Daemon) -> Option<Response> {
let request: Request = match serde_json::from_str(line) {
Ok(request) => request,
Err(e) => {
return Some(Response::failure(
None,
RpcError::new(CODE_PARSE, e.to_string()),
));
}
};
let id = request.id.clone();
let Some(method) = Method::from_wire(&request.method) else {
return id.is_some().then(|| {
Response::failure(
request.id,
RpcError::new(
CODE_METHOD_NOT_FOUND,
format!("no such method: {}", request.method),
),
)
});
};
let outcome = daemon.call(method, request.params);
id.map(|id| match outcome {
Ok(result) => Response::result(Some(id), result),
Err(error) => Response::failure(Some(id), error),
})
}
fn write_loop(stream: Stream, outbox: &Receiver<Response>) {
let mut stream = stream;
for response in outbox {
let Ok(line) = serde_json::to_string(&response) else {
continue;
};
if writeln!(stream, "{line}").is_err() || stream.flush().is_err() {
return;
}
}
}
/// Bind the TCP control port, refusing to come up rather than silently
/// listening somewhere unintended.
pub fn bind_tcp(address: &str) -> std::io::Result<Listener> {
TcpListener::bind(address).map(Listener::Tcp)
}
/// Bind the Unix control socket with permissions that make it the local
/// authentication: only this user can open it.
///
/// Bound under a temporary name and moved into place, because `bind` creates
/// the socket with the process umask — for the moment between `bind` and
/// `set_permissions` the real path would be connectable by anyone on the
/// machine, and on this transport connecting *is* authenticating. The rename
/// is atomic, so the published path never exists with the wrong mode.
#[cfg(unix)]
pub fn bind_unix(path: &std::path::Path) -> std::io::Result<Listener> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// A socket file outlives the process that made it, so a previous crash
// would otherwise make the daemon unstartable.
if path.exists() {
remove_stale_socket(path)?;
}
let staging = path.with_extension("binding");
let _ = std::fs::remove_file(&staging);
let listener = std::os::unix::net::UnixListener::bind(&staging)?;
std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o600))?;
std::fs::rename(&staging, path)?;
Ok(Listener::Unix(listener))
}
/// Remove a socket file left by a dead daemon — but never one a live daemon is
/// still serving, which would silently steal its clients.
#[cfg(unix)]
fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> {
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("a daemon is already listening on {}", path.display()),
)),
Err(_) => std::fs::remove_file(path),
}
}
+67 -8
View File
@@ -18,6 +18,14 @@ use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
/// How many undrained events of one kind a client may accumulate.
///
/// Every attached client is sent everything, including events for work another
/// client asked for. A command that only drains room events would otherwise
/// grow its private-message buffer for as long as it runs. Dropping the oldest
/// is right for a buffer nobody is reading: the newest state is the useful one.
const BUFFER_LIMIT: usize = 4096;
/// Everything a pushed event can land in, behind one lock. /// Everything a pushed event can land in, behind one lock.
/// ///
/// One lock rather than several: it is taken for the length of a `Vec::push` /// One lock rather than several: it is taken for the length of a `Vec::push`
@@ -49,21 +57,24 @@ impl Mirror {
match event { match event {
Event::Room => { Event::Room => {
if let Ok(room) = parse::<RoomEventDto>(event, params) { if let Ok(room) = parse::<RoomEventDto>(event, params) {
buffers.rooms.push(room.into()); push(&mut buffers.rooms, room.into());
} }
} }
Event::Message => { Event::Message => {
if let Ok(message) = parse::<UserMessageDto>(event, params) { if let Ok(message) = parse::<UserMessageDto>(event, params) {
buffers.messages.push(message.into()); push(&mut buffers.messages, message.into());
} }
} }
Event::Upload => { Event::Upload => {
if let Ok(upload) = parse::<UploadInfoDto>(event, params) { if let Ok(upload) = parse::<UploadInfoDto>(event, params) {
buffers.uploads.push(upload.into()); push(&mut buffers.uploads, upload.into());
} }
} }
Event::Browse => { Event::Browse => {
if let Ok(browse) = parse::<BrowseEvent>(event, params) { if let Ok(browse) = parse::<BrowseEvent>(event, params) {
if buffers.browses.len() >= BUFFER_LIMIT {
buffers.browses.clear();
}
buffers.browses.insert( buffers.browses.insert(
browse.username, browse.username,
browse browse
@@ -112,6 +123,18 @@ impl Mirror {
} }
} }
/// Stop tracking every transfer, so anything blocked on one is told the
/// connection is gone.
///
/// Dropping the senders is the signal: a caller waiting on its receiver
/// sees a disconnect immediately instead of sitting out its full transfer
/// timeout and then reporting a misleading "gave up".
pub fn abandon_downloads(&self) {
if let Ok(mut buffers) = self.0.lock() {
buffers.downloads.clear();
}
}
/// Register where a transfer's updates should go before asking for it, so /// Register where a transfer's updates should go before asking for it, so
/// an update that lands immediately is not dropped on the floor. /// an update that lands immediately is not dropped on the floor.
pub fn expect_download( pub fn expect_download(
@@ -156,10 +179,20 @@ impl Mirror {
} }
fn take<T: Default>(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T { fn take<T: Default>(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T {
self.0.lock().map_or_else(|_| T::default(), |mut b| drain(&mut b)) self.0
.lock()
.map_or_else(|_| T::default(), |mut b| drain(&mut b))
} }
} }
/// Append, dropping the oldest once the buffer is at its limit.
fn push<T>(buffer: &mut Vec<T>, item: T) {
if buffer.len() >= BUFFER_LIMIT {
buffer.remove(0);
}
buffer.push(item);
}
fn parse<T: serde::de::DeserializeOwned>( fn parse<T: serde::de::DeserializeOwned>(
event: Event, event: Event,
params: serde_json::Value, params: serde_json::Value,
@@ -178,10 +211,7 @@ mod tests {
#[test] #[test]
fn a_room_event_is_delivered_once_and_then_gone() { fn a_room_event_is_delivered_once_and_then_gone() {
let mirror = Mirror::default(); let mirror = Mirror::default();
mirror.apply( mirror.apply(Event::Room, json!({ "event": "left", "room": "lobby" }));
Event::Room,
json!({ "event": "left", "room": "lobby" }),
);
assert_eq!( assert_eq!(
mirror.take_rooms(), mirror.take_rooms(),
@@ -286,6 +316,35 @@ mod tests {
); );
} }
#[test]
fn an_undrained_buffer_stops_growing_at_its_limit() {
// A client is sent every event, including ones for work it never asked
// for; a command that drains only rooms must not grow without bound.
let mirror = Mirror::default();
for _ in 0..(BUFFER_LIMIT + 50) {
mirror.apply(
Event::Room,
json!({ "event": "left", "room": "lobby" }),
);
}
assert_eq!(mirror.take_rooms().len(), BUFFER_LIMIT);
}
#[test]
fn abandoning_transfers_wakes_whoever_is_waiting_on_them() {
let mirror = Mirror::default();
let (sender, receiver) = std::sync::mpsc::channel();
mirror.expect_download("bob", "a.mp3", sender);
mirror.abandon_downloads();
assert!(
matches!(receiver.recv(), Err(std::sync::mpsc::RecvError)),
"a caller blocked on a transfer must be told the daemon is gone, \
not left to time out"
);
}
#[test] #[test]
fn an_unreadable_event_is_ignored_rather_than_fatal() { fn an_unreadable_event_is_ignored_rather_than_fatal() {
// A newer daemon may send a shape this client does not know. Dropping // A newer daemon may send a shape this client does not know. Dropping
+26 -28
View File
@@ -170,7 +170,11 @@ impl RemoteSession {
std::thread::spawn(move || { std::thread::spawn(move || {
read_loop(read, &pending, &mirror); read_loop(read, &pending, &mirror);
hung_up.store(true, Ordering::Relaxed); hung_up.store(true, Ordering::Relaxed);
// Everything waiting on this connection has to be told, not
// left to discover it by timing out: calls in flight, and
// transfers whose progress only ever arrived as events.
pending.fail_all(); pending.fail_all();
mirror.abandon_downloads();
}); });
} }
@@ -185,7 +189,10 @@ impl RemoteSession {
}; };
let token = if endpoint.self_authenticating() { let token = if endpoint.self_authenticating() {
token // Offering a token here can only hurt: the socket's permissions
// have already answered the question, and a stale one on disk
// would turn a connection that should succeed into a refusal.
None
} else { } else {
Some(token.ok_or_else(|| { Some(token.ok_or_else(|| {
network( network(
@@ -250,7 +257,8 @@ impl RemoteSession {
{ {
let mut outgoing = let mut outgoing =
self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?; self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?;
if writeln!(outgoing, "{line}").is_err() || outgoing.flush().is_err() if writeln!(outgoing, "{line}").is_err()
|| outgoing.flush().is_err()
{ {
self.pending.abandon(id); self.pending.abandon(id);
return Err(SoulseekRs::ConnectionClosed); return Err(SoulseekRs::ConnectionClosed);
@@ -328,33 +336,27 @@ impl RemoteSession {
username: username.clone(), username: username.clone(),
filename: filename.clone(), filename: filename.clone(),
size, size,
// The daemon writes to its own filesystem, which a remote
// caller cannot see and so does not get to choose.
download_directory: None,
metadata: (&metadata).into(), metadata: (&metadata).into(),
}, },
) )
.inspect_err(|_| self.mirror.forget_download(&username, &filename))?; .inspect_err(|_| {
self.mirror.forget_download(&username, &filename);
})?;
Ok((rebuild(started.download, sender, metadata), receiver)) Ok((rebuild(started.download, sender), receiver))
} }
/// One parameterless request, for the control commands that only need to /// One parameterless request, for the control commands that only need to
/// ask a daemon something once. /// ask a daemon something once.
///
pub fn ask_for<R: DeserializeOwned>(&self, method: Method) -> Result<R> { pub fn ask_for<R: DeserializeOwned>(&self, method: Method) -> Result<R> {
self.request(method, ()) self.request(method, ())
} }
} }
/// Turn the daemon's view of a transfer back into the library's, with the /// Turn the daemon's view of a transfer back into the library's, with the
/// caller's own channel spliced in. /// caller's own channel spliced in.
fn rebuild( fn rebuild(dto: DownloadDto, sender: Sender<DownloadStatus>) -> Download {
dto: DownloadDto, let metadata = dto.metadata.into();
sender: Sender<DownloadStatus>,
metadata: DownloadMetadata,
) -> Download {
Download { Download {
username: dto.username, username: dto.username,
filename: dto.filename, filename: dto.filename,
@@ -381,8 +383,9 @@ fn open(endpoint: &Endpoint) -> Result<Halves> {
network(format!("cannot reach the daemon at {address}: {e}")) network(format!("cannot reach the daemon at {address}: {e}"))
})?; })?;
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
let closer = let closer = Closer::Tcp(
Closer::Tcp(stream.try_clone().map_err(SoulseekRs::NetworkError)?); stream.try_clone().map_err(SoulseekRs::NetworkError)?,
);
Ok((Box::new(read), Box::new(stream), closer)) Ok((Box::new(read), Box::new(stream), closer))
} }
Endpoint::Socket(path) => connect_socket(path), Endpoint::Socket(path) => connect_socket(path),
@@ -391,8 +394,12 @@ fn open(endpoint: &Endpoint) -> Result<Halves> {
#[cfg(unix)] #[cfg(unix)]
fn connect_socket(path: &Path) -> Result<Halves> { fn connect_socket(path: &Path) -> Result<Halves> {
let stream = std::os::unix::net::UnixStream::connect(path).map_err(|e| { let stream =
network(format!("cannot reach the daemon at {}: {e}", path.display())) std::os::unix::net::UnixStream::connect(path).map_err(|e| {
network(format!(
"cannot reach the daemon at {}: {e}",
path.display()
))
})?; })?;
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?; let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
let closer = let closer =
@@ -474,14 +481,6 @@ impl SessionApi for RemoteSession {
}) })
} }
fn search(
&self,
query: &str,
timeout: Duration,
) -> Result<Vec<SearchResult>> {
self.search_with_cancel(query, timeout, None)
}
fn search_with_cancel( fn search_with_cancel(
&self, &self,
query: &str, query: &str,
@@ -577,8 +576,7 @@ impl SessionApi for RemoteSession {
// transfers too, and the ones this client started already have // transfers too, and the ones this client started already have
// a live channel from `download`. // a live channel from `download`.
let (sender, _) = channel(); let (sender, _) = channel();
let metadata = dto.metadata.clone().into(); rebuild(dto, sender)
rebuild(dto, sender, metadata)
}) })
.collect() .collect()
} }
+37 -28
View File
@@ -7,13 +7,13 @@ use crate::cli::{
}; };
use crate::commands::Ctx; use crate::commands::Ctx;
use crate::output::{CliError, CliResult, Exit, Out}; use crate::output::{CliError, CliResult, Exit, Out};
use crate::ui::launch_main_tui;
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
use std::io::{BufRead, IsTerminal}; use std::io::{BufRead, IsTerminal};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::{env, io}; use std::{env, io};
use crate::ui::launch_main_tui;
pub fn run(mut cli: Cli, out: &Out) -> CliResult { pub fn run(mut cli: Cli, out: &Out) -> CliResult {
let config_path = config_path(&cli); let config_path = config_path(&cli);
@@ -25,6 +25,15 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
let resolved = crate::persist::config::resolve(&cli, &file_config); let resolved = crate::persist::config::resolve(&cli, &file_config);
let Some(command) = cli.command.take() else { let Some(command) = cli.command.take() else {
// Without a terminal the TUI would panic on its first draw. A script
// that reached this point wanted a subcommand, so say that instead of
// crashing.
if !std::io::stdout().is_terminal() {
return Err(CliError::usage(
"the interactive interface needs a terminal; run a subcommand \
for scripted use (see --help)",
));
}
// A running daemon already holds a session; the TUI attaches to it // A running daemon already holds a session; the TUI attaches to it
// rather than logging in a second time, which the server would answer // rather than logging in a second time, which the server would answer
// by cutting the daemon off. // by cutting the daemon off.
@@ -62,7 +71,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
return crate::commands::settings::config_get(out, &store, key); return crate::commands::settings::config_get(out, &store, key);
} }
Commands::Config(ConfigCommand::Set { ref key, ref value }) => { Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
return crate::commands::settings::config_set(out, &store, key, value); return crate::commands::settings::config_set(
out, &store, key, value,
);
} }
Commands::Shares(SharesCommand::List) => { Commands::Shares(SharesCommand::List) => {
return crate::commands::settings::shares_list( return crate::commands::settings::shares_list(
@@ -72,10 +83,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
); );
} }
Commands::Shares(SharesCommand::Add { ref directory }) => { Commands::Shares(SharesCommand::Add { ref directory }) => {
return crate::commands::settings::shares_add(out, &store, directory); return crate::commands::settings::shares_add(
out, &store, directory,
);
} }
Commands::Shares(SharesCommand::Remove { ref directory }) => { Commands::Shares(SharesCommand::Remove { ref directory }) => {
return crate::commands::settings::shares_remove(out, &store, directory); return crate::commands::settings::shares_remove(
out, &store, directory,
);
} }
Commands::Wish(WishCommand::Add { ref query }) => { Commands::Wish(WishCommand::Add { ref query }) => {
return crate::commands::wish::add(out, &store, query); return crate::commands::wish::add(out, &store, query);
@@ -97,9 +112,11 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
let token = cli.daemon_token.clone(); let token = cli.daemon_token.clone();
return match command { return match command {
DaemonCommand::Token => crate::commands::daemon::token(out), DaemonCommand::Token => crate::commands::daemon::token(out),
DaemonCommand::Status => { DaemonCommand::Status => crate::commands::daemon::status(
crate::commands::daemon::status(out, endpoint.as_ref(), token) out,
} endpoint.as_ref(),
token,
),
DaemonCommand::Stop => { DaemonCommand::Stop => {
crate::commands::daemon::stop(out, endpoint.as_ref(), token) crate::commands::daemon::stop(out, endpoint.as_ref(), token)
} }
@@ -125,7 +142,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
Commands::Serve(ref args) => { Commands::Serve(ref args) => {
let sweeper = args let sweeper = args
.wishlist .wishlist
.then(|| crate::commands::wish::Sweeper::new(store.config.wishes())) .then(|| {
crate::commands::wish::Sweeper::new(store.config.wishes())
})
.flatten(); .flatten();
if args.wishlist && sweeper.is_none() { if args.wishlist && sweeper.is_none() {
out.warn( out.warn(
@@ -145,7 +164,9 @@ fn config_path(cli: &Cli) -> Option<PathBuf> {
if cli.no_config { if cli.no_config {
return None; return None;
} }
cli.config.clone().or_else(crate::persist::paths::config_file) cli.config
.clone()
.or_else(crate::persist::paths::config_file)
} }
/// Assemble everything a one-shot command needs, demanding the credentials /// Assemble everything a one-shot command needs, demanding the credentials
@@ -338,15 +359,6 @@ fn run_default_tui(
terminal::{Clear, ClearType}, terminal::{Clear, ClearType},
}; };
// Without a terminal the TUI would panic on its first draw. A script that
// reached this point wanted a subcommand, so say that instead of crashing.
if !std::io::stdout().is_terminal() {
return Err(CliError::usage(
"the interactive interface needs a terminal; run a subcommand for \
scripted use (see --help)",
));
}
let out = Out::new(false, false); let out = Out::new(false, false);
let (server_host, server_port) = let (server_host, server_port) =
parse_server_address(&resolved.server).map_err(CliError::usage)?; parse_server_address(&resolved.server).map_err(CliError::usage)?;
@@ -418,8 +430,8 @@ fn run_default_tui(
.listen_port() .listen_port()
.map(crate::port_mapping::PortMapper::spawn); .map(crate::port_mapping::PortMapper::spawn);
let store = let store = crate::persist::paths::state_dir()
crate::persist::paths::state_dir().map(crate::persist::state::StateStore::new); .map(crate::persist::state::StateStore::new);
launch_main_tui( launch_main_tui(
terminal, terminal,
@@ -448,19 +460,16 @@ fn run_attached_tui(
terminal::{Clear, ClearType}, terminal::{Clear, ClearType},
}; };
if !std::io::stdout().is_terminal() {
return Err(CliError::usage(
"the interactive interface needs a terminal; run a subcommand for \
scripted use (see --help)",
));
}
let token = cli let token = cli
.daemon_token .daemon_token
.clone() .clone()
.or_else(crate::daemon::stored_token); .or_else(crate::daemon::stored_token);
let session = crate::remote::RemoteSession::connect(endpoint, token) let session = crate::remote::RemoteSession::connect(endpoint, token)
.map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?; .map_err(|e| {
CliError::connection(format!(
"cannot use the daemon at {endpoint}: {e}"
))
})?;
soulseek_rs::utils::logger::enable_buffering(); soulseek_rs::utils::logger::enable_buffering();
let _ = let _ =
+8 -13
View File
@@ -551,10 +551,8 @@ impl DaemonFixture {
static COUNTER: std::sync::atomic::AtomicU32 = static COUNTER: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0); std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let state = std::env::temp_dir().join(format!( let state = std::env::temp_dir()
"soulseek-e2e-daemon-{}-{n}", .join(format!("soulseek-e2e-daemon-{}-{n}", std::process::id()));
std::process::id()
));
std::fs::create_dir_all(&state).expect("daemon state directory"); std::fs::create_dir_all(&state).expect("daemon state directory");
let mut args = vec![ let mut args = vec![
@@ -568,9 +566,10 @@ impl DaemonFixture {
]; ];
// A listener of its own unless the test asked for something specific: // A listener of its own unless the test asked for something specific:
// transfers arrive on a connection the uploader opens back to us. // transfers arrive on a connection the uploader opens back to us.
if !session_flags.iter().any(|flag| { if !session_flags
flag == "--listener-port" || flag == "--no-listener" .iter()
}) { .any(|flag| flag == "--listener-port" || flag == "--no-listener")
{
args.push("--listener-port".to_string()); args.push("--listener-port".to_string());
args.push(free_port().expect("listener port").to_string()); args.push(free_port().expect("listener port").to_string());
} }
@@ -631,9 +630,7 @@ impl DaemonFixture {
"daemon", "daemon",
"status", "status",
]); ]);
command command.output().is_ok_and(|output| output.status.success())
.output()
.is_ok_and(|output| output.status.success())
} }
} }
@@ -663,9 +660,7 @@ impl TestServer {
child: None, child: None,
db: None, db: None,
_gate: gate, _gate: gate,
daemons: std::sync::Mutex::new( daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
std::collections::HashMap::new(),
),
}); });
} }