1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::path::Path;
5use std::process::Stdio;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, anyhow, bail};
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::process::{Child, ChildStdin, ChildStdout, Command};
14use tokio::sync::{mpsc, watch};
15
16use hel::hel_config::harness_authentication_marker;
17use hel::hel_credentials::{
18 CredentialSnapshot, CredentialSyncAction, CredentialSyncHandle, CredentialSyncOutcome,
19 CredentialSyncResult, CredentialSyncTarget, SYNC_INTERVAL, SyncAction, SyncTrigger, enqueue,
20 profiles_with_targets, read_credential_file, reconcile, validate_credential_payload,
21 write_credential_file,
22};
23use hel::hel_elicitation::ElicitationResponse;
24use hel::hel_targets::CommandSpec;
25use hel::hel_worker::{
26 MAX_FRAME_BYTES, RELAY_EVENT_GENESIS_DIGEST, RELAY_MIN_PROTOCOL_VERSION,
27 RELAY_PROTOCOL_VERSION, RelayCommand, RelayCursor, RelayErrorCode, RelayEvent,
28 RelayOperationalState, RelayProtocolError, RelayRequest, RelayRequestEnvelope,
29 RelayResponseBody, RelayResponseEnvelope, RelayResponsePayload, RelayVersionRange,
30 ReviewerRequest, validate_relay_event,
31};
32use hel::hel_worker_launch::ReviewerLaunchConfig;
33
34const RELAY_RPC_TIMEOUT: Duration = Duration::from_secs(15);
35const RELAY_SLOW_OPERATION_WARNING: Duration = Duration::from_secs(5);
36const RELAY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(300);
40const RELAY_HISTORY_TIMEOUT: Duration = Duration::from_secs(900);
44const RELAY_ACKNOWLEDGE_TIMEOUT: Duration = Duration::from_secs(300);
48const REVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300);
51const REVIEW_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(660);
55const RELAY_PROXY_DETACH_GRACE: Duration = Duration::from_millis(500);
56const RELAY_PROXY_REAP_POLL: Duration = Duration::from_millis(10);
57
58async fn drain_proxy_stderr(
62 errors: tokio::process::ChildStderr,
63 purpose: String,
64 session_id: String,
65) {
66 let mut lines = BufReader::new(errors).lines();
67 loop {
68 match lines.next_line().await {
69 Ok(Some(line)) if line.trim().is_empty() => continue,
70 Ok(Some(line)) => {
71 tracing::warn!(%session_id, %purpose, %line, "relay proxy stderr")
72 }
73 Ok(None) => return,
74 Err(error) => {
75 tracing::warn!(%session_id, %purpose, %error, "read relay proxy stderr");
76 return;
77 }
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
83pub struct RelayAttachment {
84 pub state: RelayOperationalState,
85 pub events: Vec<RelayEvent>,
86 pub through_ordinal: u64,
87 pub through_digest: String,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
92pub struct StartedReviewer {
93 pub native_session_id: Option<String>,
95 pub config_options: Vec<agent_client_protocol::schema::v1::SessionConfigOption>,
98 pub reused: bool,
100 pub state: RelayOperationalState,
101}
102
103#[derive(Debug, Clone)]
107pub struct RelayEventPage {
108 pub events: Vec<RelayEvent>,
109 pub through_ordinal: u64,
110 pub through_digest: String,
111}
112
113#[derive(Debug, Clone)]
114pub struct RelayCatchUp {
115 pub state: RelayOperationalState,
116 pub frontier: RelayCursor,
117 pub first_page: RelayEventPage,
118}
119
120#[derive(Debug)]
121pub struct RelayRejected(pub RelayProtocolError);
122
123impl std::fmt::Display for RelayRejected {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 write!(
126 formatter,
127 "relay rejected request ({:?}): {}",
128 self.0.code, self.0.message
129 )
130 }
131}
132
133impl std::error::Error for RelayRejected {}
134
135impl RelayRejected {
136 pub fn is_desynchronized(&self) -> bool {
137 self.0.code == RelayErrorCode::Desynchronized
138 }
139
140 pub fn is_retryable(&self) -> bool {
143 self.0.retryable
144 }
145}
146
147#[derive(Debug)]
154pub struct RelayTransportDead {
155 message: String,
156 handshake_failed: bool,
157}
158
159impl std::fmt::Display for RelayTransportDead {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 formatter.write_str(&self.message)
162 }
163}
164
165impl std::error::Error for RelayTransportDead {}
166
167impl RelayTransportDead {
168 pub fn new(message: impl Into<String>) -> Self {
169 Self {
170 message: message.into(),
171 handshake_failed: false,
172 }
173 }
174
175 fn from_io(error: std::io::Error, kind: ExchangeKind) -> Self {
178 Self::during_exchange(error.to_string(), kind)
179 }
180
181 fn during_exchange(message: impl Into<String>, kind: ExchangeKind) -> Self {
182 Self {
183 message: message.into(),
184 handshake_failed: kind == ExchangeKind::Handshake,
185 }
186 }
187
188 pub fn marks(error: &anyhow::Error) -> bool {
190 error.downcast_ref::<Self>().is_some()
191 }
192
193 pub fn marks_failed_handshake(error: &anyhow::Error) -> bool {
198 error
199 .downcast_ref::<Self>()
200 .is_some_and(|failure| failure.handshake_failed)
201 }
202}
203
204#[derive(Clone, Copy, PartialEq, Eq)]
211enum ExchangeKind {
212 Handshake,
213 Call,
214}
215
216pub struct RelayClient {
222 child: Option<Child>,
223 input: Option<ChildStdin>,
224 output: BufReader<ChildStdout>,
225 request_timeout: Duration,
226 abandoned: Option<String>,
229 next_request: u64,
230 connection_nonce: u64,
231 protocol_version: u32,
232 session_id: String,
233 relay_version: String,
234 worker_build: Option<String>,
237 latest_ordinal: u64,
238 latest_digest: String,
239}
240
241impl RelayClient {
242 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
243 Self::connect_with_timeouts(
244 spec,
245 expected_session_id,
246 RELAY_RPC_TIMEOUT,
247 RELAY_HANDSHAKE_TIMEOUT,
248 )
249 .await
250 }
251
252 #[cfg(all(test, unix))]
253 async fn connect_with_timeout(
254 spec: &CommandSpec,
255 expected_session_id: &str,
256 request_timeout: Duration,
257 ) -> Result<Self> {
258 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
259 .await
260 }
261
262 async fn connect_with_timeouts(
263 spec: &CommandSpec,
264 expected_session_id: &str,
265 request_timeout: Duration,
266 handshake_timeout: Duration,
267 ) -> Result<Self> {
268 let mut child = Command::new(&spec.program)
269 .args(&spec.args)
270 .envs(&spec.env)
271 .stdin(Stdio::piped())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped())
277 .kill_on_drop(true)
278 .spawn()
279 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
280 .map_err(|error| {
281 tracing::warn!(
282 session_id = %expected_session_id,
283 operation = "connect",
284 purpose = %spec.purpose,
285 error = %error,
286 "could not start relay proxy"
287 );
288 error
289 })?;
290 if let Some(errors) = child.stderr.take() {
291 let purpose = spec.purpose.clone();
292 let session_id = expected_session_id.to_owned();
293 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id));
294 }
295 let input = child
296 .stdin
297 .take()
298 .context("relay proxy stdin unavailable")
299 .map_err(|error| {
300 tracing::warn!(
301 session_id = %expected_session_id,
302 operation = "connect",
303 purpose = %spec.purpose,
304 error = %error,
305 "relay proxy did not provide stdin"
306 );
307 error
308 })?;
309 let output = child
310 .stdout
311 .take()
312 .context("relay proxy stdout unavailable")
313 .map_err(|error| {
314 tracing::warn!(
315 session_id = %expected_session_id,
316 operation = "connect",
317 purpose = %spec.purpose,
318 error = %error,
319 "relay proxy did not provide stdout"
320 );
321 error
322 })?;
323 let mut nonce_bytes = [0_u8; 8];
324 getrandom::fill(&mut nonce_bytes).map_err(|error| {
325 let error = anyhow!("generate relay request nonce: {error}");
326 tracing::warn!(
327 session_id = %expected_session_id,
328 operation = "connect",
329 error = %error,
330 "could not initialize relay request nonce"
331 );
332 error
333 })?;
334 let mut client = Self {
335 child: Some(child),
336 input: Some(input),
337 output: BufReader::new(output),
338 request_timeout,
339 abandoned: None,
340 next_request: 1,
341 connection_nonce: u64::from_le_bytes(nonce_bytes),
342 protocol_version: RELAY_PROTOCOL_VERSION,
343 session_id: expected_session_id.to_owned(),
347 relay_version: String::new(),
348 worker_build: None,
349 latest_ordinal: 0,
350 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
351 };
352 let response = client
353 .call_hello(
354 RelayRequest::Hello {
355 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
356 supported: RelayVersionRange::CURRENT,
357 },
358 handshake_timeout,
359 )
360 .await?;
361 let RelayResponsePayload::Hello {
362 negotiated,
363 relay_version,
364 session_id,
365 worker_build,
366 } = response
367 else {
368 let error = anyhow!("relay returned an unexpected hello response");
369 log_relay_client_failure(&client, "hello", "relay-hello", &error);
370 return Err(error);
371 };
372 if session_id != expected_session_id {
373 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
374 log_relay_client_failure(&client, "hello", "relay-hello", &error);
375 return Err(error);
376 }
377 if !RelayVersionRange::CURRENT.contains(negotiated) {
378 let error = anyhow!(
379 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
380 RELAY_MIN_PROTOCOL_VERSION,
381 RELAY_PROTOCOL_VERSION
382 );
383 log_relay_client_failure(&client, "hello", "relay-hello", &error);
384 return Err(error);
385 }
386 client.protocol_version = negotiated;
387 client.session_id = session_id;
388 client.relay_version = relay_version;
389 client.worker_build = worker_build;
390 Ok(client)
391 }
392
393 pub fn session_id(&self) -> &str {
394 &self.session_id
395 }
396
397 pub const fn supports_project_memory_sync(&self) -> bool {
398 self.protocol_version >= 4
399 }
400
401 pub fn relay_version(&self) -> &str {
402 &self.relay_version
403 }
404
405 pub fn worker_build(&self) -> Option<&str> {
409 self.worker_build.as_deref()
410 }
411
412 pub fn protocol_version(&self) -> u32 {
413 self.protocol_version
414 }
415
416 pub fn latest_ordinal(&self) -> u64 {
417 self.latest_ordinal
418 }
419
420 pub fn latest_digest(&self) -> &str {
421 &self.latest_digest
422 }
423
424 pub async fn attach(
425 &mut self,
426 after_ordinal: u64,
427 after_digest: impl Into<String>,
428 ) -> Result<RelayAttachment> {
429 let after_digest = after_digest.into();
430 match self
431 .call_with_timeout(
432 RelayRequest::Attach {
433 after_ordinal,
434 after_digest: after_digest.clone(),
435 },
436 RELAY_HISTORY_TIMEOUT,
437 )
438 .await?
439 {
440 RelayResponsePayload::Attached {
441 state,
442 events,
443 through_ordinal,
444 through_digest,
445 } => {
446 let mut cursor = RelayCursor {
447 ordinal: after_ordinal,
448 digest: after_digest,
449 };
450 for event in &events {
451 validate_relay_event(cursor.ordinal, &cursor.digest, event)
452 .context("verify relay attachment event chain")?;
453 cursor.ordinal = event.ordinal;
454 cursor.digest.clone_from(&event.digest);
455 }
456 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
457 bail!("relay attachment frontier does not match its event chain");
458 }
459 self.latest_ordinal = state.latest_ordinal;
460 self.latest_digest = state.latest_digest.clone();
461 Ok(RelayAttachment {
462 state,
463 events,
464 through_ordinal,
465 through_digest,
466 })
467 }
468 _ => bail!("relay returned an unexpected attach response"),
469 }
470 }
471
472 pub async fn begin_catch_up(
477 &mut self,
478 after_ordinal: u64,
479 after_digest: impl Into<String>,
480 ) -> Result<RelayCatchUp> {
481 let after_digest = after_digest.into();
482 let first = self.attach(after_ordinal, after_digest.clone()).await?;
483 let frontier = RelayCursor {
484 ordinal: first.state.latest_ordinal,
485 digest: first.state.latest_digest.clone(),
486 };
487 let previous = RelayCursor {
488 ordinal: after_ordinal,
489 digest: after_digest,
490 };
491 let state = first.state.clone();
492 let first_page = clip_catch_up_page(first, &previous, &frontier)?;
493 Ok(RelayCatchUp {
494 state,
495 frontier,
496 first_page,
497 })
498 }
499
500 pub async fn next_catch_up_page(
504 &mut self,
505 previous: &RelayCursor,
506 frontier: &RelayCursor,
507 ) -> Result<RelayEventPage> {
508 if previous.ordinal >= frontier.ordinal {
509 bail!("relay catch-up is already at its fixed frontier");
510 }
511 let attachment = self
512 .attach(previous.ordinal, previous.digest.clone())
513 .await?;
514 clip_catch_up_page(attachment, previous, frontier)
515 }
516
517 pub async fn acknowledge(
518 &mut self,
519 through_ordinal: u64,
520 through_digest: impl Into<String>,
521 ) -> Result<RelayCursor> {
522 match self
523 .call_with_timeout(
524 RelayRequest::Acknowledge {
525 through_ordinal,
526 through_digest: through_digest.into(),
527 },
528 RELAY_ACKNOWLEDGE_TIMEOUT,
529 )
530 .await?
531 {
532 RelayResponsePayload::Acknowledged {
533 through_ordinal,
534 through_digest,
535 } => Ok(RelayCursor {
536 ordinal: through_ordinal,
537 digest: through_digest,
538 }),
539 _ => bail!("relay returned an unexpected acknowledgement response"),
540 }
541 }
542
543 pub async fn status(&mut self) -> Result<RelayOperationalState> {
544 match self.call(RelayRequest::Status).await? {
545 RelayResponsePayload::Status(status) => {
546 self.latest_ordinal = status.latest_ordinal;
547 self.latest_digest = status.latest_digest.clone();
548 Ok(status)
549 }
550 _ => bail!("relay returned an unexpected status response"),
551 }
552 }
553
554 pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
557 credential_snapshot(self.call(RelayRequest::CredentialState).await?)
558 }
559
560 pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
563 match self.call(RelayRequest::ReadCredentials).await? {
564 RelayResponsePayload::Credentials { data } => BASE64
565 .decode(data.as_bytes())
566 .context("decode relay credential payload"),
567 _ => bail!("relay returned an unexpected credential response"),
568 }
569 }
570
571 pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
574 credential_snapshot(
575 self.call(RelayRequest::InstallCredentials {
576 data: BASE64.encode(bytes),
577 })
578 .await?,
579 )
580 }
581
582 pub async fn github_token_state(
583 &mut self,
584 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
585 github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
586 }
587
588 pub async fn install_github_token(
589 &mut self,
590 token: &str,
591 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
592 github_token_snapshot(
593 self.call(RelayRequest::InstallGithubToken {
594 data: BASE64.encode(token.as_bytes()),
595 })
596 .await?,
597 )
598 }
599
600 pub async fn remove_github_token(
601 &mut self,
602 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
603 github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
604 }
605
606 pub async fn skills_state(&mut self) -> Result<hel::hel_skills::SkillsSyncState> {
609 skills_sync_state(self.call(RelayRequest::SkillsState).await?)
610 }
611
612 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
615 let request = RelayRequest::InstallPromptContext { text };
616 if !request.supported_at(self.protocol_version) {
617 bail!(
618 "hidden prompt context requires relay protocol {}; this session negotiated {}",
619 request.minimum_protocol(),
620 self.protocol_version
621 );
622 }
623 match self.call(request).await? {
624 RelayResponsePayload::PromptContextInstalled => Ok(()),
625 _ => bail!("relay returned an unexpected prompt-context response"),
626 }
627 }
628
629 pub async fn project_memory_snapshot(
630 &mut self,
631 ) -> Result<(
632 hel::hel_project_memory::ProjectMemorySnapshot,
633 hel::hel_project_memory::ProjectMemorySnapshot,
634 )> {
635 let request = RelayRequest::ProjectMemorySnapshot;
636 if !request.supported_at(self.protocol_version) {
637 bail!(
638 "project memory synchronization requires relay protocol {}; this session negotiated {}",
639 request.minimum_protocol(),
640 self.protocol_version
641 );
642 }
643 match self.call(request).await? {
644 RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
645 Ok((baseline, replica))
646 }
647 _ => bail!("relay returned an unexpected project-memory response"),
648 }
649 }
650
651 pub async fn install_project_memory_snapshot(
652 &mut self,
653 snapshot: hel::hel_project_memory::ProjectMemorySnapshot,
654 ) -> Result<()> {
655 let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
656 if !request.supported_at(self.protocol_version) {
657 bail!(
658 "project memory synchronization requires relay protocol {}; this session negotiated {}",
659 request.minimum_protocol(),
660 self.protocol_version
661 );
662 }
663 match self.call(request).await? {
664 RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
665 _ => bail!("relay returned an unexpected project-memory install response"),
666 }
667 }
668
669 pub async fn install_skills(
673 &mut self,
674 archive_bytes: &[u8],
675 ) -> Result<hel::hel_skills::SkillsSyncState> {
676 skills_sync_state(
677 self.call(RelayRequest::InstallSkills {
678 data: BASE64.encode(archive_bytes),
679 })
680 .await?,
681 )
682 }
683
684 pub async fn submit(
685 &mut self,
686 command_id: impl Into<String>,
687 command: RelayCommand,
688 ) -> Result<u64> {
689 let command_id = command_id.into();
690 match self
691 .call(RelayRequest::Submit {
692 command_id: command_id.clone(),
693 command,
694 })
695 .await?
696 {
697 RelayResponsePayload::Accepted {
698 command_id: accepted_id,
699 ordinal,
700 } if accepted_id == command_id => Ok(ordinal),
701 RelayResponsePayload::Accepted {
702 command_id: accepted_id,
703 ..
704 } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
705 _ => bail!("relay returned an unexpected command response"),
706 }
707 }
708
709 pub async fn start_reviewer(
716 &mut self,
717 role: Option<&str>,
718 config: ReviewerLaunchConfig,
719 ) -> Result<StartedReviewer> {
720 let request = self.reviewer_request(
721 role,
722 ReviewerRequest::Start {
723 config: Box::new(config),
724 },
725 )?;
726 match self
727 .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
728 .await?
729 {
730 RelayResponsePayload::ReviewerStarted {
731 native_session_id,
732 config_options,
733 reused,
734 state,
735 } => Ok(StartedReviewer {
736 native_session_id,
737 config_options,
738 reused,
739 state: *state,
740 }),
741 _ => bail!("relay returned an unexpected reviewer start response"),
742 }
743 }
744
745 pub async fn attach_reviewer(
748 &mut self,
749 role: Option<&str>,
750 after_ordinal: u64,
751 after_digest: impl Into<String>,
752 ) -> Result<RelayAttachment> {
753 let after_digest = after_digest.into();
754 let request = self.reviewer_request(
755 role,
756 ReviewerRequest::Attach {
757 after_ordinal,
758 after_digest: after_digest.clone(),
759 },
760 )?;
761 let payload = self
762 .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
763 .await?;
764 let RelayResponsePayload::Attached {
765 state,
766 events,
767 through_ordinal,
768 through_digest,
769 } = payload
770 else {
771 bail!("relay returned an unexpected reviewer attach response");
772 };
773 let mut cursor = RelayCursor {
776 ordinal: after_ordinal,
777 digest: after_digest,
778 };
779 for event in &events {
780 validate_relay_event(cursor.ordinal, &cursor.digest, event)
781 .context("verify reviewer attachment event chain")?;
782 cursor.ordinal = event.ordinal;
783 cursor.digest.clone_from(&event.digest);
784 }
785 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
786 bail!("reviewer attachment frontier does not match its event chain");
787 }
788 Ok(RelayAttachment {
789 state,
790 events,
791 through_ordinal,
792 through_digest,
793 })
794 }
795
796 pub async fn acknowledge_reviewer(
799 &mut self,
800 role: Option<&str>,
801 through_ordinal: u64,
802 through_digest: impl Into<String>,
803 ) -> Result<RelayCursor> {
804 let request = self.reviewer_request(
805 role,
806 ReviewerRequest::Acknowledge {
807 through_ordinal,
808 through_digest: through_digest.into(),
809 },
810 )?;
811 match self
812 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
813 .await?
814 {
815 RelayResponsePayload::Acknowledged {
816 through_ordinal,
817 through_digest,
818 } => Ok(RelayCursor {
819 ordinal: through_ordinal,
820 digest: through_digest,
821 }),
822 _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
823 }
824 }
825
826 pub async fn submit_to_reviewer(
828 &mut self,
829 role: Option<&str>,
830 command_id: impl Into<String>,
831 command: RelayCommand,
832 ) -> Result<u64> {
833 let command_id = command_id.into();
834 let request = self.reviewer_request(
835 role,
836 ReviewerRequest::Submit {
837 command_id: command_id.clone(),
838 command,
839 },
840 )?;
841 match self.call(request).await? {
842 RelayResponsePayload::Accepted {
843 command_id: accepted_id,
844 ordinal,
845 } if accepted_id == command_id => Ok(ordinal),
846 RelayResponsePayload::Accepted {
847 command_id: accepted_id,
848 ..
849 } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
850 _ => bail!("relay returned an unexpected reviewer command response"),
851 }
852 }
853
854 pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
855 let request = self.reviewer_request(role, ReviewerRequest::Status)?;
856 match self.call(request).await? {
857 RelayResponsePayload::Status(status) => Ok(status),
858 _ => bail!("relay returned an unexpected reviewer status response"),
859 }
860 }
861
862 pub async fn respond_to_reviewer(
864 &mut self,
865 role: Option<&str>,
866 elicitation_id: String,
867 response: ElicitationResponse,
868 ) -> Result<()> {
869 let request = self.reviewer_request(
870 role,
871 ReviewerRequest::RespondElicitation {
872 elicitation_id: elicitation_id.clone(),
873 response,
874 },
875 )?;
876 match self.call(request).await? {
877 RelayResponsePayload::ElicitationResolved {
878 elicitation_id: resolved,
879 } if resolved == elicitation_id => Ok(()),
880 RelayResponsePayload::ElicitationResolved {
881 elicitation_id: resolved,
882 } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
883 _ => bail!("relay returned an unexpected reviewer elicitation response"),
884 }
885 }
886
887 pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
890 let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
891 match self
892 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
893 .await?
894 {
895 RelayResponsePayload::ReviewerPaused => Ok(()),
896 _ => bail!("relay returned an unexpected reviewer pause response"),
897 }
898 }
899
900 pub async fn capture_review_delta(
903 &mut self,
904 role: Option<&str>,
905 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
906 ) -> Result<Vec<hel::hel_worker::RepoDelta>> {
907 let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
908 match self
909 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
910 .await?
911 {
912 RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
913 _ => bail!("relay returned an unexpected review capture response"),
914 }
915 }
916
917 pub async fn advance_review_baseline(
920 &mut self,
921 role: Option<&str>,
922 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
923 ) -> Result<()> {
924 let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
925 match self
926 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
927 .await?
928 {
929 RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
930 _ => bail!("relay returned an unexpected review baseline response"),
931 }
932 }
933
934 pub async fn analyze_review_delta(
937 &mut self,
938 role: Option<&str>,
939 repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
940 ) -> Result<String> {
941 let request =
942 self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
943 match self
944 .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
945 .await?
946 {
947 RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
948 _ => bail!("relay returned an unexpected review analysis response"),
949 }
950 }
951
952 pub async fn take_lane_dispatches(
955 &mut self,
956 ) -> Result<Vec<hel::hel_review::lanes::ReviewSubagentRequest>> {
957 let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
958 match self.call(request).await? {
959 RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
960 _ => bail!("relay returned an unexpected lane dispatch response"),
961 }
962 }
963
964 fn reviewer_request(
967 &self,
968 role: Option<&str>,
969 request: ReviewerRequest,
970 ) -> Result<RelayRequest> {
971 let request = RelayRequest::Reviewer {
972 role: role.map(str::to_owned),
973 request,
974 };
975 if !request.supported_at(self.protocol_version) {
976 bail!(
977 "a second opinion requires relay protocol {}; this session negotiated {}",
978 request.minimum_protocol(),
979 self.protocol_version
980 );
981 }
982 Ok(request)
983 }
984
985 pub async fn respond_elicitation(
988 &mut self,
989 elicitation_id: String,
990 response: ElicitationResponse,
991 ) -> Result<()> {
992 let request = RelayRequest::RespondElicitation {
993 elicitation_id: elicitation_id.clone(),
994 response,
995 };
996 if !request.supported_at(self.protocol_version) {
997 bail!(
998 "elicitation responses require relay protocol {}; this session negotiated {}",
999 request.minimum_protocol(),
1000 self.protocol_version
1001 );
1002 }
1003 match self.call(request).await? {
1004 RelayResponsePayload::ElicitationResolved {
1005 elicitation_id: resolved,
1006 } if resolved == elicitation_id => Ok(()),
1007 RelayResponsePayload::ElicitationResolved {
1008 elicitation_id: resolved,
1009 } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1010 _ => bail!("relay returned an unexpected elicitation response"),
1011 }
1012 }
1013
1014 pub async fn detach(mut self) -> Result<()> {
1015 self.input
1016 .take()
1017 .expect("connected relay owns proxy stdin")
1018 .shutdown()
1019 .await
1020 .context("close relay proxy stdin")?;
1021 let mut child = self.child.take().expect("connected relay owns proxy child");
1022 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1023 Ok(status) => {
1024 status.context("wait for relay proxy")?;
1025 }
1026 Err(_) => {
1027 if let Err(error) = child.start_kill().context("stop relay proxy") {
1028 tracing::warn!(
1029 session_id = %self.session_id,
1030 operation = "detach",
1031 %error,
1032 "could not stop relay proxy after detach timeout"
1033 );
1034 return Err(error);
1035 }
1036 if let Err(error) = child.wait().await {
1037 tracing::warn!(
1038 session_id = %self.session_id,
1039 operation = "detach",
1040 %error,
1041 "could not reap relay proxy after stopping it"
1042 );
1043 }
1044 }
1045 }
1046 Ok(())
1047 }
1048
1049 async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1050 self.call_with_timeout(request, self.request_timeout).await
1051 }
1052
1053 async fn call_with_timeout(
1054 &mut self,
1055 request: RelayRequest,
1056 timeout: Duration,
1057 ) -> Result<RelayResponsePayload> {
1058 let operation = request.method_name();
1059 let request_id = self.request_id();
1060 let envelope = RelayRequestEnvelope {
1061 request_id: request_id.clone(),
1062 protocol_version: self.protocol_version,
1063 request,
1064 };
1065 let line = match self
1066 .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1067 .await
1068 {
1069 Ok(line) => line,
1070 Err(error) => {
1071 log_relay_client_failure(self, operation, &request_id, &error);
1072 return Err(error);
1073 }
1074 };
1075 let result = decode_relay_response(&line, &request_id, self.protocol_version)
1076 .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1077 if let Err(error) = &result {
1078 log_relay_client_failure(self, operation, &request_id, error);
1079 }
1080 result
1081 }
1082
1083 async fn call_hello(
1084 &mut self,
1085 request: RelayRequest,
1086 timeout: Duration,
1087 ) -> Result<RelayResponsePayload> {
1088 let operation = request.method_name();
1089 let request_id = self.request_id();
1090 let envelope = RelayRequestEnvelope {
1091 request_id: request_id.clone(),
1092 protocol_version: RELAY_PROTOCOL_VERSION,
1093 request,
1094 };
1095 let line = match self
1096 .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1097 .await
1098 {
1099 Ok(line) => line,
1100 Err(error) => {
1101 log_relay_client_failure(self, operation, &request_id, &error);
1102 return Err(error);
1103 }
1104 };
1105 let result = decode_relay_hello_response(&line, &request_id);
1106 if let Err(error) = &result {
1107 log_relay_client_failure(self, operation, &request_id, error);
1108 }
1109 result
1110 }
1111
1112 async fn exchange(
1122 &mut self,
1123 envelope: &RelayRequestEnvelope,
1124 operation: &str,
1125 timeout: Duration,
1126 kind: ExchangeKind,
1127 ) -> Result<String> {
1128 if let Some(reason) = &self.abandoned {
1129 bail!("{reason}");
1130 }
1131 let mut frame = serde_json::to_vec(envelope)?;
1132 if frame.len() > MAX_FRAME_BYTES {
1133 bail!("relay {operation} request frame is too large");
1134 }
1135 frame.push(b'\n');
1136 let session_id = self.session_id.clone();
1137 let exchanged = tokio::time::timeout(timeout, async {
1138 self.input
1139 .as_mut()
1140 .expect("connected relay owns proxy stdin")
1141 .write_all(&frame)
1142 .await
1143 .map_err(|error| RelayTransportDead::from_io(error, kind))
1144 .with_context(|| format!("write relay {operation} request"))?;
1145 self.input
1146 .as_mut()
1147 .expect("connected relay owns proxy stdin")
1148 .flush()
1149 .await
1150 .map_err(|error| RelayTransportDead::from_io(error, kind))
1151 .with_context(|| format!("flush relay {operation} request"))?;
1152 let response = read_bounded_frame(&mut self.output, kind);
1153 tokio::pin!(response);
1154 let response = tokio::select! {
1155 response = &mut response => response,
1156 () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1157 tracing::warn!(
1158 %session_id,
1159 %operation,
1160 warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1161 timeout_seconds = timeout.as_secs_f64(),
1162 "relay operation is still waiting for its response"
1163 );
1164 response.await
1165 }
1166 };
1167 response
1168 .with_context(|| format!("read relay {operation} response"))?
1169 .ok_or_else(|| {
1170 anyhow::Error::new(RelayTransportDead::during_exchange(
1171 format!("relay proxy disconnected during {operation}"),
1172 kind,
1173 ))
1174 })
1175 })
1176 .await;
1177 match exchanged {
1178 Ok(line) => line,
1179 Err(_elapsed) => {
1180 let seconds = timeout.as_secs_f64();
1181 tracing::warn!(
1182 %session_id,
1183 %operation,
1184 timeout_seconds = seconds,
1185 "relay operation timed out; abandoning its sequential connection"
1186 );
1187 self.abandoned = Some(format!(
1188 "relay connection abandoned after {operation} timed out after {seconds} seconds"
1189 ));
1190 let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1191 Err(anyhow!(timed_out))
1192 }
1193 }
1194 }
1195
1196 fn request_id(&mut self) -> String {
1197 let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1198 self.next_request = self.next_request.wrapping_add(1);
1199 id
1200 }
1201}
1202
1203fn log_relay_client_failure(
1208 client: &RelayClient,
1209 operation: &str,
1210 request_id: &str,
1211 error: &anyhow::Error,
1212) {
1213 let rejection = error.chain().find_map(|cause| {
1214 cause
1215 .downcast_ref::<RelayRejected>()
1216 .map(|rejected| &rejected.0)
1217 });
1218 let transport_dead = RelayTransportDead::marks(error);
1219 match rejection {
1220 Some(rejection) => tracing::warn!(
1221 session_id = %client.session_id,
1222 relay_version = %client.relay_version,
1223 %operation,
1224 %request_id,
1225 relay_error_code = ?rejection.code,
1226 relay_retryable = rejection.retryable,
1227 transport_dead,
1228 error = %error,
1229 "relay request rejected"
1230 ),
1231 None => tracing::warn!(
1232 session_id = %client.session_id,
1233 relay_version = %client.relay_version,
1234 %operation,
1235 %request_id,
1236 transport_dead,
1237 error = %error,
1238 "relay request failed"
1239 ),
1240 }
1241}
1242
1243impl Drop for RelayClient {
1244 fn drop(&mut self) {
1245 drop(self.input.take());
1250 let Some(child) = self.child.take() else {
1251 return;
1252 };
1253 let session_id = self.session_id.clone();
1254 if let Err(error) = std::thread::Builder::new()
1255 .name("hel-relay-reaper".into())
1256 .spawn(move || reap_dropped_relay_proxy(child, session_id))
1257 {
1258 tracing::warn!(
1259 session_id = %self.session_id,
1260 %error,
1261 "could not start dropped relay proxy reaper"
1262 );
1263 }
1264 }
1265}
1266
1267fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1270 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1271 loop {
1272 match child.try_wait() {
1273 Ok(Some(status)) => {
1274 if !status.success() {
1275 tracing::warn!(
1276 %session_id,
1277 %status,
1278 "dropped relay proxy exited unsuccessfully"
1279 );
1280 }
1281 return;
1282 }
1283 Ok(None) if Instant::now() < deadline => {
1284 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1285 }
1286 Ok(None) => break,
1287 Err(error) => {
1288 tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1289 return;
1290 }
1291 }
1292 }
1293
1294 if let Err(error) = child.start_kill()
1295 && error.kind() != std::io::ErrorKind::NotFound
1296 {
1297 tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1298 return;
1299 }
1300 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1301 loop {
1302 match child.try_wait() {
1303 Ok(Some(_)) => return,
1304 Ok(None) if Instant::now() < deadline => {
1305 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1306 }
1307 Ok(None) => {
1308 tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1309 return;
1310 }
1311 Err(error) => {
1312 tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1313 return;
1314 }
1315 }
1316 }
1317}
1318
1319fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1320 match payload {
1321 RelayResponsePayload::CredentialState {
1322 present,
1323 fingerprint,
1324 freshness_epoch_ms,
1325 } => Ok(CredentialSnapshot {
1326 present,
1327 fingerprint,
1328 freshness_epoch_ms,
1329 }),
1330 _ => bail!("relay returned an unexpected credential state response"),
1331 }
1332}
1333
1334fn skills_sync_state(payload: RelayResponsePayload) -> Result<hel::hel_skills::SkillsSyncState> {
1335 match payload {
1336 RelayResponsePayload::SkillsState {
1337 present,
1338 fingerprint,
1339 } => Ok(hel::hel_skills::SkillsSyncState {
1340 present,
1341 fingerprint,
1342 }),
1343 _ => bail!("relay returned an unexpected skills state response"),
1344 }
1345}
1346
1347fn github_token_snapshot(
1348 payload: RelayResponsePayload,
1349) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
1350 match payload {
1351 RelayResponsePayload::GithubTokenState {
1352 present,
1353 fingerprint,
1354 } => Ok(hel::hel_credentials::GithubTokenSnapshot {
1355 present,
1356 fingerprint,
1357 }),
1358 _ => bail!("relay returned an unexpected GitHub token state response"),
1359 }
1360}
1361
1362async fn read_bounded_frame(
1363 reader: &mut (impl AsyncBufRead + Unpin),
1364 kind: ExchangeKind,
1365) -> Result<Option<String>> {
1366 read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1367}
1368
1369async fn read_bounded_frame_with_limit(
1370 reader: &mut (impl AsyncBufRead + Unpin),
1371 maximum_bytes: usize,
1372 kind: ExchangeKind,
1373) -> Result<Option<String>> {
1374 let mut frame = Vec::new();
1375 loop {
1376 let available = reader
1380 .fill_buf()
1381 .await
1382 .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1383 if available.is_empty() {
1384 if frame.is_empty() {
1385 return Ok(None);
1386 }
1387 return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1388 "relay proxy disconnected in the middle of a response frame",
1389 kind,
1390 )));
1391 }
1392 let newline = available.iter().position(|byte| *byte == b'\n');
1393 let consumed = newline.map_or(available.len(), |position| position + 1);
1394 let payload = newline.map_or(available, |position| &available[..position]);
1395 if frame.len().saturating_add(payload.len()) > maximum_bytes {
1396 bail!("relay response frame is too large");
1397 }
1398 frame.extend_from_slice(payload);
1399 reader.consume(consumed);
1400 if newline.is_some() {
1401 if frame.last() == Some(&b'\r') {
1402 frame.pop();
1403 }
1404 return String::from_utf8(frame)
1405 .context("relay response is not UTF-8")
1406 .map(Some);
1407 }
1408 }
1409}
1410
1411fn clip_catch_up_page(
1412 page: RelayAttachment,
1413 previous: &RelayCursor,
1414 frontier: &RelayCursor,
1415) -> Result<RelayEventPage> {
1416 if previous.ordinal > frontier.ordinal {
1417 bail!("relay catch-up starts beyond its fixed frontier");
1418 }
1419 if previous.ordinal == frontier.ordinal {
1420 if previous != frontier {
1421 bail!("relay catch-up cursor digest differs from its fixed frontier");
1422 }
1423 if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1424 bail!("relay attachment advanced beyond its advertised frontier");
1425 }
1426 return Ok(RelayEventPage {
1427 events: Vec::new(),
1428 through_ordinal: previous.ordinal,
1429 through_digest: previous.digest.clone(),
1430 });
1431 }
1432 if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1433 bail!("relay catch-up page did not advance");
1434 }
1435 if page.through_ordinal <= frontier.ordinal {
1436 let through = RelayCursor {
1437 ordinal: page.through_ordinal,
1438 digest: page.through_digest.clone(),
1439 };
1440 if through.ordinal == frontier.ordinal && through != *frontier {
1441 bail!("relay catch-up page digest differs from its fixed frontier");
1442 }
1443 return Ok(RelayEventPage {
1444 events: page.events,
1445 through_ordinal: through.ordinal,
1446 through_digest: through.digest,
1447 });
1448 }
1449
1450 let events = page
1451 .events
1452 .into_iter()
1453 .take_while(|event| event.ordinal <= frontier.ordinal)
1454 .collect::<Vec<_>>();
1455 let reached = events
1456 .last()
1457 .map(|event| RelayCursor {
1458 ordinal: event.ordinal,
1459 digest: event.digest.clone(),
1460 })
1461 .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1462 if reached != *frontier {
1463 bail!("relay catch-up page does not contain its fixed frontier");
1464 }
1465 Ok(RelayEventPage {
1466 events,
1467 through_ordinal: reached.ordinal,
1468 through_digest: reached.digest,
1469 })
1470}
1471
1472fn decode_relay_response(
1473 line: &str,
1474 request_id: &str,
1475 protocol: u32,
1476) -> Result<RelayResponsePayload> {
1477 let response: RelayResponseEnvelope =
1478 serde_json::from_str(line).context("decode relay response")?;
1479 if response.request_id != request_id {
1480 bail!(
1481 "relay response ID mismatch: expected {request_id}, got {}",
1482 response.request_id
1483 );
1484 }
1485 if response.protocol_version != protocol {
1486 bail!(
1487 "relay response protocol mismatch: expected {protocol}, got {}",
1488 response.protocol_version
1489 );
1490 }
1491 match response.body {
1492 RelayResponseBody::Ok { payload } => Ok(payload),
1493 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1494 }
1495}
1496
1497fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1498 let response: RelayResponseEnvelope =
1499 serde_json::from_str(line).context("decode relay hello response")?;
1500 if response.request_id != request_id {
1501 bail!(
1502 "relay response ID mismatch: expected {request_id}, got {}",
1503 response.request_id
1504 );
1505 }
1506 match response.body {
1507 RelayResponseBody::Ok {
1508 payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1509 } => {
1510 if response.protocol_version != negotiated {
1511 bail!(
1512 "relay hello envelope uses protocol {}, negotiated {negotiated}",
1513 response.protocol_version
1514 );
1515 }
1516 Ok(payload)
1517 }
1518 RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1519 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1520 }
1521}
1522
1523pub struct CredentialSyncCoordinator {
1524 handle: CredentialSyncHandle,
1525 results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1526}
1527
1528impl CredentialSyncCoordinator {
1529 pub fn spawn() -> Self {
1530 let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1531 let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1532 let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1533 let (results_tx, results_rx) = mpsc::unbounded_channel();
1534 tokio::spawn(async move {
1535 let mut tick = tokio::time::interval_at(
1536 tokio::time::Instant::now() + SYNC_INTERVAL,
1537 SYNC_INTERVAL,
1538 );
1539 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1540 let mut busy = BTreeSet::<String>::new();
1543 let mut queue = VecDeque::<SyncTrigger>::new();
1544 loop {
1545 tokio::select! {
1546 _ = tick.tick() => {
1547 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1548 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1549 }
1550 }
1551 changed = targets_rx.changed() => {
1552 if changed.is_err() { break; }
1553 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1554 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1555 }
1556 }
1557 trigger = triggers_rx.recv() => {
1558 let Some(trigger) = trigger else { break };
1559 enqueue(&mut queue, trigger);
1560 }
1561 completed = completed_rx.recv() => {
1562 let Some(result) = completed else { break };
1563 busy.remove(&result.profile_id);
1564 if result.trigger.is_some()
1565 || result.failure.is_some()
1566 || !result.outcomes.is_empty()
1567 {
1568 let profile_id = result.profile_id.clone();
1569 if results_tx.send(result).is_err() {
1570 tracing::debug!(
1571 %profile_id,
1572 operation = "credential_sync_result",
1573 "credential sync result receiver was already closed"
1574 );
1575 }
1576 }
1577 }
1578 }
1579
1580 let mut deferred = VecDeque::new();
1581 while let Some(trigger) = queue.pop_front() {
1582 if busy.contains(&trigger.profile_id) {
1583 deferred.push_back(trigger);
1584 continue;
1585 }
1586 let targets: Vec<_> = targets_rx
1587 .borrow()
1588 .iter()
1589 .filter(|target| target.profile_id == trigger.profile_id)
1590 .cloned()
1591 .collect();
1592 if targets.is_empty() {
1593 if trigger.cause.is_some() {
1594 let profile_id = trigger.profile_id.clone();
1595 if results_tx
1596 .send(CredentialSyncResult {
1597 profile_id: trigger.profile_id,
1598 trigger: trigger.cause,
1599 failure: None,
1600 outcomes: Vec::new(),
1601 })
1602 .is_err()
1603 {
1604 tracing::debug!(
1605 %profile_id,
1606 operation = "credential_sync_result",
1607 "credential sync result receiver was already closed"
1608 );
1609 }
1610 }
1611 continue;
1612 }
1613 busy.insert(trigger.profile_id.clone());
1614 let completed_tx = completed_tx.clone();
1615 let handle = tokio::runtime::Handle::current();
1616 tokio::spawn(async move {
1619 let joined = tokio::task::spawn_blocking(move || {
1620 handle.block_on(reconcile_profile(&targets))
1621 })
1622 .await;
1623 let (failure, outcomes) = match joined {
1624 Ok(outcomes) => (None, outcomes),
1625 Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1626 };
1627 let profile_id = trigger.profile_id.clone();
1628 if completed_tx
1629 .send(CredentialSyncResult {
1630 profile_id: trigger.profile_id,
1631 trigger: trigger.cause,
1632 failure,
1633 outcomes,
1634 })
1635 .is_err()
1636 {
1637 tracing::debug!(
1638 %profile_id,
1639 operation = "credential_sync_completion",
1640 "credential sync coordinator stopped before receiving completion"
1641 );
1642 }
1643 });
1644 }
1645 queue = deferred;
1646 }
1647 });
1648 Self {
1649 handle: CredentialSyncHandle {
1650 targets: Arc::new(targets_tx),
1651 triggers: triggers_tx,
1652 },
1653 results: results_rx,
1654 }
1655 }
1656
1657 pub fn handle(&self) -> CredentialSyncHandle {
1658 self.handle.clone()
1659 }
1660
1661 pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1662 self.results.try_recv().ok()
1663 }
1664
1665 pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1671 self.results.recv().await
1672 }
1673}
1674
1675async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1682 let github_token = targets
1683 .iter()
1684 .any(|target| target.sync_github_token)
1685 .then(crate::hel_controller::controller_github_token)
1686 .flatten();
1687 let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
1688 for pass in 0..2 {
1689 let mut pulled = false;
1690 for target in targets {
1691 match reconcile_session(target, github_token.as_deref()).await {
1692 Ok(actions) if actions.is_empty() => {}
1693 Ok(actions) => {
1694 pulled |= actions.contains(&CredentialSyncAction::Pulled);
1695 outcomes.insert(
1696 target.session_id.clone(),
1697 CredentialSyncOutcome {
1698 session_id: target.session_id.clone(),
1699 outcome: Ok(actions),
1700 },
1701 );
1702 }
1703 Err(error) => {
1704 tracing::warn!(
1705 session_id = %target.session_id,
1706 profile_id = %target.profile_id,
1707 pass = pass + 1,
1708 error = %error,
1709 "credential synchronization failed for relay session"
1710 );
1711 outcomes.insert(
1712 target.session_id.clone(),
1713 CredentialSyncOutcome {
1714 session_id: target.session_id.clone(),
1715 outcome: Err(format!("{error:#}")),
1716 },
1717 );
1718 }
1719 }
1720 }
1721 if !pulled || pass == 1 {
1722 break;
1723 }
1724 }
1725 outcomes.into_values().collect()
1726}
1727
1728async fn reconcile_session(
1730 target: &CredentialSyncTarget,
1731 github_token: Option<&str>,
1732) -> Result<Vec<CredentialSyncAction>> {
1733 let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
1734 let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
1735 let canonical_skills = hel::hel_skills::collect_skills(target.harness, &target.profile_home)
1736 .with_context(|| {
1737 format!(
1738 "collect canonical skills for profile {} from {}",
1739 target.profile_id,
1740 target.profile_home.display()
1741 )
1742 })?;
1743 let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
1744 let result = reconcile_connected(
1745 &mut client,
1746 target,
1747 &canonical_path,
1748 &canonical,
1749 &canonical_bytes,
1750 &canonical_skills,
1751 github_token,
1752 )
1753 .await;
1754 if let Err(error) = client.detach().await {
1758 tracing::warn!(
1759 session_id = %target.session_id,
1760 "could not close the credential sync connection: {error:#}"
1761 );
1762 }
1763 result
1764}
1765
1766async fn reconcile_connected(
1767 client: &mut RelayClient,
1768 target: &CredentialSyncTarget,
1769 canonical_path: &Path,
1770 canonical: &CredentialSnapshot,
1771 canonical_bytes: &[u8],
1772 canonical_skills: &hel::hel_skills::SkillsArchive,
1773 github_token: Option<&str>,
1774) -> Result<Vec<CredentialSyncAction>> {
1775 let mut actions = Vec::new();
1776 let session = client.credential_state().await?;
1777 match reconcile(canonical, &session) {
1778 SyncAction::None => {
1779 if canonical.present
1780 && session.present
1781 && canonical.fingerprint != session.fingerprint
1782 && canonical.freshness_epoch_ms.is_none()
1783 && session.freshness_epoch_ms.is_none()
1784 {
1785 tracing::warn!(
1786 session_id = %target.session_id,
1787 profile_id = %target.profile_id,
1788 "credential copies differ but neither reports a refresh time; leaving both alone"
1789 );
1790 }
1791 }
1792 SyncAction::Push => {
1793 client.install_credentials(canonical_bytes).await?;
1794 actions.push(CredentialSyncAction::Pushed);
1795 }
1796 SyncAction::Pull => {
1797 let bytes = client.read_credentials().await?;
1798 validate_credential_payload(target.harness, &bytes).with_context(|| {
1799 format!(
1800 "session {} returned an unusable credential file",
1801 target.session_id
1802 )
1803 })?;
1804 write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
1805 format!(
1806 "install fresher credentials from session {} for profile {}",
1807 target.session_id, target.profile_id
1808 )
1809 })?;
1810 actions.push(CredentialSyncAction::Pulled);
1811 }
1812 }
1813 if reconcile_skills(client, target, canonical_skills).await? {
1814 actions.push(CredentialSyncAction::SkillsPushed);
1815 }
1816 if target.sync_github_token
1817 && let Some(action) = reconcile_github_token(client, target, github_token).await?
1818 {
1819 actions.push(action);
1820 }
1821 Ok(actions)
1822}
1823
1824async fn reconcile_github_token(
1825 client: &mut RelayClient,
1826 target: &CredentialSyncTarget,
1827 canonical: Option<&str>,
1828) -> Result<Option<CredentialSyncAction>> {
1829 let session = match client.github_token_state().await {
1830 Ok(state) => state,
1831 Err(error) if sync_method_unsupported(&error) => {
1832 tracing::debug!(
1833 session_id = %target.session_id,
1834 profile_id = %target.profile_id,
1835 "worker predates GitHub token sync; skipping until the target is re-provisioned"
1836 );
1837 return Ok(None);
1838 }
1839 Err(error) => return Err(error),
1840 };
1841 match canonical {
1842 Some(token) => {
1843 let canonical = hel::hel_credentials::GithubTokenSnapshot::of(token);
1844 if session == canonical {
1845 return Ok(None);
1846 }
1847 let installed = client.install_github_token(token).await?;
1848 if installed != canonical {
1849 bail!(
1850 "session {} GitHub token fingerprint does not match the controller after install",
1851 target.session_id
1852 );
1853 }
1854 Ok(Some(CredentialSyncAction::GithubTokenPushed))
1855 }
1856 None if session.present => {
1857 let removed = client.remove_github_token().await?;
1858 if removed.present {
1859 bail!(
1860 "session {} retained its GitHub token after removal",
1861 target.session_id
1862 );
1863 }
1864 Ok(Some(CredentialSyncAction::GithubTokenRemoved))
1865 }
1866 None => Ok(None),
1867 }
1868}
1869
1870async fn reconcile_skills(
1875 client: &mut RelayClient,
1876 target: &CredentialSyncTarget,
1877 canonical: &hel::hel_skills::SkillsArchive,
1878) -> Result<bool> {
1879 let canonical_state = canonical.state();
1880 let session = match client.skills_state().await {
1881 Ok(state) => state,
1882 Err(error) if sync_method_unsupported(&error) => {
1883 tracing::debug!(
1884 session_id = %target.session_id,
1885 profile_id = %target.profile_id,
1886 "worker predates skills sync; skipping until the target is re-provisioned"
1887 );
1888 return Ok(false);
1889 }
1890 Err(error) => return Err(error),
1891 };
1892 if session == canonical_state {
1893 return Ok(false);
1894 }
1895 let installed = client.install_skills(&canonical.encode()).await?;
1896 if installed != canonical_state {
1897 bail!(
1898 "session {} skills fingerprint {} does not match the canonical {} after install",
1899 target.session_id,
1900 installed.fingerprint,
1901 canonical_state.fingerprint
1902 );
1903 }
1904 Ok(true)
1905}
1906
1907fn sync_method_unsupported(error: &anyhow::Error) -> bool {
1908 error
1909 .downcast_ref::<RelayRejected>()
1910 .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
1911}
1912
1913#[cfg(test)]
1914mod tests {
1915 use super::*;
1916 use hel::hel_worker::{DurableRelay, RelayObservation};
1917 const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
1918
1919 #[test]
1920 fn relay_decoder_preserves_explicit_desynchronization() {
1921 let response = RelayResponseEnvelope {
1922 request_id: "relay-1".into(),
1923 protocol_version: RELAY_PROTOCOL_VERSION,
1924 body: RelayResponseBody::Error {
1925 error: RelayProtocolError {
1926 code: RelayErrorCode::Desynchronized,
1927 message: "journal gap".into(),
1928 retryable: false,
1929 detail: None,
1930 },
1931 },
1932 };
1933 let encoded = serde_json::to_string(&response).unwrap();
1934 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
1935 assert!(
1936 error
1937 .downcast_ref::<RelayRejected>()
1938 .is_some_and(RelayRejected::is_desynchronized)
1939 );
1940 }
1941
1942 #[test]
1943 fn relay_decoder_rejects_crossed_request_ids() {
1944 let response = RelayResponseEnvelope {
1945 request_id: "other".into(),
1946 protocol_version: RELAY_PROTOCOL_VERSION,
1947 body: RelayResponseBody::Ok {
1948 payload: RelayResponsePayload::Acknowledged {
1949 through_ordinal: 4,
1950 through_digest: "a".repeat(64),
1951 },
1952 },
1953 };
1954 let encoded = serde_json::to_string(&response).unwrap();
1955 assert!(
1956 decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
1957 .unwrap_err()
1958 .to_string()
1959 .contains("ID mismatch")
1960 );
1961 }
1962
1963 #[test]
1964 fn command_spec_preserves_argv_boundaries() {
1965 let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
1966 assert_eq!(spec.program, "ssh");
1967 assert_eq!(spec.args.len(), 2);
1968 assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
1969 }
1970
1971 #[test]
1972 fn relay_protocol_version_range_contains_current_version() {
1973 assert_eq!(
1974 RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
1975 Some(RELAY_PROTOCOL_VERSION)
1976 );
1977 assert_eq!(
1978 RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
1979 Some(1)
1980 );
1981 }
1982
1983 #[cfg(unix)]
1984 #[tokio::test]
1985 async fn controller_accepts_negotiated_protocol_v1() {
1986 let script = format!(
1987 r#"python3 -c '
1988import json, sys
1989session = {session:?}
1990req = json.loads(sys.stdin.readline())
1991assert req["request"]["method"] == "hello"
1992supported = req["request"]["params"]["supported"]
1993assert supported["min"] <= 1 <= supported["max"]
1994print(json.dumps({{
1995 "request_id": req["request_id"],
1996 "protocol_version": 1,
1997 "result": "ok",
1998 "payload": {{
1999 "type": "hello",
2000 "data": {{
2001 "negotiated": 1,
2002 "relay_version": "v1-fixture",
2003 "session_id": session,
2004 }},
2005 }},
2006}}), flush=True)
2007sys.stdin.read()
2008'"#,
2009 session = SESSION_ID
2010 );
2011 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2012 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2013 .await
2014 .expect("protocol v1 hello must be accepted");
2015 assert_eq!(client.protocol_version(), 1);
2016 assert_eq!(client.relay_version(), "v1-fixture");
2017 }
2018
2019 #[cfg(unix)]
2023 #[tokio::test]
2024 async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2025 let hello = |build: Option<&str>| {
2026 let data = match build {
2027 Some(build) => format!(
2028 r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2029 ),
2030 None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2031 .to_owned(),
2032 };
2033 format!(
2034 r#"
2035IFS= read -r hello
2036id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2037printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2038' "$id" "$1"
2039sh -c 'while :; do sleep 30; done'
2040"#
2041 )
2042 };
2043 for reported in [None, Some("a".repeat(64).as_str())] {
2044 let spec = CommandSpec::new(
2045 "sh",
2046 [
2047 "-c".to_owned(),
2048 hello(reported),
2049 "hel-relay-build-fixture".to_owned(),
2050 SESSION_ID.to_owned(),
2051 ],
2052 )
2053 .purpose("relay worker build fixture");
2054 let client =
2055 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2056 .await
2057 .expect("hello must be accepted with and without a worker build");
2058 assert_eq!(client.worker_build(), reported);
2059 }
2060 }
2061
2062 #[cfg(unix)]
2063 #[tokio::test]
2064 async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2065 let directory = tempfile::tempdir().unwrap();
2066 let eof = directory.path().join("proxy-saw-eof");
2067 let script = r#"
2068IFS= read -r hello
2069id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2070printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2071if IFS= read -r _; then exit 9; fi
2072: > "$2"
2073"#;
2074 let spec = CommandSpec::new(
2075 "sh",
2076 [
2077 "-c".to_owned(),
2078 script.to_owned(),
2079 "hel-relay-eof-fixture".to_owned(),
2080 SESSION_ID.to_owned(),
2081 eof.to_string_lossy().into_owned(),
2082 ],
2083 )
2084 .purpose("relay proxy EOF fixture");
2085 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2086 .await
2087 .unwrap();
2088
2089 drop(client);
2090 tokio::time::timeout(Duration::from_secs(2), async {
2091 while !eof.exists() {
2092 tokio::time::sleep(Duration::from_millis(10)).await;
2093 }
2094 })
2095 .await
2096 .expect("proxy launcher was killed before it observed stdin EOF");
2097 }
2098
2099 #[cfg(unix)]
2100 #[tokio::test]
2101 async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2102 let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2103 let script = format!(
2104 r#"python3 -c '
2105import json, sys
2106session = {session:?}
2107req = json.loads(sys.stdin.readline())
2108print(json.dumps({{
2109 "request_id": req["request_id"],
2110 "protocol_version": {future_protocol},
2111 "result": "ok",
2112 "payload": {{
2113 "type": "hello",
2114 "data": {{
2115 "negotiated": {future_protocol},
2116 "relay_version": "future",
2117 "session_id": session,
2118 }},
2119 }},
2120}}), flush=True)
2121sys.stdin.read()
2122'"#,
2123 session = SESSION_ID,
2124 future_protocol = future_protocol,
2125 );
2126 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2127 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2128 .await
2129 .err()
2130 .expect("a future protocol hello must be rejected");
2131 assert!(
2132 error.to_string().contains(&format!(
2133 "negotiated unsupported protocol {future_protocol}"
2134 )),
2135 "{error:#}"
2136 );
2137 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2140 }
2141
2142 #[cfg(unix)]
2145 #[tokio::test]
2146 async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2147 let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2148
2149 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2150 .await
2151 .err()
2152 .expect("a proxy that exits cannot complete hello");
2153
2154 assert!(RelayTransportDead::marks(&error), "{error:#}");
2155 assert!(RelayTransportDead::marks_failed_handshake(&error));
2156 }
2157
2158 #[cfg(unix)]
2159 #[tokio::test]
2160 async fn silent_proxy_handshake_has_a_bounded_deadline() {
2161 let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2162 let started = std::time::Instant::now();
2163
2164 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2165 .await
2166 .err()
2167 .expect("silent relay must time out");
2168
2169 assert!(error.to_string().contains("relay hello timed out"));
2170 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2174 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2175 assert!(started.elapsed() < Duration::from_secs(2));
2176 }
2177
2178 #[cfg(unix)]
2181 const STALLING_RELAY: &str = r#"
2182IFS= read -r hello
2183id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2184printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2185IFS= read -r stalled
2186id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2187sleep 5
2188printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2189cat > /dev/null
2190"#;
2191
2192 #[cfg(unix)]
2193 #[tokio::test]
2194 async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2195 let spec = CommandSpec::new(
2196 "sh",
2197 ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2198 )
2199 .purpose("stalling relay fixture");
2200 let mut client =
2201 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2202 .await
2203 .expect("the fixture answers hello immediately");
2204
2205 let timed_out = client
2206 .status()
2207 .await
2208 .expect_err("the stalled status call must time out");
2209 assert!(
2210 format!("{timed_out:#}").contains("relay status timed out"),
2211 "{timed_out:#}"
2212 );
2213 assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2216
2217 let started = std::time::Instant::now();
2222 let subsequent = client
2223 .status()
2224 .await
2225 .expect_err("a call on an abandoned connection must fail");
2226 let elapsed = started.elapsed();
2227 assert!(
2228 format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2229 "{subsequent:#}"
2230 );
2231 assert!(
2232 elapsed < Duration::from_millis(250),
2233 "an abandoned connection must fail fast, took {elapsed:?}"
2234 );
2235
2236 let repeated = client
2237 .status()
2238 .await
2239 .expect_err("the connection stays abandoned");
2240 assert!(
2241 format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2242 "{repeated:#}"
2243 );
2244 }
2245
2246 #[test]
2247 fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2248 let response = hel::hel_worker::unsupported_relay_method_response(
2252 "relay-1".into(),
2253 RELAY_PROTOCOL_VERSION,
2254 "skills_state".into(),
2255 );
2256 let encoded = serde_json::to_string(&response).unwrap();
2257 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2258 assert!(sync_method_unsupported(&error), "{error:#}");
2259 }
2260
2261 #[tokio::test]
2262 async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2263 let profile = tempfile::tempdir().unwrap();
2264 let mut coordinator = CredentialSyncCoordinator::spawn();
2265 coordinator.handle().set_targets(vec![CredentialSyncTarget {
2266 session_id: SESSION_ID.into(),
2267 profile_id: "work".into(),
2268 harness: hel::hel_config::HarnessKind::Codex,
2269 profile_home: profile.path().to_path_buf(),
2270 sync_github_token: false,
2271 spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2272 }]);
2273
2274 let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2275 .await
2276 .expect("target publication must not wait for the 60-second periodic tick")
2277 .expect("credential coordinator stopped");
2278 assert_eq!(result.profile_id, "work");
2279 assert_eq!(result.outcomes.len(), 1);
2280 assert!(result.outcomes[0].outcome.is_err());
2281 }
2282
2283 #[tokio::test]
2284 async fn response_frame_limit_is_enforced_before_newline() {
2285 let (mut writer, reader) = tokio::io::duplex(32);
2286 let write = tokio::spawn(async move {
2287 writer.write_all(b"123456789\n").await.unwrap();
2288 });
2289 let mut reader = BufReader::new(reader);
2290
2291 let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2292 .await
2293 .unwrap_err();
2294
2295 write.await.unwrap();
2296 assert!(error.to_string().contains("frame is too large"));
2297 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2300 }
2301
2302 #[tokio::test]
2303 async fn a_half_written_response_frame_reports_a_dead_transport() {
2304 let (mut writer, reader) = tokio::io::duplex(32);
2305 writer.write_all(b"{\"partial\":").await.unwrap();
2306 drop(writer);
2307 let mut reader = BufReader::new(reader);
2308
2309 let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2310 .await
2311 .unwrap_err();
2312
2313 assert!(RelayTransportDead::marks(&error), "{error:#}");
2314 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2315 }
2316
2317 #[test]
2318 fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2319 let temp = tempfile::tempdir().unwrap();
2320 let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2321 for message in ["one", "two", "arrived concurrently"] {
2322 relay
2323 .record_observation(RelayObservation::Warning {
2324 message: message.into(),
2325 })
2326 .unwrap();
2327 }
2328 let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2329 let previous = RelayCursor {
2330 ordinal: all[0].ordinal,
2331 digest: all[0].digest.clone(),
2332 };
2333 let frontier = RelayCursor {
2334 ordinal: all[1].ordinal,
2335 digest: all[1].digest.clone(),
2336 };
2337 let page = RelayAttachment {
2338 state: relay.operational_state(),
2339 events: all[1..].to_vec(),
2340 through_ordinal: all[2].ordinal,
2341 through_digest: all[2].digest.clone(),
2342 };
2343 let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2344 assert_eq!(clipped.through_ordinal, frontier.ordinal);
2345 assert_eq!(clipped.through_digest, frontier.digest);
2346 assert_eq!(clipped.events.len(), 1);
2347 assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2348 }
2349}