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(
35 spec: &CommandSpec,
36 expected_session_id: &str,
37 request_timeout: Duration,
38 handshake_timeout: Duration,
39 ) -> Result<Self> {
40 let purpose = format!("{} for session {expected_session_id}", spec.purpose);
41 let mut refusals = 0;
42 let mut socket_waits = 0;
43 loop {
44 let outcome = Self::connect_attempt(
45 spec,
46 expected_session_id,
47 request_timeout,
48 handshake_timeout,
49 )
50 .await;
51 let (error, retry) = match outcome {
52 Ok(client) => return Ok(client),
53 Err(ConnectFailure { error, retry: None }) => return Err(error),
54 Err(ConnectFailure {
55 error,
56 retry: Some(retry),
57 }) => (error, retry),
58 };
59 let delay = match retry {
60 ConnectRetry::Refused(refusal, stderr) => {
65 refusals += 1;
66 let destination = spec.ssh_destination.as_deref().unwrap_or_default();
67 if refusals == SSH_RETRY_ATTEMPTS {
68 refusal.log_exhausted(destination, &purpose, &stderr);
69 return Err(error);
70 }
71 let delay = mj_core::targets::ssh_retry_delay(refusals);
72 refusal.log_retry(destination, &purpose, refusals, delay, &stderr);
73 delay
74 }
75 ConnectRetry::SocketMissing => {
76 let Some(&delay) = WORKER_SOCKET_RETRY_DELAYS.get(socket_waits) else {
77 tracing::warn!(
78 session_id = %expected_session_id,
79 purpose = %purpose,
80 operation = "hello",
81 attempts = socket_waits + 1,
82 error = format!("{error:#}"),
83 "the worker's control socket is still missing; giving up on this connection"
84 );
85 return Err(error);
86 };
87 socket_waits += 1;
88 tracing::debug!(
89 session_id = %expected_session_id,
90 purpose = %purpose,
91 attempt = socket_waits,
92 delay_ms = delay.as_millis() as u64,
93 "the worker has not bound its control socket yet; retrying"
94 );
95 delay
96 }
97 };
98 tokio::time::sleep(delay).await;
99 }
100 }
101
102 pub(super) async fn connect_attempt(
111 spec: &CommandSpec,
112 expected_session_id: &str,
113 request_timeout: Duration,
114 handshake_timeout: Duration,
115 ) -> std::result::Result<Self, ConnectFailure> {
116 let Some(destination) = spec.ssh_destination.clone() else {
117 return Self::spawn_and_handshake(
118 spec,
119 expected_session_id,
120 request_timeout,
121 handshake_timeout,
122 None,
123 None,
124 )
125 .await;
126 };
127 let requested = spec.clone();
128 let prepared = tokio::task::spawn_blocking(move || {
129 let (spec, lease) = requested
132 .open_ssh_session(&BoundedProcessExecutor::new(SSH_MASTER_OPEN_TIMEOUT))?
133 .into_parts();
134 let permit = SshAdmission::acquire(&destination);
135 Ok::<_, anyhow::Error>((spec, lease, permit))
136 })
137 .await;
138 let (spec, lease, permit) = match prepared {
139 Ok(Ok(prepared)) => prepared,
140 Ok(Err(error)) => {
141 return Err(ConnectFailure::plain(
142 error.context("open the SSH session for the relay proxy"),
143 ));
144 }
145 Err(error) => {
146 return Err(ConnectFailure::plain(anyhow!(
147 "SSH admission for the relay proxy was cancelled: {error}"
148 )));
149 }
150 };
151 Self::spawn_and_handshake(
152 &spec,
153 expected_session_id,
154 request_timeout,
155 handshake_timeout,
156 Some(permit),
157 lease,
158 )
159 .await
160 }
161
162 pub(super) async fn spawn_and_handshake(
163 spec: &CommandSpec,
164 expected_session_id: &str,
165 request_timeout: Duration,
166 handshake_timeout: Duration,
167 permit: Option<SshPermit>,
168 ssh_session: Option<SshSessionLease>,
169 ) -> std::result::Result<Self, ConnectFailure> {
170 let mut child = Command::new(&spec.program)
171 .args(&spec.args)
172 .envs(&spec.env)
173 .stdin(Stdio::piped())
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped())
179 .kill_on_drop(true)
180 .spawn()
181 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
182 .map_err(|error| {
183 tracing::warn!(
184 session_id = %expected_session_id,
185 operation = "connect",
186 purpose = %spec.purpose,
187 error = %error,
188 "could not start relay proxy"
189 );
190 error
191 })?;
192 let stderr_tail: ProxyStderrTail = Default::default();
193 let handshake_done = Arc::new(AtomicBool::new(false));
194 let draining = child.stderr.take().map(|errors| {
195 let purpose = spec.purpose.clone();
196 let session_id = expected_session_id.to_owned();
197 let tail = stderr_tail.clone();
198 let handshake_done = handshake_done.clone();
199 tokio::spawn(drain_proxy_stderr(
200 errors,
201 purpose,
202 session_id,
203 tail,
204 handshake_done,
205 ))
206 });
207 let input = child
208 .stdin
209 .take()
210 .context("relay proxy stdin unavailable")
211 .map_err(|error| {
212 tracing::warn!(
213 session_id = %expected_session_id,
214 operation = "connect",
215 purpose = %spec.purpose,
216 error = %error,
217 "relay proxy did not provide stdin"
218 );
219 error
220 })?;
221 let output = child
222 .stdout
223 .take()
224 .context("relay proxy stdout unavailable")
225 .map_err(|error| {
226 tracing::warn!(
227 session_id = %expected_session_id,
228 operation = "connect",
229 purpose = %spec.purpose,
230 error = %error,
231 "relay proxy did not provide stdout"
232 );
233 error
234 })?;
235 let mut nonce_bytes = [0_u8; 8];
236 getrandom::fill(&mut nonce_bytes).map_err(|error| {
237 let error = anyhow!("generate relay request nonce: {error}");
238 tracing::warn!(
239 session_id = %expected_session_id,
240 operation = "connect",
241 error = %error,
242 "could not initialize relay request nonce"
243 );
244 error
245 })?;
246 let mut client = Self {
247 child: Some(child),
248 input: Some(input),
249 output: BufReader::new(output),
250 request_timeout,
251 abandoned: None,
252 next_request: 1,
253 connection_nonce: u64::from_le_bytes(nonce_bytes),
254 protocol_version: RELAY_PROTOCOL_VERSION,
255 session_id: expected_session_id.to_owned(),
259 relay_version: String::new(),
260 worker_build: None,
261 latest_ordinal: 0,
262 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
263 ssh_session,
264 };
265 match client
266 .complete_handshake(expected_session_id, handshake_timeout)
267 .await
268 {
269 Ok(()) => {
270 drop(permit);
273 handshake_done.store(true, Ordering::Release);
274 Ok(client)
276 }
277 Err(error) => {
278 let status = match client.child.as_mut() {
282 Some(child) => {
283 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
284 Ok(Ok(status)) => status.code(),
285 _ => {
292 let _ = child.start_kill();
293 None
294 }
295 }
296 }
297 None => None,
298 };
299 let tail = Self::proxy_stderr_tail(draining, &stderr_tail).await;
300 let stderr = tail.join("\n");
301 let refusal = permit
302 .as_ref()
303 .and(status)
304 .and_then(|status| ssh_refusal(status, &stderr));
305 drop(permit);
306 if refusal == Some(SshRefusal::BeforeAuthentication)
309 && let Some(lease) = &client.ssh_session
310 {
311 lease.invalidate();
312 }
313 let error = Self::attach_proxy_stderr(error, tail);
314 let retry = match refusal {
315 Some(refusal) => Some(ConnectRetry::Refused(refusal, stderr)),
316 None if status.is_some() && worker_socket_missing(&stderr) => {
317 Some(ConnectRetry::SocketMissing)
318 }
319 None => None,
320 };
321 match retry {
322 Some(_) => drop(client.child.take()),
326 None => log_relay_client_failure(&client, "hello", "relay-hello", &error),
328 }
329 Err(ConnectFailure { error, retry })
330 }
331 }
332 }
333
334 pub(super) async fn proxy_stderr_tail(
342 draining: Option<tokio::task::JoinHandle<()>>,
343 tail: &ProxyStderrTail,
344 ) -> Vec<String> {
345 if let Some(handle) = draining
346 && tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle)
347 .await
348 .is_err()
349 {
350 tracing::debug!("relay proxy stderr is still open; reporting the lines read so far");
351 }
352 tail.lock()
353 .unwrap_or_else(PoisonError::into_inner)
354 .iter()
355 .cloned()
356 .collect()
357 }
358
359 pub(super) fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
363 if lines.is_empty() {
364 return error;
365 }
366 error.context(format!(
367 "relay proxy stderr (last {} lines):\n{}",
368 lines.len(),
369 lines.join("\n")
370 ))
371 }
372
373 pub(super) async fn complete_handshake(
375 &mut self,
376 expected_session_id: &str,
377 handshake_timeout: Duration,
378 ) -> Result<()> {
379 let response = self
380 .call_hello(
381 RelayRequest::Hello {
382 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
383 supported: RelayVersionRange::CURRENT,
384 },
385 handshake_timeout,
386 )
387 .await?;
388 let RelayResponsePayload::Hello {
389 negotiated,
390 relay_version,
391 session_id,
392 worker_build,
393 } = response
394 else {
395 bail!("relay returned an unexpected hello response");
396 };
397 if session_id != expected_session_id {
398 bail!("relay belongs to session {session_id}, not {expected_session_id}");
399 }
400 if !RelayVersionRange::CURRENT.contains(negotiated) {
401 bail!(
402 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
403 RELAY_MIN_PROTOCOL_VERSION,
404 RELAY_PROTOCOL_VERSION
405 );
406 }
407 self.protocol_version = negotiated;
408 self.session_id = session_id;
409 self.relay_version = relay_version;
410 self.worker_build = worker_build;
411 Ok(())
412 }
413}
414
415fn worker_socket_missing(stderr: &str) -> bool {
420 stderr.contains("connect worker socket") && stderr.contains("No such file or directory")
421}