1use std::fmt;
39use std::str::FromStr;
40
41use base64::Engine as _;
42use base64::prelude::BASE64_STANDARD;
43use hyperactor::Actor;
44use hyperactor::ActorRef;
45use hyperactor::Context;
46use hyperactor::Endpoint as _;
47use hyperactor::HandleClient;
48use hyperactor::Handler;
49#[cfg(test)]
50use hyperactor::Instance;
51use hyperactor::PortRef;
52use hyperactor::RefClient;
53use hyperactor::context;
54use serde::Deserialize;
55use serde::Serialize;
56use serde::de::DeserializeOwned;
57use typeuri::Named;
58
59pub trait TokenPeer:
61 Named + Serialize + DeserializeOwned + Send + Sync + fmt::Debug + 'static
62{
63}
64
65impl<T> TokenPeer for T where
66 T: Named + Serialize + DeserializeOwned + Send + Sync + fmt::Debug + 'static
67{
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
72pub struct Options {
73 pub policy: Policy,
75}
76wirevalue::register_type!(Options);
77
78impl Default for Options {
79 fn default() -> Self {
80 Self {
81 policy: Policy::Multi,
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
88pub enum Policy {
89 Multi,
91 Once,
93}
94wirevalue::register_type!(Policy);
95
96#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
98#[serde(bound(serialize = "P: TokenPeer"))]
99#[serde(bound(deserialize = "P: TokenPeer"))]
100pub struct Joined<P: TokenPeer> {
101 pub peer: P,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
107#[serde(bound(serialize = "P: TokenPeer"))]
108#[serde(bound(deserialize = "P: TokenPeer"))]
109pub enum JoinResult<P: TokenPeer> {
110 Joined {
112 peer: P,
114 },
115 Rejected {
117 reason: String,
119 },
120}
121
122#[derive(
124 Clone,
125 Debug,
126 Serialize,
127 Deserialize,
128 Named,
129 Handler,
130 HandleClient,
131 RefClient
132)]
133#[serde(bound(serialize = "C: TokenPeer, J: TokenPeer"))]
134#[serde(bound(deserialize = "C: TokenPeer, J: TokenPeer"))]
135pub struct Join<C: TokenPeer, J: TokenPeer> {
136 pub joiner: J,
138 pub result: PortRef<JoinResult<C>>,
140}
141
142hyperactor::behavior!(RendezvousLike<C, J>, Join<C, J>);
143
144pub fn create<A, C, J>(
150 this: &impl context::Actor<A = A>,
151 creator: C,
152 creator_joined: PortRef<Joined<J>>,
153 options: Options,
154) -> anyhow::Result<Token<C, J>>
155where
156 A: Actor,
157 C: TokenPeer + Clone,
158 J: TokenPeer,
159{
160 let rendezvous = this
161 .instance()
162 .spawn(Rendezvous::new(creator, creator_joined, options));
163 Ok(Token::new(rendezvous.bind::<RendezvousLike<C, J>>()))
164}
165
166#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct Token<C: TokenPeer, J: TokenPeer> {
169 rendezvous: ActorRef<RendezvousLike<C, J>>,
170}
171
172impl<C: TokenPeer, J: TokenPeer> Token<C, J> {
173 pub fn new(rendezvous: ActorRef<RendezvousLike<C, J>>) -> Self {
175 Self { rendezvous }
176 }
177
178 pub fn rendezvous(&self) -> &ActorRef<RendezvousLike<C, J>> {
180 &self.rendezvous
181 }
182
183 pub fn join(
185 &self,
186 cx: &impl hyperactor::context::Actor,
187 joiner: J,
188 result: PortRef<JoinResult<C>>,
189 ) -> anyhow::Result<()> {
190 (&self.rendezvous).post(cx, Join { joiner, result });
191 Ok(())
192 }
193
194 fn payload(&self) -> TokenPayload<C, J> {
195 TokenPayload {
196 creator_type_uri: C::typename().to_string(),
197 joiner_type_uri: J::typename().to_string(),
198 rendezvous_type_uri: <RendezvousLike<C, J> as Named>::typename().to_string(),
199 rendezvous: self.rendezvous.clone(),
200 }
201 }
202
203 fn encode(&self) -> Result<String, serde_json::Error> {
204 Ok(BASE64_STANDARD.encode(serde_json::to_string(&self.payload())?))
205 }
206
207 fn compact_json(&self) -> Result<String, serde_json::Error> {
208 serde_json::to_string(&self.payload())
209 }
210}
211
212impl<C, J> Serialize for Token<C, J>
213where
214 C: TokenPeer,
215 J: TokenPeer,
216{
217 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
218 where
219 S: serde::Serializer,
220 {
221 serializer.serialize_str(&self.encode().map_err(serde::ser::Error::custom)?)
222 }
223}
224
225impl<'de, C, J> Deserialize<'de> for Token<C, J>
226where
227 C: TokenPeer,
228 J: TokenPeer,
229{
230 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231 where
232 D: serde::Deserializer<'de>,
233 {
234 let token = String::deserialize(deserializer)?;
235 let json = BASE64_STANDARD
236 .decode(token)
237 .map_err(serde::de::Error::custom)?;
238 let payload: TokenPayload<C, J> =
239 serde_json::from_slice(&json).map_err(serde::de::Error::custom)?;
240 payload.validate().map_err(serde::de::Error::custom)?;
241 Ok(Self {
242 rendezvous: payload.rendezvous,
243 })
244 }
245}
246
247impl<C, J> FromStr for Token<C, J>
248where
249 C: TokenPeer,
250 J: TokenPeer,
251{
252 type Err = anyhow::Error;
253
254 fn from_str(token: &str) -> Result<Self, Self::Err> {
255 let token = token.split_once('#').map_or(token, |(token, _)| token);
256 let json = BASE64_STANDARD.decode(token)?;
257 let payload: TokenPayload<C, J> = serde_json::from_slice(&json)?;
258 payload.validate()?;
259 Ok(Self {
260 rendezvous: payload.rendezvous,
261 })
262 }
263}
264
265impl<C, J> fmt::Display for Token<C, J>
266where
267 C: TokenPeer,
268 J: TokenPeer,
269{
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 let token = self.encode().map_err(|_| fmt::Error)?;
272 let json = self.compact_json().map_err(|_| fmt::Error)?;
273 write!(f, "{}#{}", token, json)
274 }
275}
276
277#[derive(Debug)]
278struct Rendezvous<C: TokenPeer, J: TokenPeer> {
279 creator: C,
280 creator_joined: PortRef<Joined<J>>,
281 options: Options,
282 joined: bool,
283}
284
285impl<C: TokenPeer, J: TokenPeer> Rendezvous<C, J> {
286 fn new(creator: C, creator_joined: PortRef<Joined<J>>, options: Options) -> Self {
287 Self {
288 creator,
289 creator_joined,
290 options,
291 joined: false,
292 }
293 }
294}
295
296#[async_trait::async_trait]
297impl<C: TokenPeer, J: TokenPeer> Actor for Rendezvous<C, J> {}
298
299#[async_trait::async_trait]
300impl<C: TokenPeer + Clone, J: TokenPeer> Handler<Join<C, J>> for Rendezvous<C, J> {
301 async fn handle(&mut self, cx: &Context<Self>, message: Join<C, J>) -> anyhow::Result<()> {
302 if self.options.policy == Policy::Once && self.joined {
303 message.result.post(
304 cx,
305 JoinResult::Rejected {
306 reason: "token already joined".to_string(),
307 },
308 );
309 return Ok(());
310 }
311
312 (&self.creator_joined).post(
313 cx,
314 Joined {
315 peer: message.joiner,
316 },
317 );
318
319 message.result.post(
320 cx,
321 JoinResult::Joined {
322 peer: self.creator.clone(),
323 },
324 );
325 self.joined = true;
326 Ok(())
327 }
328}
329
330#[derive(Clone, Debug, Serialize, Deserialize)]
331#[serde(bound(serialize = "C: TokenPeer, J: TokenPeer"))]
332#[serde(bound(deserialize = "C: TokenPeer, J: TokenPeer"))]
333struct TokenPayload<C: TokenPeer, J: TokenPeer> {
334 creator_type_uri: String,
335 joiner_type_uri: String,
336 rendezvous_type_uri: String,
337 rendezvous: ActorRef<RendezvousLike<C, J>>,
338}
339
340impl<C: TokenPeer, J: TokenPeer> TokenPayload<C, J> {
341 fn validate(&self) -> Result<(), TokenTypeError> {
342 check_type_uri("creator", &self.creator_type_uri, C::typename())?;
343 check_type_uri("joiner", &self.joiner_type_uri, J::typename())?;
344 check_type_uri(
345 "rendezvous",
346 &self.rendezvous_type_uri,
347 <RendezvousLike<C, J> as Named>::typename(),
348 )
349 }
350}
351
352#[derive(Debug, thiserror::Error)]
353#[error("{kind} type uri mismatch: expected {expected}, got {actual}")]
354struct TokenTypeError {
355 kind: &'static str,
356 expected: &'static str,
357 actual: String,
358}
359
360fn check_type_uri(
361 kind: &'static str,
362 actual: &str,
363 expected: &'static str,
364) -> Result<(), TokenTypeError> {
365 if actual == expected {
366 return Ok(());
367 }
368 Err(TokenTypeError {
369 kind,
370 expected,
371 actual: actual.to_string(),
372 })
373}
374
375#[cfg(test)]
376mod tests {
377 use hyperactor::Actor;
378 use hyperactor::Context;
379 use hyperactor::PortHandle;
380 use hyperactor::Proc;
381 use hyperactor::testing::process_assertion::assert_termination;
382
383 use super::*;
384
385 #[derive(Debug)]
386 #[hyperactor::export]
387 struct RendezvousStub;
388
389 #[async_trait::async_trait]
390 impl Actor for RendezvousStub {}
391
392 #[async_trait::async_trait]
393 impl Handler<Join<CreatorRef, JoinerRef>> for RendezvousStub {
394 async fn handle(
395 &mut self,
396 _cx: &Context<Self>,
397 _message: Join<CreatorRef, JoinerRef>,
398 ) -> anyhow::Result<()> {
399 Ok(())
400 }
401 }
402
403 #[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
404 struct CreatorRef;
405
406 #[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
407 struct JoinerRef;
408
409 #[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
410 struct OtherJoinerRef;
411
412 #[derive(
413 Clone,
414 Debug,
415 Serialize,
416 Deserialize,
417 Named,
418 PartialEq,
419 Eq,
420 PartialOrd,
421 Ord
422 )]
423 enum MultiJoinerRef {
424 One,
425 Two,
426 Three,
427 }
428
429 #[derive(Debug)]
430 struct CreatorActor {
431 creator_joined: PortHandle<Joined<JoinerRef>>,
432 token_out: PortHandle<Token<CreatorRef, JoinerRef>>,
433 }
434
435 #[async_trait::async_trait]
436 impl Actor for CreatorActor {
437 async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
438 let token = create(
439 this,
440 CreatorRef,
441 self.creator_joined.bind(),
442 Options::default(),
443 )?;
444 self.token_out.post(this, token);
445 Ok(())
446 }
447 }
448
449 #[tokio::test]
450 async fn test_create_delivers_join_to_both_sides() {
451 let proc = Proc::isolated();
452 let creator = proc.client("creator");
453 let (creator_joined, mut creator_joined_rx) = creator.open_port::<Joined<JoinerRef>>();
454 let token = create(
455 &creator,
456 CreatorRef,
457 creator_joined.bind(),
458 Options::default(),
459 )
460 .unwrap();
461 let joiner = proc.client("joiner");
462 let (join_result, mut join_result_rx) = joiner.open_port::<JoinResult<CreatorRef>>();
463
464 token.join(&joiner, JoinerRef, join_result.bind()).unwrap();
465
466 assert_eq!(creator_joined_rx.recv().await.unwrap().peer, JoinerRef);
467 assert_eq!(
468 join_result_rx.recv().await.unwrap(),
469 JoinResult::Joined { peer: CreatorRef }
470 );
471 }
472
473 #[tokio::test]
474 async fn test_once_token_rejects_later_joins() {
475 let proc = Proc::isolated();
476 let creator = proc.client("creator");
477 let (creator_joined, mut creator_joined_rx) = creator.open_port::<Joined<JoinerRef>>();
478 let token = create(
479 &creator,
480 CreatorRef,
481 creator_joined.bind(),
482 Options {
483 policy: Policy::Once,
484 },
485 )
486 .unwrap();
487 let joiner = proc.client("joiner");
488 let (first_result, mut first_result_rx) = joiner.open_port::<JoinResult<CreatorRef>>();
489 let (second_result, mut second_result_rx) = joiner.open_port::<JoinResult<CreatorRef>>();
490
491 token.join(&joiner, JoinerRef, first_result.bind()).unwrap();
492 token
493 .join(&joiner, JoinerRef, second_result.bind())
494 .unwrap();
495
496 assert_eq!(creator_joined_rx.recv().await.unwrap().peer, JoinerRef);
497 assert_eq!(
498 first_result_rx.recv().await.unwrap(),
499 JoinResult::Joined { peer: CreatorRef }
500 );
501 assert_eq!(
502 second_result_rx.recv().await.unwrap(),
503 JoinResult::Rejected {
504 reason: "token already joined".to_string()
505 }
506 );
507 }
508
509 #[tokio::test]
510 async fn test_multi_token_accepts_every_join() {
511 let proc = Proc::isolated();
512 let creator = proc.client("creator");
513 let (creator_joined, mut creator_joined_rx) = creator.open_port::<Joined<MultiJoinerRef>>();
514 let token = create(
515 &creator,
516 CreatorRef,
517 creator_joined.bind(),
518 Options {
519 policy: Policy::Multi,
520 },
521 )
522 .unwrap();
523
524 let joiner1 = proc.client("joiner1");
525 let joiner2 = proc.client("joiner2");
526 let joiner3 = proc.client("joiner3");
527
528 let (r1, mut r1_rx) = joiner1.open_port::<JoinResult<CreatorRef>>();
529 let (r2, mut r2_rx) = joiner2.open_port::<JoinResult<CreatorRef>>();
530 let (r3, mut r3_rx) = joiner3.open_port::<JoinResult<CreatorRef>>();
531
532 token
533 .join(&joiner1, MultiJoinerRef::One, r1.bind())
534 .unwrap();
535 token
536 .join(&joiner2, MultiJoinerRef::Two, r2.bind())
537 .unwrap();
538 token
539 .join(&joiner3, MultiJoinerRef::Three, r3.bind())
540 .unwrap();
541
542 let mut joined = vec![
543 creator_joined_rx.recv().await.unwrap().peer,
544 creator_joined_rx.recv().await.unwrap().peer,
545 creator_joined_rx.recv().await.unwrap().peer,
546 ];
547 joined.sort();
548
549 assert_eq!(
550 joined,
551 vec![
552 MultiJoinerRef::One,
553 MultiJoinerRef::Two,
554 MultiJoinerRef::Three,
555 ]
556 );
557
558 assert_eq!(
559 r1_rx.recv().await.unwrap(),
560 JoinResult::Joined { peer: CreatorRef }
561 );
562 assert_eq!(
563 r2_rx.recv().await.unwrap(),
564 JoinResult::Joined { peer: CreatorRef }
565 );
566 assert_eq!(
567 r3_rx.recv().await.unwrap(),
568 JoinResult::Joined { peer: CreatorRef }
569 );
570 }
571
572 #[tokio::test]
573 async fn test_token_serializes_as_single_base64_json_string() {
574 let proc = Proc::isolated();
575 let rendezvous = proc.spawn(RendezvousStub);
576 let token = Token::<CreatorRef, JoinerRef>::new(
577 rendezvous.bind::<RendezvousLike<CreatorRef, JoinerRef>>(),
578 );
579
580 let serialized = serde_json::to_string(&token).unwrap();
581 let encoded: String = serde_json::from_str(&serialized).unwrap();
582 assert!(!encoded.contains('#'));
583
584 let json = BASE64_STANDARD.decode(encoded).unwrap();
585 let payload: serde_json::Value = serde_json::from_slice(&json).unwrap();
586 assert_eq!(
587 payload["creator_type_uri"],
588 <CreatorRef as Named>::typename()
589 );
590 assert_eq!(payload["joiner_type_uri"], <JoinerRef as Named>::typename());
591 assert_eq!(
592 payload["rendezvous_type_uri"],
593 <RendezvousLike<CreatorRef, JoinerRef> as Named>::typename()
594 );
595
596 rendezvous.stop("test").unwrap();
597 rendezvous.await;
598 }
599
600 #[tokio::test]
601 async fn test_token_display_includes_compact_json_suffix() {
602 let proc = Proc::isolated();
603 let rendezvous = proc.spawn(RendezvousStub);
604 let token = Token::<CreatorRef, JoinerRef>::new(
605 rendezvous.bind::<RendezvousLike<CreatorRef, JoinerRef>>(),
606 );
607
608 let display = token.to_string();
609 let (encoded, json) = display.split_once('#').unwrap();
610 assert_eq!(encoded, token.encode().unwrap());
611 assert_eq!(json, token.compact_json().unwrap());
612
613 rendezvous.stop("test").unwrap();
614 rendezvous.await;
615 }
616
617 #[tokio::test]
618 async fn test_token_from_str_accepts_display_suffix() {
619 let proc = Proc::isolated();
620 let rendezvous = proc.spawn(RendezvousStub);
621 let token = Token::<CreatorRef, JoinerRef>::new(
622 rendezvous.bind::<RendezvousLike<CreatorRef, JoinerRef>>(),
623 );
624
625 let encoded = token.encode().unwrap();
626 let parsed_encoded: Token<CreatorRef, JoinerRef> = encoded.parse().unwrap();
627 let parsed_display: Token<CreatorRef, JoinerRef> = token.to_string().parse().unwrap();
628 let parsed_ignored_suffix: Token<CreatorRef, JoinerRef> =
629 format!("{encoded}#not json").parse().unwrap();
630
631 assert_eq!(parsed_encoded, token);
632 assert_eq!(parsed_display, token);
633 assert_eq!(parsed_ignored_suffix, token);
634
635 rendezvous.stop("test").unwrap();
636 rendezvous.await;
637 }
638
639 #[tokio::test]
640 async fn test_token_rejects_incompatible_type_parameters() {
641 let proc = Proc::isolated();
642 let rendezvous = proc.spawn(RendezvousStub);
643 let token = Token::<CreatorRef, JoinerRef>::new(
644 rendezvous.bind::<RendezvousLike<CreatorRef, JoinerRef>>(),
645 );
646 let serialized = serde_json::to_string(&token).unwrap();
647
648 let result = serde_json::from_str::<Token<CreatorRef, OtherJoinerRef>>(&serialized);
649
650 assert!(result.is_err());
651
652 rendezvous.stop("test").unwrap();
653 rendezvous.await;
654 }
655
656 #[test]
657 fn test_token_from_str_rejects_corrupt_base64() {
658 let result: Result<Token<CreatorRef, JoinerRef>, _> = "%%%".parse();
659 assert!(result.is_err());
660 }
661
662 #[test]
663 fn test_token_from_str_rejects_invalid_json() {
664 let encoded = BASE64_STANDARD.encode(b"not json at all");
665 let result: Result<Token<CreatorRef, JoinerRef>, _> = encoded.parse();
666 assert!(result.is_err());
667 }
668
669 #[tokio::test]
670 async fn test_token_from_str_rejects_mismatched_type_uris() {
671 let proc = Proc::isolated();
672 let rendezvous = proc.spawn(RendezvousStub);
673 let token = Token::<CreatorRef, JoinerRef>::new(
674 rendezvous.bind::<RendezvousLike<CreatorRef, JoinerRef>>(),
675 );
676
677 let encoded = token.encode().unwrap();
678 let result: Result<Token<CreatorRef, OtherJoinerRef>, _> = encoded.parse();
679 assert!(result.is_err());
680
681 rendezvous.stop("test").unwrap();
682 rendezvous.await;
683 }
684
685 #[tokio::test]
686 async fn test_join_after_creator_stops_exits() {
687 assert_termination(
688 || async {
689 let proc = Proc::isolated();
690 let inst = proc.client("inst");
691 let (creator_joined, _creator_joined_rx) = inst.open_port::<Joined<JoinerRef>>();
692 let (token_out, mut token_out_rx) =
693 inst.open_port::<Token<CreatorRef, JoinerRef>>();
694
695 let creator_handle = proc.spawn(CreatorActor {
696 creator_joined,
697 token_out,
698 });
699
700 let token = token_out_rx.recv().await.unwrap();
701
702 creator_handle.stop("test").unwrap();
705 tokio::time::timeout(std::time::Duration::from_secs(5), creator_handle)
706 .await
707 .unwrap();
708
709 let joiner = proc.client("joiner");
710 let (result, _result_rx) = joiner.open_port::<JoinResult<CreatorRef>>();
711 token.join(&joiner, JoinerRef, result.bind()).unwrap();
712
713 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
714 },
715 1,
716 )
717 .await
718 .unwrap();
719 }
720}