1use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock, RwLock};
9use std::time::Duration;
10
11use anyhow::Context as _;
12pub 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
19const NETWORK_THREADS: usize = 2;
21const STATUS_EVERY: Duration = Duration::from_secs(1);
23pub const DEFAULT_SERVER_NAME: &str = "localhost";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ServerProfile {
29 pub name: String,
31 pub address: String,
33 #[serde(default = "default_server_name")]
35 pub server_name: String,
36 pub identity: PathBuf,
38}
39
40fn default_server_name() -> String {
41 DEFAULT_SERVER_NAME.to_string()
42}
43
44impl ServerProfile {
45 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
61pub const SCHEME: &str = "dspk://";
64
65pub fn uri(server: &str, path: &str) -> PathBuf {
67 PathBuf::from(format!("{SCHEME}{server}/{}", path.trim_start_matches('/')))
68}
69
70pub 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
77fn 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
83pub fn set_profiles(servers: &[ServerProfile]) {
85 *profiles().write().unwrap_or_else(|e| e.into_inner()) = servers.to_vec();
86}
87
88pub 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
93pub 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
101pub 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
114pub 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
123pub 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
131pub 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
139pub 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
146pub 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
164pub 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
170pub 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 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 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 #[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 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 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 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 #[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}