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