Skip to main content

mj_controller/worker_client/
connect.rs

1use 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    /// Start a relay proxy and complete its handshake, retrying while the
25    /// remote `sshd` is turning fresh connections away before authentication,
26    /// and while the worker has not bound its control socket yet.
27    ///
28    /// The whole daemon reconnects at once after a restart, which is exactly
29    /// when a host at its `MaxStartups` ceiling drops the surplus. Those
30    /// rejections say nothing about the worker, so escalating one to worker
31    /// recovery would destroy a healthy session. A worker that was started
32    /// moments ago has usually not bound its socket yet; that is routine too,
33    /// as long as the socket appears within [`WORKER_SOCKET_RETRY_DELAYS`].
34    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                // Logged by the routine every other refused ssh command uses:
61                // a MaxSessions refusal is routine while sessions start and
62                // goes to debug, one before authentication stays a warning
63                // (R7-1).
64                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    /// One proxy launch and handshake.
103    ///
104    /// When the proxy runs over a shared SSH connection, a session is leased
105    /// first (opening a master if needed) and kept for the life of the
106    /// proxy. An admission permit is then taken before the proxy is spawned
107    /// and released once hello completes: `sshd` counts only unauthenticated
108    /// connections against `MaxStartups`, so the long-lived relay stops
109    /// occupying a slot as soon as it is authenticated and talking.
110    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            // Lease before taking the permit: opening a master takes a
133            // permit of its own.
134            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            // Never inherit: the controller owns a TUI alternate screen, so a
177            // child writing to the shared stderr corrupts the display outside
178            // the renderer's buffer. Drain it into the log instead.
179            .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            // Keep the expected identity from process creation onward so a
257            // handshake failure and the dropped proxy that follows it remain
258            // attributable even when Hello never returns a session ID.
259            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                // Hello succeeded, so this connection is past authentication
272                // and no longer counts against the server's startup budget.
273                drop(permit);
274                handshake_done.store(true, Ordering::Release);
275                // The drain task keeps logging for the life of the connection.
276                Ok(client)
277            }
278            Err(error) => {
279                // Read the proxy's exit status before killing it: a connection
280                // the server dropped has already exited 255, and that status
281                // is what separates a refused connection from a broken worker.
282                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                            // Still running, or unwaitable. Stop the proxy so
287                            // it closes stderr; otherwise a proxy that is
288                            // merely slow would hold the drain task open past
289                            // its grace period and the tail would be lost. The
290                            // child stays in place so dropping `client` reaps
291                            // it as usual.
292                            _ => {
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                // A session turned away by the transport usually means its
308                // master died; make the retry check and reopen it.
309                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                    // A launch that will be retried is logged by the retry.
324                    // Its proxy has exited and was reaped above, so dropping
325                    // `client` must not report that exit as a second warning.
326                    Some(_) => drop(client.child.take()),
327                    // Anything else is a failure of this proxy or its worker.
328                    None => log_relay_client_failure(&client, "hello", "relay-hello", &error),
329                }
330                Err(ConnectFailure { error, retry })
331            }
332        }
333    }
334
335    /// Collect the proxy's trailing stderr.
336    ///
337    /// The caller has already waited for the proxy or killed it, so the drain
338    /// normally reaches EOF at once. The grace period covers the case it
339    /// cannot: a grandchild that inherited stderr keeps the pipe open for as
340    /// long as it lives. Either way the lines already read are returned, since
341    /// the drain publishes them as it goes.
342    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    /// Attach the proxy's own stderr tail to a failed connect. The proxy
361    /// explains failures the controller cannot see any other way, such as a
362    /// worker socket path longer than `sun_path`.
363    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    /// Exchange `Hello` and record what the relay negotiated.
375    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
416/// Whether a relay proxy that exited during hello found no control socket to
417/// connect to. The worker's proxy reports a failed connect as "connect worker
418/// socket <path>" (`mj-worker`'s `proxy`), and a socket file that does not
419/// exist yet as ENOENT.
420fn worker_socket_missing(stderr: &str) -> bool {
421    stderr.contains("connect worker socket") && stderr.contains("No such file or directory")
422}