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 started_at_unix: ctx.started_at_unix,
946 seeded: radicle_artifact_core::protocol::SeededStats {
947 count,
948 bytes_logical,
949 },
950 connections,
951 traffic,
952 relay,
953 pkarr: Some(radicle_artifact_core::protocol::PkarrStats {
954 uri: ctx.endpoint_id.to_pkarr_uri(),
955 relays: ctx.pkarr_urls.clone(),
956 }),
957 warnings: radicle_artifact_core::protocol::Warnings { relay_unreachable },
958 })
959}
960
961fn relay_stats(endpoint: &iroh::Endpoint) -> radicle_artifact_core::protocol::RelayStats {
967 use iroh::Watcher;
968
969 let report = endpoint.net_report().get();
970 let mut latency_ms: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
972 if let Some(r) = &report {
973 for (_probe, url, dur) in r.relay_latency.iter() {
974 let ms = dur.as_millis() as u64;
975 latency_ms
976 .entry(url.to_string())
977 .and_modify(|m| *m = (*m).min(ms))
978 .or_insert(ms);
979 }
980 }
981
982 let relays = endpoint
983 .home_relay_status()
984 .get()
985 .into_iter()
986 .map(|s| {
987 let url = s.url().to_string();
988 radicle_artifact_core::protocol::RelayHealth {
989 latency_ms: latency_ms.get(&url).copied(),
990 connected: s.is_connected(),
991 last_error: s.last_error().map(|e| e.to_string()),
992 url,
993 }
994 })
995 .collect();
996
997 radicle_artifact_core::protocol::RelayStats {
998 relays,
999 preferred: report
1000 .as_ref()
1001 .and_then(|r| r.preferred_relay.as_ref().map(|u| u.to_string())),
1002 udp_v4: report.as_ref().map(|r| r.udp_v4).unwrap_or(false),
1003 udp_v6: report.as_ref().map(|r| r.udp_v6).unwrap_or(false),
1004 }
1005}
1006
1007fn ok_json<T: serde::Serialize>(v: T) -> String {
1008 serde_json::to_string(&CommandResult::Okay(v))
1009 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1010}
1011
1012fn err_json<T>(code: ErrorCode, message: String) -> String
1013where
1014 T: serde::Serialize,
1015{
1016 serde_json::to_string(&CommandResult::<T>::Error(CommandError { code, message }))
1017 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1018}
1019
1020fn err_from_share<T>(e: ShareError) -> String
1021where
1022 T: serde::Serialize,
1023{
1024 let code = share_error_to_code(&e);
1025 err_json::<T>(code, e.to_string())
1026}
1027
1028fn share_error_to_code(e: &ShareError) -> ErrorCode {
1029 match e {
1030 ShareError::CidMismatch { .. } => ErrorCode::CidMismatch,
1031 ShareError::Io(_) => ErrorCode::Io,
1032 ShareError::Iroh(_) => ErrorCode::Iroh,
1033 _ => ErrorCode::Internal,
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use std::fs;
1040
1041 use cid::multihash::Multihash;
1042
1043 use super::*;
1044 use radicle_artifact_core::cid::{
1045 self as cid_utils, ArtifactKind, HASH_CODE_BLAKE3, RAW_CODEC,
1046 };
1047
1048 fn fake_blob_cid(data: &[u8]) -> Cid {
1051 let digest = blake3::hash(data);
1052 let mh = Multihash::<64>::wrap(HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
1053 Cid::from(cid::Cid::new_v1(RAW_CODEC, mh))
1054 }
1055
1056 fn rid_a() -> RepoId {
1057 RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap()
1058 }
1059
1060 fn release_a() -> Oid {
1061 Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap()
1062 }
1063
1064 async fn start_node(
1067 home: &Path,
1068 secret: iroh::SecretKey,
1069 ) -> (PathBuf, tokio::task::JoinHandle<Result<(), NodeError>>) {
1070 let home_path = home.to_path_buf();
1071 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1072 let socket = home.join(ARTIFACTS_DIR).join("control.sock");
1073 for _ in 0..200 {
1074 if socket.exists() {
1075 break;
1076 }
1077 tokio::time::sleep(Duration::from_millis(50)).await;
1078 }
1079 assert!(socket.exists(), "control socket never appeared");
1080 (socket, handle)
1081 }
1082
1083 #[test]
1087 fn node_round_trip() {
1088 let rt = tokio::runtime::Builder::new_multi_thread()
1089 .enable_all()
1090 .build()
1091 .unwrap();
1092 rt.block_on(async {
1093 let home = tempfile::tempdir().unwrap();
1094
1095 let blob_path = home.path().join("payload.bin");
1098 let payload = b"hello rad-artifact";
1099 fs::write(&blob_path, payload).unwrap();
1100 let real_cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1101 let rid = rid_a();
1102
1103 let secret = iroh::SecretKey::from_bytes(&[1u8; 32]);
1105 let expected_endpoint_id = EndpointId::from(secret.public());
1106
1107 let home_path = home.path().to_path_buf();
1110 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1111
1112 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1115 for _ in 0..200 {
1116 if socket.exists() {
1117 break;
1118 }
1119 tokio::time::sleep(Duration::from_millis(50)).await;
1120 }
1121 assert!(socket.exists(), "control socket never appeared");
1122
1123 let client = Client::new(socket.clone());
1124
1125 assert!(client.is_running().await);
1127
1128 let status = client.status().await.unwrap();
1130 assert_eq!(status.endpoint_id, expected_endpoint_id);
1131 assert_eq!(status.seeded.count, 0);
1132
1133 let receipt = client
1135 .seed(
1136 rid,
1137 release_a(),
1138 real_cid,
1139 &blob_path,
1140 ArtifactKind::Blob,
1141 ImportMode::Copy,
1142 )
1143 .await
1144 .unwrap();
1145 assert!(receipt.was_new);
1146 assert_eq!(receipt.endpoint_id, expected_endpoint_id);
1147 assert_eq!(receipt.bytes, payload.len() as u64);
1148
1149 let receipt2 = client
1151 .seed(
1152 rid,
1153 release_a(),
1154 real_cid,
1155 &blob_path,
1156 ArtifactKind::Blob,
1157 ImportMode::Copy,
1158 )
1159 .await
1160 .unwrap();
1161 assert!(!receipt2.was_new);
1162
1163 let bad_path = home.path().join("tampered.bin");
1165 fs::write(&bad_path, b"different bytes").unwrap();
1166 let bad_cid = fake_blob_cid(b"not the right preimage");
1167 let err = client
1168 .seed(
1169 rid,
1170 release_a(),
1171 bad_cid,
1172 &bad_path,
1173 ArtifactKind::Blob,
1174 ImportMode::Copy,
1175 )
1176 .await
1177 .expect_err("CID mismatch must error");
1178 match err {
1179 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1180 assert_eq!(code, ErrorCode::CidMismatch);
1181 }
1182 other => panic!("expected CidMismatch error, got {other:?}"),
1183 }
1184
1185 assert!(client.is_seeding(rid, real_cid).await.unwrap());
1187
1188 let entries = client.list_seeded(rid).await.unwrap();
1190 assert_eq!(entries.len(), 1);
1191 assert_eq!(entries[0].cid, real_cid);
1192 assert_eq!(entries[0].bytes, payload.len() as u64);
1193 assert_eq!(entries[0].releases, vec![release_a()]);
1194
1195 let status = client.status().await.unwrap();
1197 assert_eq!(status.seeded.count, 1);
1198 assert_eq!(status.seeded.bytes_logical, payload.len() as u64);
1199
1200 let r1 = client
1202 .unseed(rid, Some(release_a()), real_cid)
1203 .await
1204 .unwrap();
1205 assert!(r1.was_removed);
1206 let r2 = client
1207 .unseed(rid, Some(release_a()), real_cid)
1208 .await
1209 .unwrap();
1210 assert!(!r2.was_removed);
1211 assert!(!client.is_seeding(rid, real_cid).await.unwrap());
1212
1213 client.shutdown().await.unwrap();
1215 tokio::time::timeout(Duration::from_secs(10), node_handle)
1216 .await
1217 .expect("node did not exit within 10s")
1218 .expect("join error")
1219 .expect("node returned error");
1220
1221 assert!(!socket.exists());
1223 });
1224 }
1225
1226 #[test]
1230 fn invalid_typed_fields_surface_as_invalid_request() {
1231 let rt = tokio::runtime::Builder::new_multi_thread()
1232 .enable_all()
1233 .build()
1234 .unwrap();
1235 rt.block_on(async {
1236 let home = tempfile::tempdir().unwrap();
1237 let secret = iroh::SecretKey::from_bytes(&[4u8; 32]);
1238 let home_path = home.path().to_path_buf();
1239 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1240
1241 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1242 for _ in 0..200 {
1243 if socket.exists() {
1244 break;
1245 }
1246 tokio::time::sleep(Duration::from_millis(50)).await;
1247 }
1248 assert!(socket.exists());
1249
1250 for (frame, expected_field) in [
1253 (
1254 br#"{"command":"listSeeded","rid":"not-a-real-rid"}"#.as_slice(),
1255 "rid",
1256 ),
1257 (
1258 br#"{"command":"isSeeding","rid":"rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip","cid":"not-a-real-cid"}"#.as_slice(),
1259 "cid",
1260 ),
1261 ] {
1262 let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap();
1263 tokio::io::AsyncWriteExt::write_all(&mut stream, frame)
1264 .await
1265 .unwrap();
1266 tokio::io::AsyncWriteExt::write_all(&mut stream, b"\n")
1267 .await
1268 .unwrap();
1269 let mut buf = String::new();
1270 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1271 .await
1272 .unwrap();
1273 let parsed: CommandResult<serde_json::Value> =
1274 serde_json::from_str(buf.trim()).unwrap();
1275 match parsed {
1276 CommandResult::Error(CommandError { code, message }) => {
1277 assert_eq!(code, ErrorCode::InvalidRequest);
1278 assert!(
1279 message.contains(expected_field),
1280 "message should name {expected_field}: {message}"
1281 );
1282 }
1283 CommandResult::Okay(_) => panic!("expected error, got ok"),
1284 }
1285 }
1286
1287 let client = Client::new(socket);
1289 client.shutdown().await.unwrap();
1290 node_handle.await.unwrap().unwrap();
1291 });
1292 }
1293
1294 #[test]
1297 fn double_start_errors() {
1298 let rt = tokio::runtime::Builder::new_multi_thread()
1299 .enable_all()
1300 .build()
1301 .unwrap();
1302 rt.block_on(async {
1303 let home = tempfile::tempdir().unwrap();
1304 let secret = iroh::SecretKey::from_bytes(&[2u8; 32]);
1305 let home_path = home.path().to_path_buf();
1306 let first = tokio::spawn(async move { run(&home_path, secret).await });
1307
1308 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1309 for _ in 0..200 {
1310 if socket.exists() {
1311 break;
1312 }
1313 tokio::time::sleep(Duration::from_millis(50)).await;
1314 }
1315 assert!(socket.exists());
1316
1317 let secret2 = iroh::SecretKey::from_bytes(&[3u8; 32]);
1319 let err = run(home.path(), secret2).await.expect_err("must fail");
1320 assert!(matches!(err, NodeError::AlreadyRunning(_)));
1321
1322 let client = Client::new(socket);
1324 client.shutdown().await.unwrap();
1325 first.await.unwrap().unwrap();
1326 });
1327 }
1328
1329 async fn oneshot<T: serde::de::DeserializeOwned>(
1331 socket: &Path,
1332 cmd: &Command,
1333 ) -> CommandResult<T> {
1334 let mut stream = UnixStream::connect(socket).await.unwrap();
1335 let mut line = serde_json::to_string(cmd).unwrap();
1336 line.push('\n');
1337 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1338 .await
1339 .unwrap();
1340 let mut buf = String::new();
1341 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1342 .await
1343 .unwrap();
1344 serde_json::from_str(buf.trim()).unwrap()
1345 }
1346
1347 async fn streaming<T: serde::de::DeserializeOwned>(
1349 socket: &Path,
1350 cmd: &Command,
1351 ) -> (usize, StreamEvent<T>) {
1352 let mut stream = UnixStream::connect(socket).await.unwrap();
1353 let mut line = serde_json::to_string(cmd).unwrap();
1354 line.push('\n');
1355 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1356 .await
1357 .unwrap();
1358 let mut buf = String::new();
1359 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1360 .await
1361 .unwrap();
1362 let mut progress = 0usize;
1363 let mut terminal = None;
1364 for l in buf.lines().filter(|l| !l.trim().is_empty()) {
1365 match serde_json::from_str::<StreamEvent<T>>(l).unwrap() {
1366 StreamEvent::Progress(_) => progress += 1,
1367 term => terminal = Some(term),
1368 }
1369 }
1370 (progress, terminal.expect("a terminal frame"))
1371 }
1372
1373 #[test]
1376 fn has_and_export_round_trip() {
1377 let rt = tokio::runtime::Builder::new_multi_thread()
1378 .enable_all()
1379 .build()
1380 .unwrap();
1381 rt.block_on(async {
1382 let home = tempfile::tempdir().unwrap();
1383 let payload = b"hello has/export";
1384 let blob_path = home.path().join("payload.bin");
1385 fs::write(&blob_path, payload).unwrap();
1386 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1387 let rid = rid_a();
1388
1389 let secret = iroh::SecretKey::from_bytes(&[5u8; 32]);
1390 let home_path = home.path().to_path_buf();
1391 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1392
1393 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1394 for _ in 0..200 {
1395 if socket.exists() {
1396 break;
1397 }
1398 tokio::time::sleep(Duration::from_millis(50)).await;
1399 }
1400 assert!(socket.exists(), "control socket never appeared");
1401
1402 let client = Client::new(socket.clone());
1404 client
1405 .seed(
1406 rid,
1407 release_a(),
1408 cid,
1409 &blob_path,
1410 ArtifactKind::Blob,
1411 ImportMode::Copy,
1412 )
1413 .await
1414 .unwrap();
1415
1416 match oneshot::<HasResult>(&socket, &Command::Has { cid }).await {
1418 CommandResult::Okay(h) => {
1419 assert!(h.present);
1420 assert!(h.complete);
1421 assert_eq!(h.bytes, payload.len() as u64);
1422 }
1423 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1424 }
1425
1426 let unknown = fake_blob_cid(b"never stored");
1428 match oneshot::<HasResult>(&socket, &Command::Has { cid: unknown }).await {
1429 CommandResult::Okay(h) => {
1430 assert!(!h.present);
1431 assert!(!h.complete);
1432 }
1433 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1434 }
1435
1436 let dest = home.path().join("exported.bin");
1438 let (_progress, term) = streaming::<ExportReceipt>(
1439 &socket,
1440 &Command::Export {
1441 cid,
1442 dest: dest.clone(),
1443 },
1444 )
1445 .await;
1446 match term {
1447 StreamEvent::Okay(r) => {
1448 assert_eq!(r.bytes, payload.len() as u64);
1449 assert_eq!(r.dest, dest);
1450 }
1451 other => panic!("expected okay, got {other:?}"),
1452 }
1453 assert_eq!(fs::read(&dest).unwrap(), payload);
1454
1455 let (_p, term) = streaming::<ExportReceipt>(
1457 &socket,
1458 &Command::Export {
1459 cid: unknown,
1460 dest: home.path().join("nope.bin"),
1461 },
1462 )
1463 .await;
1464 match term {
1465 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NotLocal),
1466 other => panic!("expected NotLocal error, got {other:?}"),
1467 }
1468
1469 client.shutdown().await.unwrap();
1470 node_handle.await.unwrap().unwrap();
1471 });
1472 }
1473
1474 #[test]
1481 fn fetch_and_download_fast_path_and_no_locations() {
1482 let rt = tokio::runtime::Builder::new_multi_thread()
1483 .enable_all()
1484 .build()
1485 .unwrap();
1486 rt.block_on(async {
1487 let home = tempfile::tempdir().unwrap();
1488 let payload = b"hello fetch fast-path";
1489 let blob_path = home.path().join("payload.bin");
1490 fs::write(&blob_path, payload).unwrap();
1491 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1492 let rid = rid_a();
1493
1494 let secret = iroh::SecretKey::from_bytes(&[6u8; 32]);
1495 let home_path = home.path().to_path_buf();
1496 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1497
1498 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1499 for _ in 0..200 {
1500 if socket.exists() {
1501 break;
1502 }
1503 tokio::time::sleep(Duration::from_millis(50)).await;
1504 }
1505 assert!(socket.exists(), "control socket never appeared");
1506
1507 let client = Client::new(socket.clone());
1509 client
1510 .seed(
1511 rid,
1512 release_a(),
1513 cid,
1514 &blob_path,
1515 ArtifactKind::Blob,
1516 ImportMode::Copy,
1517 )
1518 .await
1519 .unwrap();
1520
1521 let (_p, term) = streaming::<FetchReceipt>(
1524 &socket,
1525 &Command::Fetch {
1526 rid,
1527 cid,
1528 locations: vec![],
1529 seed: None,
1530 },
1531 )
1532 .await;
1533 match term {
1534 StreamEvent::Okay(r) => {
1535 assert!(r.from_cache);
1536 assert!(!r.seeded);
1537 assert_eq!(r.bytes, payload.len() as u64);
1538 }
1539 other => panic!("expected okay, got {other:?}"),
1540 }
1541
1542 let dest = home.path().join("downloaded.bin");
1544 let (_p, term) = streaming::<DownloadReceipt>(
1545 &socket,
1546 &Command::Download {
1547 rid,
1548 cid,
1549 locations: vec![],
1550 dest: dest.clone(),
1551 seed: None,
1552 },
1553 )
1554 .await;
1555 match term {
1556 StreamEvent::Okay(r) => {
1557 assert!(r.from_cache);
1558 assert!(!r.seeded);
1559 assert_eq!(r.bytes, payload.len() as u64);
1560 assert_eq!(r.dest, dest);
1561 }
1562 other => panic!("expected okay, got {other:?}"),
1563 }
1564 assert_eq!(fs::read(&dest).unwrap(), payload);
1565
1566 let rid2 = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
1569 let (_p, term) = streaming::<FetchReceipt>(
1570 &socket,
1571 &Command::Fetch {
1572 rid: rid2,
1573 cid,
1574 locations: vec![],
1575 seed: Some(release_a()),
1576 },
1577 )
1578 .await;
1579 match term {
1580 StreamEvent::Okay(r) => {
1581 assert!(r.from_cache);
1582 assert!(r.seeded);
1583 }
1584 other => panic!("expected okay, got {other:?}"),
1585 }
1586 assert!(client.is_seeding(rid2, cid).await.unwrap());
1587
1588 let unknown = fake_blob_cid(b"absent");
1590 let (_p, term) = streaming::<FetchReceipt>(
1591 &socket,
1592 &Command::Fetch {
1593 rid,
1594 cid: unknown,
1595 locations: vec![],
1596 seed: None,
1597 },
1598 )
1599 .await;
1600 match term {
1601 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NoLocations),
1602 other => panic!("expected NoLocations error, got {other:?}"),
1603 }
1604
1605 client.shutdown().await.unwrap();
1606 node_handle.await.unwrap().unwrap();
1607 });
1608 }
1609
1610 #[test]
1615 fn run_stream_aborts_on_client_disconnect() {
1616 use std::sync::atomic::AtomicBool;
1617
1618 let rt = tokio::runtime::Builder::new_multi_thread()
1619 .enable_all()
1620 .build()
1621 .unwrap();
1622 rt.block_on(async {
1623 let (client_end, server_end) = tokio::io::duplex(64);
1626 let (mut srv_read, mut srv_write) = tokio::io::split(server_end);
1627
1628 struct DropFlag(Arc<AtomicBool>);
1631 impl Drop for DropFlag {
1632 fn drop(&mut self) {
1633 self.0.store(true, Ordering::SeqCst);
1634 }
1635 }
1636 let aborted = Arc::new(AtomicBool::new(false));
1637 let completed = Arc::new(AtomicBool::new(false));
1638 let aborted_op = aborted.clone();
1639 let completed_op = completed.clone();
1640
1641 let op = async move |_tx: mpsc::UnboundedSender<FetchProgress>| {
1642 let _guard = DropFlag(aborted_op);
1643 std::future::pending::<()>().await;
1645 completed_op.store(true, Ordering::SeqCst);
1646 Ok::<(), (ErrorCode, String)>(())
1647 };
1648
1649 let server =
1650 tokio::spawn(
1651 async move { run_stream::<()>(&mut srv_read, &mut srv_write, op).await },
1652 );
1653
1654 tokio::time::sleep(Duration::from_millis(50)).await;
1657 drop(client_end); let res = tokio::time::timeout(Duration::from_secs(5), server)
1660 .await
1661 .expect("run_stream did not return after disconnect")
1662 .expect("join error");
1663 assert!(res.is_ok());
1664 assert!(
1665 aborted.load(Ordering::SeqCst),
1666 "op future was not dropped on disconnect"
1667 );
1668 assert!(
1669 !completed.load(Ordering::SeqCst),
1670 "op should have been aborted, not completed"
1671 );
1672 });
1673 }
1674
1675 #[test]
1678 fn stale_socket_is_reclaimed() {
1679 let rt = tokio::runtime::Builder::new_multi_thread()
1680 .enable_all()
1681 .build()
1682 .unwrap();
1683 rt.block_on(async {
1684 let home = tempfile::tempdir().unwrap();
1685 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1686 fs::create_dir_all(socket.parent().unwrap()).unwrap();
1687 drop(UnixListener::bind(&socket).unwrap());
1689 assert!(socket.exists());
1690
1691 let secret = iroh::SecretKey::from_bytes(&[7u8; 32]);
1692 let home_path = home.path().to_path_buf();
1693 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1694
1695 let client = Client::new(socket.clone());
1698 let mut ready = false;
1699 for _ in 0..200 {
1700 if client.is_running().await {
1701 ready = true;
1702 break;
1703 }
1704 tokio::time::sleep(Duration::from_millis(50)).await;
1705 }
1706 assert!(ready, "node never came up after reclaiming stale socket");
1707
1708 client.shutdown().await.unwrap();
1709 handle.await.unwrap().unwrap();
1710 });
1711 }
1712
1713 #[test]
1716 fn seed_missing_path_errors() {
1717 let rt = tokio::runtime::Builder::new_multi_thread()
1718 .enable_all()
1719 .build()
1720 .unwrap();
1721 rt.block_on(async {
1722 let home = tempfile::tempdir().unwrap();
1723 let secret = iroh::SecretKey::from_bytes(&[8u8; 32]);
1724 let (socket, handle) = start_node(home.path(), secret).await;
1725
1726 let client = Client::new(socket.clone());
1727 let missing = home.path().join("does-not-exist.bin");
1728 let err = client
1729 .seed(
1730 rid_a(),
1731 release_a(),
1732 fake_blob_cid(b"whatever"),
1733 &missing,
1734 ArtifactKind::Blob,
1735 ImportMode::Copy,
1736 )
1737 .await
1738 .expect_err("missing path must error");
1739 match err {
1740 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1741 assert_eq!(code, ErrorCode::PathNotFound);
1742 }
1743 other => panic!("expected PathNotFound, got {other:?}"),
1744 }
1745
1746 client.shutdown().await.unwrap();
1747 handle.await.unwrap().unwrap();
1748 });
1749 }
1750
1751 #[test]
1754 fn malformed_json_surfaces_as_invalid_request() {
1755 let rt = tokio::runtime::Builder::new_multi_thread()
1756 .enable_all()
1757 .build()
1758 .unwrap();
1759 rt.block_on(async {
1760 let home = tempfile::tempdir().unwrap();
1761 let secret = iroh::SecretKey::from_bytes(&[9u8; 32]);
1762 let (socket, handle) = start_node(home.path(), secret).await;
1763
1764 let mut stream = UnixStream::connect(&socket).await.unwrap();
1765 tokio::io::AsyncWriteExt::write_all(&mut stream, b"this is not json\n")
1766 .await
1767 .unwrap();
1768 let mut buf = String::new();
1769 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1770 .await
1771 .unwrap();
1772 let parsed: CommandResult<serde_json::Value> =
1773 serde_json::from_str(buf.trim()).unwrap();
1774 match parsed {
1775 CommandResult::Error(CommandError { code, message }) => {
1776 assert_eq!(code, ErrorCode::InvalidRequest);
1777 assert!(
1778 message.contains("invalid command JSON"),
1779 "message should name the JSON failure: {message}"
1780 );
1781 }
1782 CommandResult::Okay(_) => panic!("expected error, got ok"),
1783 }
1784
1785 let client = Client::new(socket);
1786 client.shutdown().await.unwrap();
1787 handle.await.unwrap().unwrap();
1788 });
1789 }
1790}