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 executor =
129 crate::targets::CancellableProcessExecutor::with_timeout(SSH_MASTER_OPEN_TIMEOUT);
130 let _cancel_preparation = executor.cancel_on_drop();
131 let prepared = tokio::task::spawn_blocking(move || {
132 let (spec, lease) = requested.open_ssh_session(&executor)?.into_parts();
135 let permit = SshAdmission::acquire_unless(&destination, &|| executor.is_cancelled())?;
136 Ok::<_, anyhow::Error>((spec, lease, permit))
137 })
138 .await;
139 let (spec, lease, permit) = match prepared {
140 Ok(Ok(prepared)) => prepared,
141 Ok(Err(error)) => {
142 return Err(ConnectFailure::plain(
143 error.context("open the SSH session for the relay proxy"),
144 ));
145 }
146 Err(error) => {
147 return Err(ConnectFailure::plain(anyhow!(
148 "SSH admission for the relay proxy was cancelled: {error}"
149 )));
150 }
151 };
152 Self::spawn_and_handshake(
153 &spec,
154 expected_session_id,
155 request_timeout,
156 handshake_timeout,
157 Some(permit),
158 lease,
159 )
160 .await
161 }
162
163 pub(super) async fn spawn_and_handshake(
164 spec: &CommandSpec,
165 expected_session_id: &str,
166 request_timeout: Duration,
167 handshake_timeout: Duration,
168 permit: Option<SshPermit>,
169 ssh_session: Option<SshSessionLease>,
170 ) -> std::result::Result<Self, ConnectFailure> {
171 let mut child = Command::new(&spec.program)
172 .args(&spec.args)
173 .envs(&spec.env)
174 .stdin(Stdio::piped())
175 .stdout(Stdio::piped())
176 .stderr(Stdio::piped())
180 .kill_on_drop(true)
181 .spawn()
182 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
183 .map_err(|error| {
184 tracing::warn!(
185 session_id = %expected_session_id,
186 operation = "connect",
187 purpose = %spec.purpose,
188 error = %error,
189 "could not start relay proxy"
190 );
191 error
192 })?;
193 let stderr_tail: ProxyStderrTail = Default::default();
194 let handshake_done = Arc::new(AtomicBool::new(false));
195 let draining = child.stderr.take().map(|errors| {
196 let purpose = spec.purpose.clone();
197 let session_id = expected_session_id.to_owned();
198 let tail = stderr_tail.clone();
199 let handshake_done = handshake_done.clone();
200 tokio::spawn(drain_proxy_stderr(
201 errors,
202 purpose,
203 session_id,
204 tail,
205 handshake_done,
206 ))
207 });
208 let input = child
209 .stdin
210 .take()
211 .context("relay proxy stdin unavailable")
212 .map_err(|error| {
213 tracing::warn!(
214 session_id = %expected_session_id,
215 operation = "connect",
216 purpose = %spec.purpose,
217 error = %error,
218 "relay proxy did not provide stdin"
219 );
220 error
221 })?;
222 let output = child
223 .stdout
224 .take()
225 .context("relay proxy stdout unavailable")
226 .map_err(|error| {
227 tracing::warn!(
228 session_id = %expected_session_id,
229 operation = "connect",
230 purpose = %spec.purpose,
231 error = %error,
232 "relay proxy did not provide stdout"
233 );
234 error
235 })?;
236 let mut nonce_bytes = [0_u8; 8];
237 getrandom::fill(&mut nonce_bytes).map_err(|error| {
238 let error = anyhow!("generate relay request nonce: {error}");
239 tracing::warn!(
240 session_id = %expected_session_id,
241 operation = "connect",
242 error = %error,
243 "could not initialize relay request nonce"
244 );
245 error
246 })?;
247 let mut client = Self {
248 child: Some(child),
249 input: Some(input),
250 output: BufReader::new(output),
251 request_timeout,
252 abandoned: None,
253 next_request: 1,
254 connection_nonce: u64::from_le_bytes(nonce_bytes),
255 protocol_version: RELAY_PROTOCOL_VERSION,
256 session_id: expected_session_id.to_owned(),
260 relay_version: String::new(),
261 worker_build: None,
262 latest_ordinal: 0,
263 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
264 ssh_session,
265 };
266 match client
267 .complete_handshake(expected_session_id, handshake_timeout)
268 .await
269 {
270 Ok(()) => {
271 drop(permit);
274 handshake_done.store(true, Ordering::Release);
275 Ok(client)
277 }
278 Err(error) => {
279 let status = match client.child.as_mut() {
283 Some(child) => {
284 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
285 Ok(Ok(status)) => status.code(),
286 _ => {
293 let _ = child.start_kill();
294 None
295 }
296 }
297 }
298 None => None,
299 };
300 let tail = Self::proxy_stderr_tail(draining, &stderr_tail).await;
301 let stderr = tail.join("\n");
302 let refusal = permit
303 .as_ref()
304 .and(status)
305 .and_then(|status| ssh_refusal(status, &stderr));
306 drop(permit);
307 if refusal == Some(SshRefusal::BeforeAuthentication)
310 && let Some(lease) = &client.ssh_session
311 {
312 lease.invalidate();
313 }
314 let error = Self::attach_proxy_stderr(error, tail);
315 let retry = match refusal {
316 Some(refusal) => Some(ConnectRetry::Refused(refusal, stderr)),
317 None if status.is_some() && worker_socket_missing(&stderr) => {
318 Some(ConnectRetry::SocketMissing)
319 }
320 None => None,
321 };
322 match retry {
323 Some(_) => drop(client.child.take()),
327 None => log_relay_client_failure(&client, "hello", "relay-hello", &error),
329 }
330 Err(ConnectFailure { error, retry })
331 }
332 }
333 }
334
335 pub(super) async fn proxy_stderr_tail(
343 draining: Option<tokio::task::JoinHandle<()>>,
344 tail: &ProxyStderrTail,
345 ) -> Vec<String> {
346 if let Some(handle) = draining
347 && tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle)
348 .await
349 .is_err()
350 {
351 tracing::debug!("relay proxy stderr is still open; reporting the lines read so far");
352 }
353 tail.lock()
354 .unwrap_or_else(PoisonError::into_inner)
355 .iter()
356 .cloned()
357 .collect()
358 }
359
360 pub(super) fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
364 if lines.is_empty() {
365 return error;
366 }
367 error.context(format!(
368 "relay proxy stderr (last {} lines):\n{}",
369 lines.len(),
370 lines.join("\n")
371 ))
372 }
373
374 pub(super) async fn complete_handshake(
376 &mut self,
377 expected_session_id: &str,
378 handshake_timeout: Duration,
379 ) -> Result<()> {
380 let response = self
381 .call_hello(
382 RelayRequest::Hello {
383 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
384 supported: RelayVersionRange::CURRENT,
385 },
386 handshake_timeout,
387 )
388 .await?;
389 let RelayResponsePayload::Hello {
390 negotiated,
391 relay_version,
392 session_id,
393 worker_build,
394 } = response
395 else {
396 bail!("relay returned an unexpected hello response");
397 };
398 if session_id != expected_session_id {
399 bail!("relay belongs to session {session_id}, not {expected_session_id}");
400 }
401 if !RelayVersionRange::CURRENT.contains(negotiated) {
402 bail!(
403 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
404 RELAY_MIN_PROTOCOL_VERSION,
405 RELAY_PROTOCOL_VERSION
406 );
407 }
408 self.protocol_version = negotiated;
409 self.session_id = session_id;
410 self.relay_version = relay_version;
411 self.worker_build = worker_build;
412 Ok(())
413 }
414}
415
416fn worker_socket_missing(stderr: &str) -> bool {
421 stderr.contains("connect worker socket") && stderr.contains("No such file or directory")
422}