mj_controller/worker_client/
connect.rs1use super::*;
2
3impl RelayClient {
4 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
5 Self::connect_with_timeouts(
6 spec,
7 expected_session_id,
8 RELAY_RPC_TIMEOUT,
9 RELAY_HANDSHAKE_TIMEOUT,
10 )
11 .await
12 }
13
14 #[cfg(all(test, unix))]
15 pub(super) async fn connect_with_timeout(
16 spec: &CommandSpec,
17 expected_session_id: &str,
18 request_timeout: Duration,
19 ) -> Result<Self> {
20 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
21 .await
22 }
23
24 pub(super) async fn connect_with_timeouts(
32 spec: &CommandSpec,
33 expected_session_id: &str,
34 request_timeout: Duration,
35 handshake_timeout: Duration,
36 ) -> Result<Self> {
37 for attempt in 1..=SSH_RETRY_ATTEMPTS {
38 let outcome = Self::connect_attempt(
39 spec,
40 expected_session_id,
41 request_timeout,
42 handshake_timeout,
43 )
44 .await;
45 let error = match outcome {
46 Ok(client) => return Ok(client),
47 Err(ConnectFailure {
48 error,
49 transport_rejected,
50 }) => {
51 if attempt == SSH_RETRY_ATTEMPTS || !transport_rejected {
52 return Err(error);
53 }
54 error
55 }
56 };
57 let delay = mj_core::targets::ssh_retry_delay(attempt);
58 tracing::warn!(
59 session_id = %expected_session_id,
60 destination = spec.ssh_destination.as_deref().unwrap_or_default(),
61 purpose = %spec.purpose,
62 attempt,
63 attempts = SSH_RETRY_ATTEMPTS,
64 delay_ms = delay.as_millis() as u64,
65 error = %error,
66 "relay proxy was refused by the SSH server before authentication; retrying"
67 );
68 tokio::time::sleep(delay).await;
69 }
70 unreachable!("the final attempt always returns");
71 }
72
73 pub(super) async fn connect_attempt(
80 spec: &CommandSpec,
81 expected_session_id: &str,
82 request_timeout: Duration,
83 handshake_timeout: Duration,
84 ) -> std::result::Result<Self, ConnectFailure> {
85 let permit = match spec.ssh_destination.clone() {
86 Some(destination) => {
87 match tokio::task::spawn_blocking(move || SshAdmission::acquire(&destination)).await
88 {
89 Ok(permit) => Some(permit),
90 Err(error) => {
91 return Err(ConnectFailure::plain(anyhow!(
92 "SSH admission for the relay proxy was cancelled: {error}"
93 )));
94 }
95 }
96 }
97 None => None,
98 };
99 Self::spawn_and_handshake(
100 spec,
101 expected_session_id,
102 request_timeout,
103 handshake_timeout,
104 permit,
105 )
106 .await
107 }
108
109 pub(super) async fn spawn_and_handshake(
110 spec: &CommandSpec,
111 expected_session_id: &str,
112 request_timeout: Duration,
113 handshake_timeout: Duration,
114 permit: Option<SshPermit>,
115 ) -> std::result::Result<Self, ConnectFailure> {
116 let mut child = Command::new(&spec.program)
117 .args(&spec.args)
118 .envs(&spec.env)
119 .stdin(Stdio::piped())
120 .stdout(Stdio::piped())
121 .stderr(Stdio::piped())
125 .kill_on_drop(true)
126 .spawn()
127 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
128 .map_err(|error| {
129 tracing::warn!(
130 session_id = %expected_session_id,
131 operation = "connect",
132 purpose = %spec.purpose,
133 error = %error,
134 "could not start relay proxy"
135 );
136 error
137 })?;
138 let stderr_tail: ProxyStderrTail = Default::default();
139 let draining = child.stderr.take().map(|errors| {
140 let purpose = spec.purpose.clone();
141 let session_id = expected_session_id.to_owned();
142 let tail = stderr_tail.clone();
143 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id, tail))
144 });
145 let input = child
146 .stdin
147 .take()
148 .context("relay proxy stdin unavailable")
149 .map_err(|error| {
150 tracing::warn!(
151 session_id = %expected_session_id,
152 operation = "connect",
153 purpose = %spec.purpose,
154 error = %error,
155 "relay proxy did not provide stdin"
156 );
157 error
158 })?;
159 let output = child
160 .stdout
161 .take()
162 .context("relay proxy stdout unavailable")
163 .map_err(|error| {
164 tracing::warn!(
165 session_id = %expected_session_id,
166 operation = "connect",
167 purpose = %spec.purpose,
168 error = %error,
169 "relay proxy did not provide stdout"
170 );
171 error
172 })?;
173 let mut nonce_bytes = [0_u8; 8];
174 getrandom::fill(&mut nonce_bytes).map_err(|error| {
175 let error = anyhow!("generate relay request nonce: {error}");
176 tracing::warn!(
177 session_id = %expected_session_id,
178 operation = "connect",
179 error = %error,
180 "could not initialize relay request nonce"
181 );
182 error
183 })?;
184 let mut client = Self {
185 child: Some(child),
186 input: Some(input),
187 output: BufReader::new(output),
188 request_timeout,
189 abandoned: None,
190 next_request: 1,
191 connection_nonce: u64::from_le_bytes(nonce_bytes),
192 protocol_version: RELAY_PROTOCOL_VERSION,
193 session_id: expected_session_id.to_owned(),
197 relay_version: String::new(),
198 worker_build: None,
199 latest_ordinal: 0,
200 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
201 };
202 match client
203 .complete_handshake(expected_session_id, handshake_timeout)
204 .await
205 {
206 Ok(()) => {
207 drop(permit);
210 Ok(client)
212 }
213 Err(error) => {
214 let status = match client.child.as_mut() {
218 Some(child) => {
219 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
220 Ok(Ok(status)) => status.code(),
221 _ => {
228 let _ = child.start_kill();
229 None
230 }
231 }
232 }
233 None => None,
234 };
235 let tail = Self::proxy_stderr_tail(draining, &stderr_tail).await;
236 let transport_rejected = permit.is_some()
237 && status
238 .is_some_and(|status| is_transport_rejection(status, &tail.join("\n")));
239 drop(permit);
240 Err(ConnectFailure {
241 error: Self::attach_proxy_stderr(error, tail),
242 transport_rejected,
243 })
244 }
245 }
246 }
247
248 pub(super) async fn proxy_stderr_tail(
256 draining: Option<tokio::task::JoinHandle<()>>,
257 tail: &ProxyStderrTail,
258 ) -> Vec<String> {
259 if let Some(handle) = draining
260 && tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle)
261 .await
262 .is_err()
263 {
264 tracing::debug!("relay proxy stderr is still open; reporting the lines read so far");
265 }
266 tail.lock()
267 .unwrap_or_else(PoisonError::into_inner)
268 .iter()
269 .cloned()
270 .collect()
271 }
272
273 pub(super) fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
277 if lines.is_empty() {
278 return error;
279 }
280 error.context(format!(
281 "relay proxy stderr (last {} lines):\n{}",
282 lines.len(),
283 lines.join("\n")
284 ))
285 }
286
287 pub(super) async fn complete_handshake(
289 &mut self,
290 expected_session_id: &str,
291 handshake_timeout: Duration,
292 ) -> Result<()> {
293 let response = self
294 .call_hello(
295 RelayRequest::Hello {
296 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
297 supported: RelayVersionRange::CURRENT,
298 },
299 handshake_timeout,
300 )
301 .await?;
302 let RelayResponsePayload::Hello {
303 negotiated,
304 relay_version,
305 session_id,
306 worker_build,
307 } = response
308 else {
309 let error = anyhow!("relay returned an unexpected hello response");
310 log_relay_client_failure(self, "hello", "relay-hello", &error);
311 return Err(error);
312 };
313 if session_id != expected_session_id {
314 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
315 log_relay_client_failure(self, "hello", "relay-hello", &error);
316 return Err(error);
317 }
318 if !RelayVersionRange::CURRENT.contains(negotiated) {
319 let error = anyhow!(
320 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
321 RELAY_MIN_PROTOCOL_VERSION,
322 RELAY_PROTOCOL_VERSION
323 );
324 log_relay_client_failure(self, "hello", "relay-hello", &error);
325 return Err(error);
326 }
327 self.protocol_version = negotiated;
328 self.session_id = session_id;
329 self.relay_version = relay_version;
330 self.worker_build = worker_build;
331 Ok(())
332 }
333}