1use std::io;
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16use std::str::FromStr;
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21use iroh_blobs::api::downloader::Downloader;
22use iroh_blobs::store::fs::FsStore;
23use iroh_blobs::HashAndFormat;
24use radicle::git::Oid;
25use radicle::identity::RepoId;
26use radicle_artifact_core::cid::Cid;
27use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
28use tokio::net::{UnixListener, UnixStream};
29use tokio::signal::unix::{signal, SignalKind};
30use tokio::sync::{broadcast, mpsc};
31use url::Url;
32
33use crate::seeder;
34use crate::{fetch, Error as ShareError};
35use radicle_artifact_client::tokio::Client;
36use radicle_artifact_core::cid::{self as cid_utils, ArtifactKind};
37use radicle_artifact_core::keys::EndpointId;
38use radicle_artifact_core::protocol::{
39 Command, CommandError, CommandResult, DownloadReceipt, ErrorCode, ExportReceipt, FetchLocation,
40 FetchProgress, FetchReceipt, HasResult, ImportMode, SeedReceipt, SeededEntry, Status,
41 StreamEvent, UnseedReceipt,
42};
43use radicle_artifact_core::ARTIFACTS_DIR;
44
45const DRAIN_TIMEOUT: Duration = Duration::from_secs(300);
50
51const READ_TIMEOUT: Duration = Duration::from_secs(30);
55
56const ROUTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
59
60#[derive(Debug, thiserror::Error)]
62pub enum NodeError {
63 #[error("another rad-artifact node is already running at {0}")]
66 AlreadyRunning(PathBuf),
67 #[error(transparent)]
69 Share(#[from] ShareError),
70 #[error("node I/O error: {0}")]
72 Io(#[from] io::Error),
73}
74
75struct NodeCtx {
82 store: FsStore,
84 downloader: Downloader,
86 endpoint: iroh::Endpoint,
89 endpoint_id: EndpointId,
91 started_at_unix: i64,
93 pkarr_urls: Vec<url::Url>,
95}
96
97pub async fn run(home: &Path, secret: iroh::SecretKey) -> Result<(), NodeError> {
114 let socket_path = home.join(ARTIFACTS_DIR).join("control.sock");
115 if let Some(parent) = socket_path.parent() {
117 std::fs::create_dir_all(parent)?;
118 }
119
120 if socket_path.exists() {
124 let probe = Client::new(socket_path.clone());
125 if probe.is_running().await {
126 return Err(NodeError::AlreadyRunning(socket_path));
127 }
128 std::fs::remove_file(&socket_path)?;
129 }
130
131 let seeder = seeder::bootstrap(home, secret).await?;
132
133 let listener = UnixListener::bind(&socket_path)?;
134 std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
135
136 let started_at_unix = SystemTime::now()
137 .duration_since(UNIX_EPOCH)
138 .map(|d| d.as_secs() as i64)
139 .unwrap_or(0);
140
141 let endpoint_id = EndpointId::from(seeder.router.endpoint().id());
142
143 tracing::info!(
144 endpoint_id = %endpoint_id,
145 socket = %socket_path.display(),
146 "rad-artifact node ready"
147 );
148
149 let downloader = Downloader::new_with_opts(
152 seeder.blobs.as_ref(),
153 seeder.router.endpoint(),
154 fetch::pool_options(),
155 );
156 let ctx = Arc::new(NodeCtx {
157 store: seeder.blobs.clone(),
158 downloader,
159 endpoint: seeder.router.endpoint().clone(),
160 endpoint_id,
161 started_at_unix,
162 pkarr_urls: seeder.pkarr_urls.clone(),
163 });
164
165 let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(8);
170 spawn_signal_handler(shutdown_tx.clone());
171
172 let in_flight = Arc::new(AtomicUsize::new(0));
173
174 loop {
175 tokio::select! {
176 biased;
177 res = shutdown_rx.recv() => {
178 if res.is_ok() { break; }
180 }
181 accept = listener.accept() => {
182 let (stream, _addr) = match accept {
183 Ok(v) => v,
184 Err(e) => {
185 tracing::warn!("accept error: {e}");
186 continue;
187 }
188 };
189 let ctx = ctx.clone();
190 let shutdown_tx = shutdown_tx.clone();
191 let in_flight = in_flight.clone();
192 in_flight.fetch_add(1, Ordering::SeqCst);
193 tokio::spawn(async move {
194 if let Err(e) = handle_connection(stream, &ctx, &shutdown_tx).await {
195 tracing::warn!("handler error: {e}");
196 }
197 in_flight.fetch_sub(1, Ordering::SeqCst);
198 });
199 }
200 }
201 }
202
203 drop(listener);
205 let _ = std::fs::remove_file(&socket_path);
206
207 let drain_deadline = Instant::now() + DRAIN_TIMEOUT;
209 while in_flight.load(Ordering::SeqCst) > 0 && Instant::now() < drain_deadline {
210 tokio::time::sleep(Duration::from_millis(50)).await;
211 }
212
213 let _ = tokio::time::timeout(ROUTER_SHUTDOWN_TIMEOUT, seeder.router.shutdown()).await;
214 tracing::info!("rad-artifact node stopped");
215 Ok(())
216}
217
218fn spawn_signal_handler(shutdown_tx: broadcast::Sender<()>) {
220 tokio::spawn(async move {
221 let Ok(mut term) = signal(SignalKind::terminate()) else {
222 return;
223 };
224 let Ok(mut int) = signal(SignalKind::interrupt()) else {
225 return;
226 };
227 tokio::select! {
228 _ = term.recv() => {}
229 _ = int.recv() => {}
230 }
231 let _ = shutdown_tx.send(());
232 });
233}
234
235async fn handle_connection(
238 stream: UnixStream,
239 ctx: &NodeCtx,
240 shutdown_tx: &broadcast::Sender<()>,
241) -> io::Result<()> {
242 let (read, mut write) = stream.into_split();
243 let mut reader = BufReader::new(read);
244 let mut line = String::new();
245 let n = match tokio::time::timeout(READ_TIMEOUT, reader.read_line(&mut line)).await {
248 Ok(res) => res?,
249 Err(_) => return Ok(()),
250 };
251 if n == 0 {
252 return Ok(());
253 }
254
255 match parse_command(line.trim_end()) {
256 Ok(Command::Export { cid, dest }) => {
259 stream_export(ctx, &mut reader, &mut write, cid, dest).await
260 }
261 Ok(Command::Fetch {
262 rid,
263 cid,
264 locations,
265 seed,
266 }) => stream_fetch(ctx, &mut reader, &mut write, rid, cid, locations, seed).await,
267 Ok(Command::Download {
268 rid,
269 cid,
270 locations,
271 dest,
272 seed,
273 }) => {
274 stream_download(
275 ctx,
276 &mut reader,
277 &mut write,
278 rid,
279 cid,
280 locations,
281 dest,
282 seed,
283 )
284 .await
285 }
286 Ok(cmd) => write_line(&mut write, dispatch(cmd, ctx, shutdown_tx).await).await,
288 Err((code, msg)) => write_line(&mut write, err_json::<()>(code, msg)).await,
289 }
290}
291
292async fn write_line(
294 write: &mut (impl AsyncWriteExt + Unpin),
295 mut response: String,
296) -> io::Result<()> {
297 response.push('\n');
298 write.write_all(response.as_bytes()).await?;
299 write.flush().await
300}
301
302fn parse_command(line: &str) -> Result<Command, (ErrorCode, String)> {
306 let value: serde_json::Value = serde_json::from_str(line).map_err(|e| {
307 (
308 ErrorCode::InvalidRequest,
309 format!("invalid command JSON: {e}"),
310 )
311 })?;
312 if let Some(rid_s) = value.get("rid").and_then(|v| v.as_str()) {
313 if let Err(e) = RepoId::from_str(rid_s) {
314 return Err((
315 ErrorCode::InvalidRequest,
316 format!("invalid rid {rid_s:?}: {e}"),
317 ));
318 }
319 }
320 if let Some(cid_s) = value.get("cid").and_then(|v| v.as_str()) {
321 if let Err(e) = Cid::from_str(cid_s) {
322 return Err((
323 ErrorCode::InvalidRequest,
324 format!("invalid cid {cid_s:?}: {e}"),
325 ));
326 }
327 }
328 serde_json::from_value(value)
329 .map_err(|e| (ErrorCode::InvalidRequest, format!("invalid command: {e}")))
330}
331
332async fn dispatch(cmd: Command, ctx: &NodeCtx, shutdown_tx: &broadcast::Sender<()>) -> String {
336 let store = &ctx.store;
337 match cmd {
338 Command::Alive => ok_json(()),
340 Command::Status => match build_status(ctx).await {
341 Ok(status) => ok_json(status),
342 Err(e) => err_from_share::<Status>(e),
343 },
344 Command::Seed {
345 rid,
346 release,
347 cid,
348 path,
349 kind,
350 mode,
351 } => seed_response(store, rid, release, cid, &path, kind, mode, ctx.endpoint_id).await,
352 Command::Unseed { rid, release, cid } => unseed_response(store, rid, release, cid).await,
353 Command::IsSeeding { rid, cid } => is_seeding_response(store, &rid, &cid).await,
354 Command::ListSeeded { rid } => list_seeded_response(store, rid).await,
355 Command::Has { cid } => has_response(store, &cid).await,
356 Command::Export { .. } | Command::Fetch { .. } | Command::Download { .. } => {
358 unreachable!("streaming commands are handled before dispatch")
359 }
360 Command::Shutdown => {
361 let resp = ok_json(());
364 let _ = shutdown_tx.send(());
365 resp
366 }
367 }
368}
369
370fn hash_and_format(cid: &Cid) -> Result<HashAndFormat, (ErrorCode, String)> {
372 let hash = cid_utils::cid_to_blake3_hash(cid)
373 .map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
374 match cid_utils::artifact_kind(cid) {
375 Ok(ArtifactKind::Blob) => Ok(HashAndFormat::raw(hash.into())),
376 Ok(ArtifactKind::Collection) => Ok(HashAndFormat::hash_seq(hash.into())),
377 Err(e) => Err((ErrorCode::InvalidRequest, e.to_string())),
378 }
379}
380
381async fn has_response(store: &FsStore, cid: &Cid) -> String {
384 let haf = match hash_and_format(cid) {
385 Ok(h) => h,
386 Err((code, msg)) => return err_json::<HasResult>(code, msg),
387 };
388 match store.remote().local(haf).await {
389 Ok(info) => {
390 let bytes = info.local_bytes();
391 ok_json(HasResult {
392 present: bytes > 0,
393 complete: info.is_complete(),
394 bytes,
395 })
396 }
397 Err(e) => err_json::<HasResult>(ErrorCode::Iroh, format!("local lookup: {e}")),
398 }
399}
400
401async fn write_frame<T: serde::Serialize>(
403 write: &mut (impl AsyncWriteExt + Unpin),
404 event: &StreamEvent<T>,
405) -> io::Result<()> {
406 let mut line = serde_json::to_string(event).unwrap_or_else(|e| {
407 format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#)
408 });
409 line.push('\n');
410 write.write_all(line.as_bytes()).await?;
411 write.flush().await
412}
413
414async fn stream_error<T: serde::Serialize>(
416 write: &mut (impl AsyncWriteExt + Unpin),
417 code: ErrorCode,
418 message: String,
419) -> io::Result<()> {
420 write_frame::<T>(write, &StreamEvent::Error(CommandError { code, message })).await
421}
422
423async fn run_stream<T: serde::Serialize>(
432 read: &mut (impl AsyncReadExt + Unpin),
433 write: &mut (impl AsyncWriteExt + Unpin),
434 op: impl AsyncFnOnce(mpsc::UnboundedSender<FetchProgress>) -> Result<T, (ErrorCode, String)>,
435) -> io::Result<()> {
436 let (tx, mut rx) = mpsc::unbounded_channel::<FetchProgress>();
437 let fut = op(tx);
438 tokio::pin!(fut);
439 let mut probe = [0u8; 1];
440 loop {
441 tokio::select! {
442 biased;
443 res = &mut fut => {
444 while let Ok(p) = rx.try_recv() {
446 write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
447 }
448 let event = match res {
449 Ok(payload) => StreamEvent::Okay(payload),
450 Err((code, message)) => StreamEvent::Error(CommandError { code, message }),
451 };
452 return write_frame(write, &event).await;
453 }
454 Some(p) = rx.recv() => {
455 write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
456 }
457 _ = read.read(&mut probe) => {
462 return Ok(());
463 }
464 }
465 }
466}
467
468async fn stream_export(
471 ctx: &NodeCtx,
472 read: &mut (impl AsyncReadExt + Unpin),
473 write: &mut (impl AsyncWriteExt + Unpin),
474 cid: Cid,
475 dest: PathBuf,
476) -> io::Result<()> {
477 let kind = match cid_utils::artifact_kind(&cid) {
478 Ok(k) => k,
479 Err(e) => {
480 return stream_error::<ExportReceipt>(write, ErrorCode::InvalidRequest, e.to_string())
481 .await
482 }
483 };
484 let haf = match hash_and_format(&cid) {
485 Ok(h) => h,
486 Err((code, msg)) => return stream_error::<ExportReceipt>(write, code, msg).await,
487 };
488 let hash = haf.hash;
489
490 match ctx.store.blobs().has(hash).await {
492 Ok(true) => {}
493 Ok(false) => {
494 return stream_error::<ExportReceipt>(
495 write,
496 ErrorCode::NotLocal,
497 format!("content for {cid} is not complete in the store"),
498 )
499 .await
500 }
501 Err(e) => {
502 return stream_error::<ExportReceipt>(
503 write,
504 ErrorCode::Iroh,
505 format!("local lookup: {e}"),
506 )
507 .await
508 }
509 }
510
511 run_stream(read, write, async move |tx| {
512 let on_progress = move |p| {
513 let _ = tx.send(p);
514 };
515 let bytes = match kind {
516 ArtifactKind::Blob => fetch::export_blob_to(&ctx.store, hash, &dest, on_progress).await,
517 ArtifactKind::Collection => {
518 fetch::export_collection_to(&ctx.store, hash, &dest, on_progress).await
519 }
520 }
521 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
522 Ok(ExportReceipt { cid, dest, bytes })
523 })
524 .await
525}
526
527fn partition(locations: &[FetchLocation]) -> (Vec<EndpointId>, Vec<Url>) {
529 let mut iroh = Vec::new();
530 let mut urls = Vec::new();
531 for loc in locations {
532 match loc {
533 FetchLocation::Iroh(id) => iroh.push(*id),
534 FetchLocation::Url(u) => urls.push(u.clone()),
535 }
536 }
537 (iroh, urls)
538}
539
540async fn export_to_dest(
542 store: &FsStore,
543 hash: iroh_blobs::Hash,
544 kind: ArtifactKind,
545 dest: &Path,
546 on_progress: impl FnMut(FetchProgress),
547) -> Result<u64, (ErrorCode, String)> {
548 match kind {
549 ArtifactKind::Blob => fetch::export_blob_to(store, hash, dest, on_progress).await,
550 ArtifactKind::Collection => {
551 fetch::export_collection_to(store, hash, dest, on_progress).await
552 }
553 }
554 .map_err(|e| (share_error_to_code(&e), e.to_string()))
555}
556
557struct Fetched {
563 kind: ArtifactKind,
565 hash: iroh_blobs::Hash,
567 from_cache: bool,
569 _tt: iroh_blobs::api::TempTag,
571}
572
573async fn fetch_into_store(
583 ctx: &NodeCtx,
584 cid: &Cid,
585 locations: &[FetchLocation],
586 on_progress: &mut impl FnMut(FetchProgress),
587) -> Result<Fetched, (ErrorCode, String)> {
588 let kind =
589 cid_utils::artifact_kind(cid).map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
590 let haf = hash_and_format(cid)?;
591 let hash = haf.hash;
592 let store = &ctx.store;
593
594 let tt = store
601 .tags()
602 .temp_tag(haf)
603 .await
604 .map_err(|e| (ErrorCode::Iroh, format!("temp tag: {e}")))?;
605
606 let already = store
608 .blobs()
609 .has(hash)
610 .await
611 .map_err(|e| (ErrorCode::Iroh, format!("local lookup: {e}")))?;
612 if already {
613 return Ok(Fetched {
614 kind,
615 hash,
616 from_cache: true,
617 _tt: tt,
618 });
619 }
620
621 on_progress(FetchProgress::Connecting);
622
623 let (iroh_ids, urls) = partition(locations);
624 let no_locations = iroh_ids.is_empty() && urls.is_empty();
625 let mut errors: Vec<String> = Vec::new();
626 let mut got = false;
627
628 if !iroh_ids.is_empty() {
629 match fetch::download_iroh_to_store(
630 &ctx.downloader,
631 store,
632 haf,
633 iroh_ids,
634 &mut *on_progress,
635 )
636 .await
637 {
638 Ok(()) => got = true,
639 Err(errs) => errors.extend(errs.into_iter().map(|e| e.to_string())),
640 }
641 }
642 if !got {
643 match kind {
644 ArtifactKind::Blob => {
647 for url in &urls {
648 match fetch::http_to_store(store, url, cid, &mut *on_progress).await {
649 Ok(_) => break,
650 Err(e) => errors.push(e.to_string()),
651 }
652 }
653 }
654 ArtifactKind::Collection => {
655 for url in &urls {
656 errors.push(format!("HTTP fetch unsupported for collection: {url}"));
657 }
658 }
659 }
660 }
661
662 let complete = store
665 .remote()
666 .local(haf)
667 .await
668 .map(|i| i.is_complete())
669 .unwrap_or(false);
670 if !complete {
671 drop(tt); let code = if no_locations {
673 ErrorCode::NoLocations
674 } else {
675 ErrorCode::AllFailed
676 };
677 let msg = if errors.is_empty() {
678 "no locations succeeded".to_string()
679 } else {
680 errors.join("; ")
681 };
682 return Err((code, msg));
683 }
684
685 Ok(Fetched {
686 kind,
687 hash,
688 from_cache: false,
689 _tt: tt,
690 })
691}
692
693async fn stream_fetch(
702 ctx: &NodeCtx,
703 read: &mut (impl AsyncReadExt + Unpin),
704 write: &mut (impl AsyncWriteExt + Unpin),
705 rid: RepoId,
706 cid: Cid,
707 locations: Vec<FetchLocation>,
708 seed: Option<Oid>,
709) -> io::Result<()> {
710 let endpoint_id = ctx.endpoint_id;
711
712 run_stream(read, write, async move |tx| {
713 let mut on_progress = move |p| {
714 let _ = tx.send(p);
715 };
716 let store = &ctx.store;
717
718 let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
719 let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
720 .await
721 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
722
723 let bytes = seeder::artifact_size_for(store, &cid, fetched.hash).await;
725 Ok(FetchReceipt {
726 rid,
727 cid,
728 bytes,
729 from_cache: fetched.from_cache,
730 seeded,
731 endpoint_id,
732 })
733 })
735 .await
736}
737
738#[allow(clippy::too_many_arguments)]
746async fn stream_download(
747 ctx: &NodeCtx,
748 read: &mut (impl AsyncReadExt + Unpin),
749 write: &mut (impl AsyncWriteExt + Unpin),
750 rid: RepoId,
751 cid: Cid,
752 locations: Vec<FetchLocation>,
753 dest: PathBuf,
754 seed: Option<Oid>,
755) -> io::Result<()> {
756 let endpoint_id = ctx.endpoint_id;
757
758 run_stream(read, write, async move |tx| {
759 let mut on_progress = move |p| {
760 let _ = tx.send(p);
761 };
762 let store = &ctx.store;
763
764 let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
765
766 let bytes =
768 export_to_dest(store, fetched.hash, fetched.kind, &dest, &mut on_progress).await?;
769 let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
770 .await
771 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
772
773 Ok(DownloadReceipt {
774 rid,
775 cid,
776 dest,
777 bytes,
778 from_cache: fetched.from_cache,
779 seeded,
780 endpoint_id,
781 })
782 })
784 .await
785}
786
787async fn tag_if_seeding(
794 store: &iroh_blobs::api::Store,
795 rid: &RepoId,
796 seed: Option<&Oid>,
797 cid: &Cid,
798 hash: iroh_blobs::Hash,
799) -> Result<bool, ShareError> {
800 let Some(release) = seed else {
801 return Ok(false);
802 };
803 seeder::tag_seeded(store, rid, release, cid, hash).await?;
804 Ok(true)
805}
806
807#[allow(clippy::too_many_arguments)]
808async fn seed_response(
809 store: &FsStore,
810 rid: RepoId,
811 release: Oid,
812 cid: Cid,
813 path: &Path,
814 kind: ArtifactKind,
815 mode: ImportMode,
816 endpoint_id: EndpointId,
817) -> String {
818 if !path.exists() {
819 return err_json::<SeedReceipt>(
820 ErrorCode::PathNotFound,
821 format!("path not found: {}", path.display()),
822 );
823 }
824
825 let was_already = match seeder::is_seeded(store, &rid, &release, &cid).await {
826 Ok(v) => v,
827 Err(e) => return err_from_share::<SeedReceipt>(e),
828 };
829 let hash = match seeder::seed_artifact(store, &rid, &release, &cid, path, kind, mode).await {
830 Ok(hash) => hash,
831 Err(e) => return err_from_share::<SeedReceipt>(e),
832 };
833 let bytes = seeder::artifact_size_for(store, &cid, hash).await;
834 let receipt = SeedReceipt {
835 rid,
836 cid,
837 endpoint_id,
838 bytes,
839 was_new: !was_already,
840 };
841 ok_json(receipt)
842}
843
844async fn unseed_response(store: &FsStore, rid: RepoId, release: Option<Oid>, cid: Cid) -> String {
849 let was_removed = match &release {
850 Some(release) => match seeder::untag_seeded(store, &rid, release, &cid).await {
851 Ok(removed) => removed,
852 Err(e) => return err_from_share::<UnseedReceipt>(e),
853 },
854 None => match seeder::untag_all(store, &rid, &cid).await {
855 Ok(removed) => removed > 0,
856 Err(e) => return err_from_share::<UnseedReceipt>(e),
857 },
858 };
859 ok_json(UnseedReceipt {
860 rid,
861 cid,
862 was_removed,
863 })
864}
865
866async fn is_seeding_response(store: &FsStore, rid: &RepoId, cid: &Cid) -> String {
867 match seeder::is_seeded_any(store, rid, cid).await {
868 Ok(v) => ok_json(v),
869 Err(e) => err_from_share::<bool>(e),
870 }
871}
872
873async fn list_seeded_response(store: &FsStore, rid: RepoId) -> String {
874 let cids = match seeder::seeded_cids(store, &rid).await {
875 Ok(v) => v,
876 Err(e) => return err_from_share::<Vec<SeededEntry>>(e),
877 };
878 let mut out = Vec::with_capacity(cids.len());
879 for (cid, (hash, releases)) in cids {
880 let bytes = seeder::artifact_size_for(store, &cid, hash).await;
881 let complete = seeder::artifact_is_complete(store, &cid, hash).await;
884 out.push(SeededEntry {
885 cid,
886 bytes,
887 complete,
888 releases,
889 });
890 }
891 ok_json(out)
892}
893
894async fn build_status(ctx: &NodeCtx) -> Result<Status, ShareError> {
895 let store = &ctx.store;
896 let endpoint = &ctx.endpoint;
897 let metrics = endpoint.metrics();
898 let distinct: std::collections::HashMap<iroh_blobs::Hash, Cid> = seeder::all_seeded(store)
901 .await?
902 .into_iter()
903 .map(|(_rid, _release, cid, hash)| (hash, cid))
904 .collect();
905 let count = distinct.len();
906 let mut bytes_logical = 0u64;
907 for (hash, cid) in &distinct {
908 bytes_logical =
909 bytes_logical.saturating_add(seeder::artifact_size_for(store, cid, *hash).await);
910 }
911 let s = &metrics.socket;
915 let opened_total = s.num_conns_opened.get();
916 let closed_total = s.num_conns_closed.get();
917 let connections = radicle_artifact_core::protocol::ConnectionStats {
918 active: opened_total.saturating_sub(closed_total) as u32,
919 opened_total,
920 closed_total,
921 direct_total: s.num_conns_direct.get(),
922 holepunch_attempts: s.holepunch_attempts.get(),
923 paths_direct: s.paths_direct.get(),
924 paths_relayed: s.paths_relay.get(),
925 };
926 let traffic = radicle_artifact_core::protocol::TrafficStats {
927 out_bytes: s
928 .send_ipv4
929 .get()
930 .saturating_add(s.send_ipv6.get())
931 .saturating_add(s.send_relay.get()),
932 in_bytes: s
933 .recv_data_ipv4
934 .get()
935 .saturating_add(s.recv_data_ipv6.get())
936 .saturating_add(s.recv_data_relay.get())
937 .saturating_add(s.recv_data_custom.get()),
938 };
939 let relay = relay_stats(endpoint);
940 let relay_unreachable = !relay.relays.iter().any(|r| r.connected);
943 Ok(Status {
944 endpoint_id: ctx.endpoint_id,
945 version: Some(env!("CARGO_PKG_VERSION").to_owned()),
946 started_at_unix: ctx.started_at_unix,
947 seeded: radicle_artifact_core::protocol::SeededStats {
948 count,
949 bytes_logical,
950 },
951 connections,
952 traffic,
953 relay,
954 pkarr: Some(radicle_artifact_core::protocol::PkarrStats {
955 uri: ctx.endpoint_id.to_pkarr_uri(),
956 relays: ctx.pkarr_urls.clone(),
957 }),
958 warnings: radicle_artifact_core::protocol::Warnings { relay_unreachable },
959 })
960}
961
962fn relay_stats(endpoint: &iroh::Endpoint) -> radicle_artifact_core::protocol::RelayStats {
968 use iroh::Watcher;
969
970 let report = endpoint.net_report().get();
971 let mut latency_ms: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
973 if let Some(r) = &report {
974 for (_probe, url, dur) in r.relay_latency.iter() {
975 let ms = dur.as_millis() as u64;
976 latency_ms
977 .entry(url.to_string())
978 .and_modify(|m| *m = (*m).min(ms))
979 .or_insert(ms);
980 }
981 }
982
983 let relays = endpoint
984 .home_relay_status()
985 .get()
986 .into_iter()
987 .map(|s| {
988 let url = s.url().to_string();
989 radicle_artifact_core::protocol::RelayHealth {
990 latency_ms: latency_ms.get(&url).copied(),
991 connected: s.is_connected(),
992 last_error: s.last_error().map(|e| e.to_string()),
993 url,
994 }
995 })
996 .collect();
997
998 radicle_artifact_core::protocol::RelayStats {
999 relays,
1000 preferred: report
1001 .as_ref()
1002 .and_then(|r| r.preferred_relay.as_ref().map(|u| u.to_string())),
1003 udp_v4: report.as_ref().map(|r| r.udp_v4).unwrap_or(false),
1004 udp_v6: report.as_ref().map(|r| r.udp_v6).unwrap_or(false),
1005 }
1006}
1007
1008fn ok_json<T: serde::Serialize>(v: T) -> String {
1009 serde_json::to_string(&CommandResult::Okay(v))
1010 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1011}
1012
1013fn err_json<T>(code: ErrorCode, message: String) -> String
1014where
1015 T: serde::Serialize,
1016{
1017 serde_json::to_string(&CommandResult::<T>::Error(CommandError { code, message }))
1018 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1019}
1020
1021fn err_from_share<T>(e: ShareError) -> String
1022where
1023 T: serde::Serialize,
1024{
1025 let code = share_error_to_code(&e);
1026 err_json::<T>(code, e.to_string())
1027}
1028
1029fn share_error_to_code(e: &ShareError) -> ErrorCode {
1030 match e {
1031 ShareError::CidMismatch { .. } => ErrorCode::CidMismatch,
1032 ShareError::Io(_) => ErrorCode::Io,
1033 ShareError::Iroh(_) => ErrorCode::Iroh,
1034 _ => ErrorCode::Internal,
1035 }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use std::fs;
1041
1042 use cid::multihash::Multihash;
1043
1044 use super::*;
1045 use radicle_artifact_core::cid::{
1046 self as cid_utils, ArtifactKind, HASH_CODE_BLAKE3, RAW_CODEC,
1047 };
1048
1049 fn fake_blob_cid(data: &[u8]) -> Cid {
1052 let digest = blake3::hash(data);
1053 let mh = Multihash::<64>::wrap(HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
1054 Cid::from(cid::Cid::new_v1(RAW_CODEC, mh))
1055 }
1056
1057 fn rid_a() -> RepoId {
1058 RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap()
1059 }
1060
1061 fn release_a() -> Oid {
1062 Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap()
1063 }
1064
1065 async fn start_node(
1068 home: &Path,
1069 secret: iroh::SecretKey,
1070 ) -> (PathBuf, tokio::task::JoinHandle<Result<(), NodeError>>) {
1071 let home_path = home.to_path_buf();
1072 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1073 let socket = home.join(ARTIFACTS_DIR).join("control.sock");
1074 for _ in 0..200 {
1075 if socket.exists() {
1076 break;
1077 }
1078 tokio::time::sleep(Duration::from_millis(50)).await;
1079 }
1080 assert!(socket.exists(), "control socket never appeared");
1081 (socket, handle)
1082 }
1083
1084 #[test]
1088 fn node_round_trip() {
1089 let rt = tokio::runtime::Builder::new_multi_thread()
1090 .enable_all()
1091 .build()
1092 .unwrap();
1093 rt.block_on(async {
1094 let home = tempfile::tempdir().unwrap();
1095
1096 let blob_path = home.path().join("payload.bin");
1099 let payload = b"hello rad-artifact";
1100 fs::write(&blob_path, payload).unwrap();
1101 let real_cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1102 let rid = rid_a();
1103
1104 let secret = iroh::SecretKey::from_bytes(&[1u8; 32]);
1106 let expected_endpoint_id = EndpointId::from(secret.public());
1107
1108 let home_path = home.path().to_path_buf();
1111 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1112
1113 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1116 for _ in 0..200 {
1117 if socket.exists() {
1118 break;
1119 }
1120 tokio::time::sleep(Duration::from_millis(50)).await;
1121 }
1122 assert!(socket.exists(), "control socket never appeared");
1123
1124 let client = Client::new(socket.clone());
1125
1126 assert!(client.is_running().await);
1128
1129 let status = client.status().await.unwrap();
1131 assert_eq!(status.endpoint_id, expected_endpoint_id);
1132 assert_eq!(status.seeded.count, 0);
1133
1134 let receipt = client
1136 .seed(
1137 rid,
1138 release_a(),
1139 real_cid,
1140 &blob_path,
1141 ArtifactKind::Blob,
1142 ImportMode::Copy,
1143 )
1144 .await
1145 .unwrap();
1146 assert!(receipt.was_new);
1147 assert_eq!(receipt.endpoint_id, expected_endpoint_id);
1148 assert_eq!(receipt.bytes, payload.len() as u64);
1149
1150 let receipt2 = client
1152 .seed(
1153 rid,
1154 release_a(),
1155 real_cid,
1156 &blob_path,
1157 ArtifactKind::Blob,
1158 ImportMode::Copy,
1159 )
1160 .await
1161 .unwrap();
1162 assert!(!receipt2.was_new);
1163
1164 let bad_path = home.path().join("tampered.bin");
1166 fs::write(&bad_path, b"different bytes").unwrap();
1167 let bad_cid = fake_blob_cid(b"not the right preimage");
1168 let err = client
1169 .seed(
1170 rid,
1171 release_a(),
1172 bad_cid,
1173 &bad_path,
1174 ArtifactKind::Blob,
1175 ImportMode::Copy,
1176 )
1177 .await
1178 .expect_err("CID mismatch must error");
1179 match err {
1180 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1181 assert_eq!(code, ErrorCode::CidMismatch);
1182 }
1183 other => panic!("expected CidMismatch error, got {other:?}"),
1184 }
1185
1186 assert!(client.is_seeding(rid, real_cid).await.unwrap());
1188
1189 let entries = client.list_seeded(rid).await.unwrap();
1191 assert_eq!(entries.len(), 1);
1192 assert_eq!(entries[0].cid, real_cid);
1193 assert_eq!(entries[0].bytes, payload.len() as u64);
1194 assert_eq!(entries[0].releases, vec![release_a()]);
1195
1196 let status = client.status().await.unwrap();
1198 assert_eq!(status.seeded.count, 1);
1199 assert_eq!(status.seeded.bytes_logical, payload.len() as u64);
1200
1201 let r1 = client
1203 .unseed(rid, Some(release_a()), real_cid)
1204 .await
1205 .unwrap();
1206 assert!(r1.was_removed);
1207 let r2 = client
1208 .unseed(rid, Some(release_a()), real_cid)
1209 .await
1210 .unwrap();
1211 assert!(!r2.was_removed);
1212 assert!(!client.is_seeding(rid, real_cid).await.unwrap());
1213
1214 client.shutdown().await.unwrap();
1216 tokio::time::timeout(Duration::from_secs(10), node_handle)
1217 .await
1218 .expect("node did not exit within 10s")
1219 .expect("join error")
1220 .expect("node returned error");
1221
1222 assert!(!socket.exists());
1224 });
1225 }
1226
1227 #[test]
1231 fn invalid_typed_fields_surface_as_invalid_request() {
1232 let rt = tokio::runtime::Builder::new_multi_thread()
1233 .enable_all()
1234 .build()
1235 .unwrap();
1236 rt.block_on(async {
1237 let home = tempfile::tempdir().unwrap();
1238 let secret = iroh::SecretKey::from_bytes(&[4u8; 32]);
1239 let home_path = home.path().to_path_buf();
1240 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1241
1242 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1243 for _ in 0..200 {
1244 if socket.exists() {
1245 break;
1246 }
1247 tokio::time::sleep(Duration::from_millis(50)).await;
1248 }
1249 assert!(socket.exists());
1250
1251 for (frame, expected_field) in [
1254 (
1255 br#"{"command":"listSeeded","rid":"not-a-real-rid"}"#.as_slice(),
1256 "rid",
1257 ),
1258 (
1259 br#"{"command":"isSeeding","rid":"rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip","cid":"not-a-real-cid"}"#.as_slice(),
1260 "cid",
1261 ),
1262 ] {
1263 let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap();
1264 tokio::io::AsyncWriteExt::write_all(&mut stream, frame)
1265 .await
1266 .unwrap();
1267 tokio::io::AsyncWriteExt::write_all(&mut stream, b"\n")
1268 .await
1269 .unwrap();
1270 let mut buf = String::new();
1271 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1272 .await
1273 .unwrap();
1274 let parsed: CommandResult<serde_json::Value> =
1275 serde_json::from_str(buf.trim()).unwrap();
1276 match parsed {
1277 CommandResult::Error(CommandError { code, message }) => {
1278 assert_eq!(code, ErrorCode::InvalidRequest);
1279 assert!(
1280 message.contains(expected_field),
1281 "message should name {expected_field}: {message}"
1282 );
1283 }
1284 CommandResult::Okay(_) => panic!("expected error, got ok"),
1285 }
1286 }
1287
1288 let client = Client::new(socket);
1290 client.shutdown().await.unwrap();
1291 node_handle.await.unwrap().unwrap();
1292 });
1293 }
1294
1295 #[test]
1298 fn double_start_errors() {
1299 let rt = tokio::runtime::Builder::new_multi_thread()
1300 .enable_all()
1301 .build()
1302 .unwrap();
1303 rt.block_on(async {
1304 let home = tempfile::tempdir().unwrap();
1305 let secret = iroh::SecretKey::from_bytes(&[2u8; 32]);
1306 let home_path = home.path().to_path_buf();
1307 let first = tokio::spawn(async move { run(&home_path, secret).await });
1308
1309 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1310 for _ in 0..200 {
1311 if socket.exists() {
1312 break;
1313 }
1314 tokio::time::sleep(Duration::from_millis(50)).await;
1315 }
1316 assert!(socket.exists());
1317
1318 let secret2 = iroh::SecretKey::from_bytes(&[3u8; 32]);
1320 let err = run(home.path(), secret2).await.expect_err("must fail");
1321 assert!(matches!(err, NodeError::AlreadyRunning(_)));
1322
1323 let client = Client::new(socket);
1325 client.shutdown().await.unwrap();
1326 first.await.unwrap().unwrap();
1327 });
1328 }
1329
1330 async fn oneshot<T: serde::de::DeserializeOwned>(
1332 socket: &Path,
1333 cmd: &Command,
1334 ) -> CommandResult<T> {
1335 let mut stream = UnixStream::connect(socket).await.unwrap();
1336 let mut line = serde_json::to_string(cmd).unwrap();
1337 line.push('\n');
1338 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1339 .await
1340 .unwrap();
1341 let mut buf = String::new();
1342 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1343 .await
1344 .unwrap();
1345 serde_json::from_str(buf.trim()).unwrap()
1346 }
1347
1348 async fn streaming<T: serde::de::DeserializeOwned>(
1350 socket: &Path,
1351 cmd: &Command,
1352 ) -> (usize, StreamEvent<T>) {
1353 let mut stream = UnixStream::connect(socket).await.unwrap();
1354 let mut line = serde_json::to_string(cmd).unwrap();
1355 line.push('\n');
1356 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1357 .await
1358 .unwrap();
1359 let mut buf = String::new();
1360 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1361 .await
1362 .unwrap();
1363 let mut progress = 0usize;
1364 let mut terminal = None;
1365 for l in buf.lines().filter(|l| !l.trim().is_empty()) {
1366 match serde_json::from_str::<StreamEvent<T>>(l).unwrap() {
1367 StreamEvent::Progress(_) => progress += 1,
1368 term => terminal = Some(term),
1369 }
1370 }
1371 (progress, terminal.expect("a terminal frame"))
1372 }
1373
1374 #[test]
1377 fn has_and_export_round_trip() {
1378 let rt = tokio::runtime::Builder::new_multi_thread()
1379 .enable_all()
1380 .build()
1381 .unwrap();
1382 rt.block_on(async {
1383 let home = tempfile::tempdir().unwrap();
1384 let payload = b"hello has/export";
1385 let blob_path = home.path().join("payload.bin");
1386 fs::write(&blob_path, payload).unwrap();
1387 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1388 let rid = rid_a();
1389
1390 let secret = iroh::SecretKey::from_bytes(&[5u8; 32]);
1391 let home_path = home.path().to_path_buf();
1392 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1393
1394 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1395 for _ in 0..200 {
1396 if socket.exists() {
1397 break;
1398 }
1399 tokio::time::sleep(Duration::from_millis(50)).await;
1400 }
1401 assert!(socket.exists(), "control socket never appeared");
1402
1403 let client = Client::new(socket.clone());
1405 client
1406 .seed(
1407 rid,
1408 release_a(),
1409 cid,
1410 &blob_path,
1411 ArtifactKind::Blob,
1412 ImportMode::Copy,
1413 )
1414 .await
1415 .unwrap();
1416
1417 match oneshot::<HasResult>(&socket, &Command::Has { cid }).await {
1419 CommandResult::Okay(h) => {
1420 assert!(h.present);
1421 assert!(h.complete);
1422 assert_eq!(h.bytes, payload.len() as u64);
1423 }
1424 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1425 }
1426
1427 let unknown = fake_blob_cid(b"never stored");
1429 match oneshot::<HasResult>(&socket, &Command::Has { cid: unknown }).await {
1430 CommandResult::Okay(h) => {
1431 assert!(!h.present);
1432 assert!(!h.complete);
1433 }
1434 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1435 }
1436
1437 let dest = home.path().join("exported.bin");
1439 let (_progress, term) = streaming::<ExportReceipt>(
1440 &socket,
1441 &Command::Export {
1442 cid,
1443 dest: dest.clone(),
1444 },
1445 )
1446 .await;
1447 match term {
1448 StreamEvent::Okay(r) => {
1449 assert_eq!(r.bytes, payload.len() as u64);
1450 assert_eq!(r.dest, dest);
1451 }
1452 other => panic!("expected okay, got {other:?}"),
1453 }
1454 assert_eq!(fs::read(&dest).unwrap(), payload);
1455
1456 let (_p, term) = streaming::<ExportReceipt>(
1458 &socket,
1459 &Command::Export {
1460 cid: unknown,
1461 dest: home.path().join("nope.bin"),
1462 },
1463 )
1464 .await;
1465 match term {
1466 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NotLocal),
1467 other => panic!("expected NotLocal error, got {other:?}"),
1468 }
1469
1470 client.shutdown().await.unwrap();
1471 node_handle.await.unwrap().unwrap();
1472 });
1473 }
1474
1475 #[test]
1482 fn fetch_and_download_fast_path_and_no_locations() {
1483 let rt = tokio::runtime::Builder::new_multi_thread()
1484 .enable_all()
1485 .build()
1486 .unwrap();
1487 rt.block_on(async {
1488 let home = tempfile::tempdir().unwrap();
1489 let payload = b"hello fetch fast-path";
1490 let blob_path = home.path().join("payload.bin");
1491 fs::write(&blob_path, payload).unwrap();
1492 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1493 let rid = rid_a();
1494
1495 let secret = iroh::SecretKey::from_bytes(&[6u8; 32]);
1496 let home_path = home.path().to_path_buf();
1497 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1498
1499 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1500 for _ in 0..200 {
1501 if socket.exists() {
1502 break;
1503 }
1504 tokio::time::sleep(Duration::from_millis(50)).await;
1505 }
1506 assert!(socket.exists(), "control socket never appeared");
1507
1508 let client = Client::new(socket.clone());
1510 client
1511 .seed(
1512 rid,
1513 release_a(),
1514 cid,
1515 &blob_path,
1516 ArtifactKind::Blob,
1517 ImportMode::Copy,
1518 )
1519 .await
1520 .unwrap();
1521
1522 let (_p, term) = streaming::<FetchReceipt>(
1525 &socket,
1526 &Command::Fetch {
1527 rid,
1528 cid,
1529 locations: vec![],
1530 seed: None,
1531 },
1532 )
1533 .await;
1534 match term {
1535 StreamEvent::Okay(r) => {
1536 assert!(r.from_cache);
1537 assert!(!r.seeded);
1538 assert_eq!(r.bytes, payload.len() as u64);
1539 }
1540 other => panic!("expected okay, got {other:?}"),
1541 }
1542
1543 let dest = home.path().join("downloaded.bin");
1545 let (_p, term) = streaming::<DownloadReceipt>(
1546 &socket,
1547 &Command::Download {
1548 rid,
1549 cid,
1550 locations: vec![],
1551 dest: dest.clone(),
1552 seed: None,
1553 },
1554 )
1555 .await;
1556 match term {
1557 StreamEvent::Okay(r) => {
1558 assert!(r.from_cache);
1559 assert!(!r.seeded);
1560 assert_eq!(r.bytes, payload.len() as u64);
1561 assert_eq!(r.dest, dest);
1562 }
1563 other => panic!("expected okay, got {other:?}"),
1564 }
1565 assert_eq!(fs::read(&dest).unwrap(), payload);
1566
1567 let rid2 = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
1570 let (_p, term) = streaming::<FetchReceipt>(
1571 &socket,
1572 &Command::Fetch {
1573 rid: rid2,
1574 cid,
1575 locations: vec![],
1576 seed: Some(release_a()),
1577 },
1578 )
1579 .await;
1580 match term {
1581 StreamEvent::Okay(r) => {
1582 assert!(r.from_cache);
1583 assert!(r.seeded);
1584 }
1585 other => panic!("expected okay, got {other:?}"),
1586 }
1587 assert!(client.is_seeding(rid2, cid).await.unwrap());
1588
1589 let unknown = fake_blob_cid(b"absent");
1591 let (_p, term) = streaming::<FetchReceipt>(
1592 &socket,
1593 &Command::Fetch {
1594 rid,
1595 cid: unknown,
1596 locations: vec![],
1597 seed: None,
1598 },
1599 )
1600 .await;
1601 match term {
1602 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NoLocations),
1603 other => panic!("expected NoLocations error, got {other:?}"),
1604 }
1605
1606 client.shutdown().await.unwrap();
1607 node_handle.await.unwrap().unwrap();
1608 });
1609 }
1610
1611 #[test]
1616 fn run_stream_aborts_on_client_disconnect() {
1617 use std::sync::atomic::AtomicBool;
1618
1619 let rt = tokio::runtime::Builder::new_multi_thread()
1620 .enable_all()
1621 .build()
1622 .unwrap();
1623 rt.block_on(async {
1624 let (client_end, server_end) = tokio::io::duplex(64);
1627 let (mut srv_read, mut srv_write) = tokio::io::split(server_end);
1628
1629 struct DropFlag(Arc<AtomicBool>);
1632 impl Drop for DropFlag {
1633 fn drop(&mut self) {
1634 self.0.store(true, Ordering::SeqCst);
1635 }
1636 }
1637 let aborted = Arc::new(AtomicBool::new(false));
1638 let completed = Arc::new(AtomicBool::new(false));
1639 let aborted_op = aborted.clone();
1640 let completed_op = completed.clone();
1641
1642 let op = async move |_tx: mpsc::UnboundedSender<FetchProgress>| {
1643 let _guard = DropFlag(aborted_op);
1644 std::future::pending::<()>().await;
1646 completed_op.store(true, Ordering::SeqCst);
1647 Ok::<(), (ErrorCode, String)>(())
1648 };
1649
1650 let server =
1651 tokio::spawn(
1652 async move { run_stream::<()>(&mut srv_read, &mut srv_write, op).await },
1653 );
1654
1655 tokio::time::sleep(Duration::from_millis(50)).await;
1658 drop(client_end); let res = tokio::time::timeout(Duration::from_secs(5), server)
1661 .await
1662 .expect("run_stream did not return after disconnect")
1663 .expect("join error");
1664 assert!(res.is_ok());
1665 assert!(
1666 aborted.load(Ordering::SeqCst),
1667 "op future was not dropped on disconnect"
1668 );
1669 assert!(
1670 !completed.load(Ordering::SeqCst),
1671 "op should have been aborted, not completed"
1672 );
1673 });
1674 }
1675
1676 #[test]
1679 fn stale_socket_is_reclaimed() {
1680 let rt = tokio::runtime::Builder::new_multi_thread()
1681 .enable_all()
1682 .build()
1683 .unwrap();
1684 rt.block_on(async {
1685 let home = tempfile::tempdir().unwrap();
1686 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1687 fs::create_dir_all(socket.parent().unwrap()).unwrap();
1688 drop(UnixListener::bind(&socket).unwrap());
1690 assert!(socket.exists());
1691
1692 let secret = iroh::SecretKey::from_bytes(&[7u8; 32]);
1693 let home_path = home.path().to_path_buf();
1694 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1695
1696 let client = Client::new(socket.clone());
1699 let mut ready = false;
1700 for _ in 0..200 {
1701 if client.is_running().await {
1702 ready = true;
1703 break;
1704 }
1705 tokio::time::sleep(Duration::from_millis(50)).await;
1706 }
1707 assert!(ready, "node never came up after reclaiming stale socket");
1708
1709 client.shutdown().await.unwrap();
1710 handle.await.unwrap().unwrap();
1711 });
1712 }
1713
1714 #[test]
1717 fn seed_missing_path_errors() {
1718 let rt = tokio::runtime::Builder::new_multi_thread()
1719 .enable_all()
1720 .build()
1721 .unwrap();
1722 rt.block_on(async {
1723 let home = tempfile::tempdir().unwrap();
1724 let secret = iroh::SecretKey::from_bytes(&[8u8; 32]);
1725 let (socket, handle) = start_node(home.path(), secret).await;
1726
1727 let client = Client::new(socket.clone());
1728 let missing = home.path().join("does-not-exist.bin");
1729 let err = client
1730 .seed(
1731 rid_a(),
1732 release_a(),
1733 fake_blob_cid(b"whatever"),
1734 &missing,
1735 ArtifactKind::Blob,
1736 ImportMode::Copy,
1737 )
1738 .await
1739 .expect_err("missing path must error");
1740 match err {
1741 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1742 assert_eq!(code, ErrorCode::PathNotFound);
1743 }
1744 other => panic!("expected PathNotFound, got {other:?}"),
1745 }
1746
1747 client.shutdown().await.unwrap();
1748 handle.await.unwrap().unwrap();
1749 });
1750 }
1751
1752 #[test]
1755 fn malformed_json_surfaces_as_invalid_request() {
1756 let rt = tokio::runtime::Builder::new_multi_thread()
1757 .enable_all()
1758 .build()
1759 .unwrap();
1760 rt.block_on(async {
1761 let home = tempfile::tempdir().unwrap();
1762 let secret = iroh::SecretKey::from_bytes(&[9u8; 32]);
1763 let (socket, handle) = start_node(home.path(), secret).await;
1764
1765 let mut stream = UnixStream::connect(&socket).await.unwrap();
1766 tokio::io::AsyncWriteExt::write_all(&mut stream, b"this is not json\n")
1767 .await
1768 .unwrap();
1769 let mut buf = String::new();
1770 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1771 .await
1772 .unwrap();
1773 let parsed: CommandResult<serde_json::Value> =
1774 serde_json::from_str(buf.trim()).unwrap();
1775 match parsed {
1776 CommandResult::Error(CommandError { code, message }) => {
1777 assert_eq!(code, ErrorCode::InvalidRequest);
1778 assert!(
1779 message.contains("invalid command JSON"),
1780 "message should name the JSON failure: {message}"
1781 );
1782 }
1783 CommandResult::Okay(_) => panic!("expected error, got ok"),
1784 }
1785
1786 let client = Client::new(socket);
1787 client.shutdown().await.unwrap();
1788 handle.await.unwrap().unwrap();
1789 });
1790 }
1791}