Skip to main content

dspk_app_core/data/
remote.rs

1//! Servers the app works with (`dspk serve`): saved profiles (Settings), connections on one network
2//! runtime, recordings read on the server ([`RemoteSignal`]: views come from the server's pyramid),
3//! and jobs run there (a sort: configured, started, followed, its result fetched), reported as
4//! the app's other jobs ([`JobMessage`]).
5
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock, RwLock};
9use std::time::Duration;
10
11use anyhow::Context as _;
12/// The protocol's messages (a remote job is a `wire::Configure`), for the app's plugins.
13pub use dspk_stream::protocol::wire;
14use dspk_stream::{client_config_with, ClientIdentity, RemoteSignal, ServerTrust, Session};
15use serde::{Deserialize, Serialize};
16
17use super::jobs::{Cancel, JobMessage};
18
19/// Threads of the network runtime (connections are I/O-bound).
20const NETWORK_THREADS: usize = 2;
21/// How often a remote job's status is asked for.
22const STATUS_EVERY: Duration = Duration::from_secs(1);
23/// The certificate name of a server when the profile names none.
24pub const DEFAULT_SERVER_NAME: &str = "localhost";
25
26/// A server the user works with, as saved in Settings.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ServerProfile {
29    /// What the user calls it ("lab GPU").
30    pub name: String,
31    /// `IP:port` (UDP), e.g. `192.168.1.20:50051`.
32    pub address: String,
33    /// The name its certificate carries (one of `serve --name`, or localhost).
34    #[serde(default = "default_server_name")]
35    pub server_name: String,
36    /// This app's identity on it: the file `dspk clients enroll` wrote.
37    pub identity: PathBuf,
38}
39
40fn default_server_name() -> String {
41    DEFAULT_SERVER_NAME.to_string()
42}
43
44impl ServerProfile {
45    /// What is wrong with the profile before connecting (address, identity file), if anything.
46    pub fn check(&self) -> Result<(), String> {
47        if self.name.trim().is_empty() {
48            return Err("give the server a name".into());
49        }
50        if self.name.contains('/') {
51            return Err("a server name cannot contain '/' (it is part of dspk:// paths)".into());
52        }
53        self.address.parse::<SocketAddr>().map_err(|_| format!("address {:?}: an IP and port, e.g. 192.168.1.20:50051", self.address))?;
54        if !self.identity.is_file() {
55            return Err(format!("identity file {} not found", crate::domain::paths::short_path(&self.identity)));
56        }
57        Ok(())
58    }
59}
60
61/// How a recording on a server is named wherever the app names recordings by path (the session
62/// document, recent files, sortings' links): `dspk://<server name>/<path on the server>`.
63pub const SCHEME: &str = "dspk://";
64
65/// The path naming `path` on the server `server`.
66pub fn uri(server: &str, path: &str) -> PathBuf {
67    PathBuf::from(format!("{SCHEME}{server}/{}", path.trim_start_matches('/')))
68}
69
70/// The server and the path on it, for a `dspk://` path.
71pub fn parse_uri(path: &Path) -> Option<(String, String)> {
72    let rest = path.to_str()?.strip_prefix(SCHEME)?;
73    let (server, path) = rest.split_once('/')?;
74    (!server.is_empty() && !path.is_empty()).then(|| (server.to_string(), path.to_string()))
75}
76
77/// The servers saved in Settings, for code that only has a path (opening a recording).
78fn profiles() -> &'static RwLock<Vec<ServerProfile>> {
79    static PROFILES: OnceLock<RwLock<Vec<ServerProfile>>> = OnceLock::new();
80    PROFILES.get_or_init(|| RwLock::new(Vec::new()))
81}
82
83/// Updates the servers known to [`profile_named`] (the store calls it when Settings change).
84pub fn set_profiles(servers: &[ServerProfile]) {
85    *profiles().write().unwrap_or_else(|e| e.into_inner()) = servers.to_vec();
86}
87
88/// The saved server called `name`.
89pub fn profile_named(name: &str) -> Option<ServerProfile> {
90    profiles().read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
91}
92
93/// Opens the `dspk://` recording `path` (blocking: from a background thread, never on the
94/// network runtime).
95pub fn open_uri(path: &Path) -> anyhow::Result<Arc<RemoteSignal>> {
96    let (server, remote) = parse_uri(path).with_context(|| format!("{} is not a dspk:// path", crate::domain::paths::short_path(&path)))?;
97    let profile = profile_named(&server).with_context(|| format!("no server named {server:?} in Settings"))?;
98    runtime().block_on(open_recording(profile, remote))
99}
100
101/// The network runtime every connection runs on (started on first use).
102pub fn runtime() -> &'static tokio::runtime::Runtime {
103    static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
104    RUNTIME.get_or_init(|| {
105        tokio::runtime::Builder::new_multi_thread()
106            .worker_threads(NETWORK_THREADS)
107            .thread_name("dspk-net")
108            .enable_all()
109            .build()
110            .expect("the network runtime starts")
111    })
112}
113
114/// A new session with `profile`'s server (its identity presented, the server trusted through it).
115pub async fn connect(profile: &ServerProfile) -> anyhow::Result<Session> {
116    profile.check().map_err(anyhow::Error::msg)?;
117    let identity = ClientIdentity::from_file(&profile.identity).with_context(|| format!("reading {}", crate::domain::paths::short_path(&profile.identity)))?;
118    let config = client_config_with(&ServerTrust::from_identity(&identity), Some(&identity))?;
119    let addr: SocketAddr = profile.address.parse()?;
120    Session::connect(addr, &profile.server_name, config).await.with_context(|| format!("connecting to {} ({})", profile.name, profile.address))
121}
122
123/// Who the server is and who this app is to it (Settings: "Test").
124pub async fn test(profile: ServerProfile) -> anyhow::Result<wire::ServerInfo> {
125    let session = connect(&profile).await?;
126    let info = session.server().clone();
127    session.close().await;
128    Ok(info)
129}
130
131/// The entries of `dir` under the server's data root.
132pub async fn list(profile: ServerProfile, dir: String) -> anyhow::Result<wire::FileList> {
133    let session = connect(&profile).await?;
134    let list = session.list_files(&dir).await;
135    session.close().await;
136    Ok(list?)
137}
138
139/// The recording at `path` on the server, as a signal the app draws (a session of its own).
140pub async fn open_recording(profile: ServerProfile, path: String) -> anyhow::Result<Arc<RemoteSignal>> {
141    let mut session = connect(&profile).await?;
142    session.open(&path, None).await?;
143    Ok(Arc::new(RemoteSignal::new(Arc::new(session), runtime().handle().clone())?))
144}
145
146/// Fetches `path` (a file, or a directory such as a .sorting.zarr) from the server into
147/// `<results>/<server>/`, under a new name when one is there already; returns the local path.
148pub async fn fetch(profile: ServerProfile, path: String, results: PathBuf) -> anyhow::Result<PathBuf> {
149    let session = connect(&profile).await?;
150    let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or("fetched").to_string();
151    let dir = results_dir(&results, &profile);
152    tokio::fs::create_dir_all(&dir).await?;
153    let mut local = dir.join(&name);
154    let mut n = 1;
155    while local.exists() {
156        n += 1;
157        local = dir.join(format!("{n}-{name}"));
158    }
159    session.fetch(path.trim_end_matches('/'), &local).await?;
160    session.close().await;
161    Ok(local)
162}
163
164/// Where a server's results are fetched to: `<results>/<server name>/`.
165pub fn results_dir(base: &Path, profile: &ServerProfile) -> PathBuf {
166    let safe: String = profile.name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '_' }).collect();
167    base.join(if safe.is_empty() { "server".into() } else { safe })
168}
169
170/// A sort on the server: configured and started, followed until it ends (progress as
171/// [`JobMessage::Progress`]), its sorting fetched into `results` ([`JobMessage::Done`] with the
172/// local path). Cancelling stops the job on the server.
173pub fn remote_sort(profile: ServerProfile, job: wire::Configure, results: PathBuf, send: impl Fn(JobMessage) + Send + Sync + 'static) -> Cancel {
174    let cancel = Cancel::default();
175    let c = cancel.clone();
176    runtime().spawn(async move {
177        let outcome = async {
178            let session = connect(&profile).await?;
179            let configured = session.configure(job).await?;
180            session.start().await?;
181            send(JobMessage::Progress(format!("{} on {}", configured.job, profile.name), None));
182            let mut stop_sent = false;
183            let status = loop {
184                tokio::time::sleep(STATUS_EVERY).await;
185                if c.cancelled() && !stop_sent {
186                    let _ = session.stop().await;
187                    stop_sent = true;
188                }
189                let s = session.status().await?;
190                if s.state != wire::DeviceState::Running as i32 {
191                    break s;
192                }
193                let fraction = (s.total > 0).then(|| s.done as f32 / s.total as f32);
194                let step = if s.steps > 0 { format!("[{}/{}] ", s.step, s.steps) } else { String::new() };
195                send(JobMessage::Progress(format!("{} on {}: {step}{} {}/{} {}", configured.job, profile.name, s.stage, s.done, s.total, s.unit), fraction));
196            };
197            if status.state == wire::DeviceState::Error as i32 {
198                anyhow::bail!("the sort failed on {}: {}", profile.name, status.message);
199            }
200            if c.cancelled() {
201                anyhow::bail!("stopped");
202            }
203            // The result, fetched next to the others from this server (never overwritten)
204            let name = status.output.rsplit('/').next().unwrap_or("result.sorting.zarr").to_string();
205            let dir = results_dir(&results, &profile);
206            tokio::fs::create_dir_all(&dir).await?;
207            let mut local = dir.join(&name);
208            let mut n = 1;
209            while local.exists() {
210                n += 1;
211                local = dir.join(format!("{n}-{name}"));
212            }
213            send(JobMessage::Progress(format!("Fetching {} from {}", status.output, profile.name), None));
214            session.fetch(&status.output, &local).await?;
215            session.close().await;
216            anyhow::Ok(local)
217        };
218        send(match outcome.await {
219            Ok(local) => JobMessage::Done(local),
220            Err(e) => JobMessage::Failed(format!("{e:#}")),
221        });
222    });
223    cancel
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use dspk_core::{MemoryRecording, RecordingSource};
230    use dspk_stream::files::DataRoot;
231    use dspk_stream::{serve_session, server_config_with_clients, Authority, Device, OpenRecording, Permission, RecordingOpener, ServeOptions, Server, ServerIdentity};
232    use dspk_view::{Envelope, SignalBackend, View};
233
234    /// Opens every recording as a 3-channel test signal.
235    struct TestOpener;
236    impl RecordingOpener for TestOpener {
237        fn open(&self, _: &Path, _: Option<&str>) -> dspk_stream::StreamResult<OpenRecording> {
238            let data: Vec<f32> = (0..3 * 4_000).map(|i| (i % 50) as f32).collect();
239            Ok(OpenRecording::new(Arc::new(MemoryRecording::new("t", data, 3, 1000.0).unwrap()) as Arc<dyn RecordingSource>, None))
240        }
241    }
242
243    /// The app's data layer against a real server: list, open (RemoteSignal), fetch.
244    #[test]
245    fn list_open_and_fetch_from_a_server() {
246        let base = std::env::temp_dir().join(format!("dspk-app-remote-{}", std::process::id()));
247        let _ = std::fs::remove_dir_all(&base);
248        let root = base.join("data");
249        std::fs::create_dir_all(root.join("m1")).unwrap();
250        std::fs::write(root.join("m1/rec.bin"), [0u8; 8]).unwrap();
251        std::fs::write(root.join("m1/notes.txt"), "hello").unwrap();
252        let authority = Arc::new(Authority::open_or_create(&base.join("auth")).unwrap());
253        authority.enroll("app", Permission::Read).unwrap().write(&base.join("app.pem")).unwrap();
254        let tls = server_config_with_clients(&authority, &ServerIdentity::SelfSigned { names: vec!["localhost".into()] }, false).unwrap();
255        let device = Arc::new(Device {
256            name: "test".into(),
257            version: "0".into(),
258            files: Some((DataRoot::new(&root).unwrap(), Arc::new(TestOpener))),
259            recording: None,
260            jobs: None,
261            runtime: String::new(),
262            options: ServeOptions::default(),
263        });
264        let addr = runtime().block_on(async {
265            let server = Server::bind("127.0.0.1:0".parse().unwrap(), tls.config).unwrap().with_authority(authority);
266            let addr = server.local_addr().unwrap();
267            tokio::spawn(async move {
268                while let Some((c, peer)) = server.accept().await {
269                    tokio::spawn(serve_session(c, peer, device.clone()));
270                }
271            });
272            addr
273        });
274        let profile = ServerProfile { name: "test server".into(), address: addr.to_string(), server_name: "localhost".into(), identity: base.join("app.pem") };
275
276        let info = runtime().block_on(test(profile.clone())).unwrap();
277        assert_eq!(info.peer_name, "app");
278        let list = runtime().block_on(list(profile.clone(), "m1".into())).unwrap();
279        assert_eq!(list.entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["notes.txt", "rec.bin"]);
280
281        let signal = runtime().block_on(open_recording(profile.clone(), "m1/rec.bin".into())).unwrap();
282        assert_eq!(signal.info().channel_count(), 3);
283        let mut env = Envelope::Samples(Vec::new());
284        signal.view(&View { channels: vec![2], start: 0, end: 10, width: 10 }, &mut env).unwrap();
285        assert!(matches!(env, Envelope::Samples(v) if v.len() == 10));
286
287        // The dspk:// path: what opening from the browser, recent files or the session goes through
288        set_profiles(std::slice::from_ref(&profile));
289        let set = crate::data::recording::SourceSet::open(&uri(&profile.name, "m1/rec.bin")).unwrap();
290        let ds = set.default_dataset();
291        assert_eq!(ds.total_channels, 3);
292        let mut window = vec![0.0f32; 2 * 100];
293        ds.source.as_ref().expect("sample reads through RemoteSource").read(&[0, 1], 10..110, &mut window).unwrap();
294        // MemoryRecording is channel-major: channel c, sample t = data[c · 4000 + t] = (c · 4000 + t) % 50
295        assert_eq!((window[0], window[100], window[99]), (10.0, 10.0, (109 % 50) as f32));
296        assert!(crate::data::recording::SourceSet::open(&uri("unknown", "m1/rec.bin")).is_err(), "a server not in Settings");
297
298        let local = runtime().block_on(fetch(profile.clone(), "m1/notes.txt".into(), base.join("results"))).unwrap();
299        assert_eq!(local, base.join("results/test_server/notes.txt"));
300        assert_eq!(std::fs::read_to_string(&local).unwrap(), "hello");
301        let again = runtime().block_on(fetch(profile, "m1/notes.txt".into(), base.join("results"))).unwrap();
302        assert_eq!(again, base.join("results/test_server/2-notes.txt"), "never overwritten");
303        let _ = std::fs::remove_dir_all(&base);
304    }
305
306    /// Writes a small sorting folder after reporting progress (stands in for dspk-cli-sorter).
307    struct FakeSorter;
308    impl dspk_stream::jobs::JobRunner for FakeSorter {
309        fn sorters(&self) -> Vec<String> {
310            vec!["kilosort4".into()]
311        }
312        fn run(&self, job: &dspk_stream::jobs::JobSpec, report: &dyn dspk_stream::jobs::JobReport, _: &std::sync::atomic::AtomicBool) -> Result<String, String> {
313            for done in 0..3 {
314                report.progress(dspk_stream::jobs::JobProgress { stage: "Sorting".into(), step: 1, steps: 1, done, total: 3, unit: "steps".into() });
315                std::thread::sleep(Duration::from_millis(400));
316            }
317            std::fs::create_dir_all(job.output.join("spikes")).map_err(|e| e.to_string())?;
318            std::fs::write(job.output.join("zarr.json"), "{}").map_err(|e| e.to_string())?;
319            Ok("2 units".into())
320        }
321    }
322
323    /// A sort on a server as the app runs it: configured, followed (progress), fetched.
324    #[test]
325    fn a_remote_sort_is_followed_and_fetched() {
326        let base = std::env::temp_dir().join(format!("dspk-app-remote-sort-{}", std::process::id()));
327        let _ = std::fs::remove_dir_all(&base);
328        let root = base.join("data");
329        std::fs::create_dir_all(root.join("m1")).unwrap();
330        std::fs::write(root.join("m1/rec.bin"), [0u8; 8]).unwrap();
331        let authority = Arc::new(Authority::open_or_create(&base.join("auth")).unwrap());
332        authority.enroll("lab", Permission::Control).unwrap().write(&base.join("lab.pem")).unwrap();
333        let tls = server_config_with_clients(&authority, &ServerIdentity::SelfSigned { names: vec!["localhost".into()] }, false).unwrap();
334        let data_root = DataRoot::new(&root).unwrap();
335        let jobs = Arc::new(dspk_stream::jobs::Jobs::new(Arc::new(FakeSorter), data_root.clone(), None));
336        let device = Arc::new(Device { name: "t".into(), version: "0".into(), files: Some((data_root, Arc::new(TestOpener))), recording: None, jobs: Some(jobs), runtime: "cpu".into(), options: ServeOptions::default() });
337        let addr = runtime().block_on(async {
338            let server = Server::bind("127.0.0.1:0".parse().unwrap(), tls.config).unwrap().with_authority(authority);
339            let addr = server.local_addr().unwrap();
340            tokio::spawn(async move {
341                while let Some((c, peer)) = server.accept().await {
342                    tokio::spawn(serve_session(c, peer, device.clone()));
343                }
344            });
345            addr
346        });
347        let profile = ServerProfile { name: "gpu".into(), address: addr.to_string(), server_name: "localhost".into(), identity: base.join("lab.pem") };
348        let job = wire::Configure { sorter: "kilosort4".into(), recording: "m1/rec.bin".into(), ..Default::default() };
349        let (tx, rx) = std::sync::mpsc::channel();
350        let _cancel = remote_sort(profile, job, base.join("results"), move |m| {
351            let _ = tx.send(m);
352        });
353        let (mut progress, mut end) = (0, None);
354        while let Ok(m) = rx.recv_timeout(Duration::from_secs(20)) {
355            match m {
356                JobMessage::Progress(..) => progress += 1,
357                done => {
358                    end = Some(done);
359                    break;
360                }
361            }
362        }
363        let Some(JobMessage::Done(local)) = end else { panic!("the sort did not finish: {end:?}") };
364        assert!(progress >= 2, "progress reported while it ran ({progress})");
365        assert_eq!(local, base.join("results/gpu/rec.kilosort4.sorting.zarr"));
366        assert!(local.join("zarr.json").is_file() && local.join("spikes").is_dir(), "the sorting fetched whole");
367        let _ = std::fs::remove_dir_all(&base);
368    }
369
370    #[test]
371    fn remote_paths_round_trip() {
372        let p = uri("lab GPU", "/m1/rec.ap.bin");
373        assert_eq!(p, PathBuf::from("dspk://lab GPU/m1/rec.ap.bin"));
374        assert_eq!(parse_uri(&p), Some(("lab GPU".into(), "m1/rec.ap.bin".into())));
375        assert_eq!(p.file_name().unwrap(), "rec.ap.bin");
376        assert_eq!(parse_uri(Path::new("/data/rec.bin")), None);
377        assert_eq!(parse_uri(Path::new("dspk://lab")), None);
378    }
379
380    #[test]
381    fn profiles_are_checked_before_connecting() {
382        let mut p = ServerProfile { name: "lab".into(), address: "192.168.1.20:50051".into(), server_name: DEFAULT_SERVER_NAME.into(), identity: PathBuf::from("/nonexistent.pem") };
383        assert!(p.check().unwrap_err().contains("identity"));
384        p.address = "lab-gpu".into();
385        assert!(p.check().unwrap_err().contains("address"));
386        assert_eq!(results_dir(Path::new("/r"), &ServerProfile { name: "lab GPU/1".into(), ..p }), PathBuf::from("/r/lab_GPU_1"));
387    }
388}