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    ///
27    /// The whole daemon reconnects at once after a restart, which is exactly
28    /// when a host at its `MaxStartups` ceiling drops the surplus. Those
29    /// rejections say nothing about the worker, so escalating one to worker
30    /// recovery would destroy a healthy session.
31    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    /// One proxy launch and handshake.
74    ///
75    /// When the proxy runs over a shared SSH connection, a session is leased
76    /// first (opening a master if needed) and kept for the life of the
77    /// proxy. An admission permit is then taken before the proxy is spawned
78    /// and released once hello completes: `sshd` counts only unauthenticated
79    /// connections against `MaxStartups`, so the long-lived relay stops
80    /// occupying a slot as soon as it is authenticated and talking.
81    pub(super) async fn connect_attempt(
82        spec: &CommandSpec,
83        expected_session_id: &str,
84        request_timeout: Duration,
85        handshake_timeout: Duration,
86    ) -> std::result::Result<Self, ConnectFailure> {
87        let Some(destination) = spec.ssh_destination.clone() else {
88            return Self::spawn_and_handshake(
89                spec,
90                expected_session_id,
91                request_timeout,
92                handshake_timeout,
93                None,
94                None,
95            )
96            .await;
97        };
98        let requested = spec.clone();
99        let prepared = tokio::task::spawn_blocking(move || {
100            // Lease before taking the permit: opening a master takes a
101            // permit of its own.
102            let (spec, lease) = requested
103                .open_ssh_session(&BoundedProcessExecutor::new(SSH_MASTER_OPEN_TIMEOUT))?
104                .into_parts();
105            let permit = SshAdmission::acquire(&destination);
106            Ok::<_, anyhow::Error>((spec, lease, permit))
107        })
108        .await;
109        let (spec, lease, permit) = match prepared {
110            Ok(Ok(prepared)) => prepared,
111            Ok(Err(error)) => {
112                return Err(ConnectFailure::plain(
113                    error.context("open the SSH session for the relay proxy"),
114                ));
115            }
116            Err(error) => {
117                return Err(ConnectFailure::plain(anyhow!(
118                    "SSH admission for the relay proxy was cancelled: {error}"
119                )));
120            }
121        };
122        Self::spawn_and_handshake(
123            &spec,
124            expected_session_id,
125            request_timeout,
126            handshake_timeout,
127            Some(permit),
128            lease,
129        )
130        .await
131    }
132
133    pub(super) async fn spawn_and_handshake(
134        spec: &CommandSpec,
135        expected_session_id: &str,
136        request_timeout: Duration,
137        handshake_timeout: Duration,
138        permit: Option<SshPermit>,
139        ssh_session: Option<SshSessionLease>,
140    ) -> std::result::Result<Self, ConnectFailure> {
141        let mut child = Command::new(&spec.program)
142            .args(&spec.args)
143            .envs(&spec.env)
144            .stdin(Stdio::piped())
145            .stdout(Stdio::piped())
146            // Never inherit: the controller owns a TUI alternate screen, so a
147            // child writing to the shared stderr corrupts the display outside
148            // the renderer's buffer. Drain it into the log instead.
149            .stderr(Stdio::piped())
150            .kill_on_drop(true)
151            .spawn()
152            .with_context(|| format!("start session relay proxy for {}", spec.purpose))
153            .map_err(|error| {
154                tracing::warn!(
155                    session_id = %expected_session_id,
156                    operation = "connect",
157                    purpose = %spec.purpose,
158                    error = %error,
159                    "could not start relay proxy"
160                );
161                error
162            })?;
163        let stderr_tail: ProxyStderrTail = Default::default();
164        let draining = child.stderr.take().map(|errors| {
165            let purpose = spec.purpose.clone();
166            let session_id = expected_session_id.to_owned();
167            let tail = stderr_tail.clone();
168            tokio::spawn(drain_proxy_stderr(errors, purpose, session_id, tail))
169        });
170        let input = child
171            .stdin
172            .take()
173            .context("relay proxy stdin unavailable")
174            .map_err(|error| {
175                tracing::warn!(
176                    session_id = %expected_session_id,
177                    operation = "connect",
178                    purpose = %spec.purpose,
179                    error = %error,
180                    "relay proxy did not provide stdin"
181                );
182                error
183            })?;
184        let output = child
185            .stdout
186            .take()
187            .context("relay proxy stdout unavailable")
188            .map_err(|error| {
189                tracing::warn!(
190                    session_id = %expected_session_id,
191                    operation = "connect",
192                    purpose = %spec.purpose,
193                    error = %error,
194                    "relay proxy did not provide stdout"
195                );
196                error
197            })?;
198        let mut nonce_bytes = [0_u8; 8];
199        getrandom::fill(&mut nonce_bytes).map_err(|error| {
200            let error = anyhow!("generate relay request nonce: {error}");
201            tracing::warn!(
202                session_id = %expected_session_id,
203                operation = "connect",
204                error = %error,
205                "could not initialize relay request nonce"
206            );
207            error
208        })?;
209        let mut client = Self {
210            child: Some(child),
211            input: Some(input),
212            output: BufReader::new(output),
213            request_timeout,
214            abandoned: None,
215            next_request: 1,
216            connection_nonce: u64::from_le_bytes(nonce_bytes),
217            protocol_version: RELAY_PROTOCOL_VERSION,
218            // Keep the expected identity from process creation onward so a
219            // handshake failure and the dropped proxy that follows it remain
220            // attributable even when Hello never returns a session ID.
221            session_id: expected_session_id.to_owned(),
222            relay_version: String::new(),
223            worker_build: None,
224            latest_ordinal: 0,
225            latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
226            ssh_session,
227        };
228        match client
229            .complete_handshake(expected_session_id, handshake_timeout)
230            .await
231        {
232            Ok(()) => {
233                // Hello succeeded, so this connection is past authentication
234                // and no longer counts against the server's startup budget.
235                drop(permit);
236                // The drain task keeps logging for the life of the connection.
237                Ok(client)
238            }
239            Err(error) => {
240                // Read the proxy's exit status before killing it: a connection
241                // the server dropped has already exited 255, and that status
242                // is what separates a refused connection from a broken worker.
243                let status = match client.child.as_mut() {
244                    Some(child) => {
245                        match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
246                            Ok(Ok(status)) => status.code(),
247                            // Still running, or unwaitable. Stop the proxy so
248                            // it closes stderr; otherwise a proxy that is
249                            // merely slow would hold the drain task open past
250                            // its grace period and the tail would be lost. The
251                            // child stays in place so dropping `client` reaps
252                            // it as usual.
253                            _ => {
254                                let _ = child.start_kill();
255                                None
256                            }
257                        }
258                    }
259                    None => None,
260                };
261                let tail = Self::proxy_stderr_tail(draining, &stderr_tail).await;
262                let transport_rejected = permit.is_some()
263                    && status
264                        .is_some_and(|status| is_transport_rejection(status, &tail.join("\n")));
265                drop(permit);
266                // A session turned away by the transport usually means its
267                // master died; make the retry check and reopen it.
268                if transport_rejected && let Some(lease) = &client.ssh_session {
269                    lease.invalidate();
270                }
271                Err(ConnectFailure {
272                    error: Self::attach_proxy_stderr(error, tail),
273                    transport_rejected,
274                })
275            }
276        }
277    }
278
279    /// Collect the proxy's trailing stderr.
280    ///
281    /// The caller has already waited for the proxy or killed it, so the drain
282    /// normally reaches EOF at once. The grace period covers the case it
283    /// cannot: a grandchild that inherited stderr keeps the pipe open for as
284    /// long as it lives. Either way the lines already read are returned, since
285    /// the drain publishes them as it goes.
286    pub(super) async fn proxy_stderr_tail(
287        draining: Option<tokio::task::JoinHandle<()>>,
288        tail: &ProxyStderrTail,
289    ) -> Vec<String> {
290        if let Some(handle) = draining
291            && tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle)
292                .await
293                .is_err()
294        {
295            tracing::debug!("relay proxy stderr is still open; reporting the lines read so far");
296        }
297        tail.lock()
298            .unwrap_or_else(PoisonError::into_inner)
299            .iter()
300            .cloned()
301            .collect()
302    }
303
304    /// Attach the proxy's own stderr tail to a failed connect. The proxy
305    /// explains failures the controller cannot see any other way, such as a
306    /// worker socket path longer than `sun_path`.
307    pub(super) fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
308        if lines.is_empty() {
309            return error;
310        }
311        error.context(format!(
312            "relay proxy stderr (last {} lines):\n{}",
313            lines.len(),
314            lines.join("\n")
315        ))
316    }
317
318    /// Exchange `Hello` and record what the relay negotiated.
319    pub(super) async fn complete_handshake(
320        &mut self,
321        expected_session_id: &str,
322        handshake_timeout: Duration,
323    ) -> Result<()> {
324        let response = self
325            .call_hello(
326                RelayRequest::Hello {
327                    controller_version: env!("CARGO_PKG_VERSION").to_owned(),
328                    supported: RelayVersionRange::CURRENT,
329                },
330                handshake_timeout,
331            )
332            .await?;
333        let RelayResponsePayload::Hello {
334            negotiated,
335            relay_version,
336            session_id,
337            worker_build,
338        } = response
339        else {
340            let error = anyhow!("relay returned an unexpected hello response");
341            log_relay_client_failure(self, "hello", "relay-hello", &error);
342            return Err(error);
343        };
344        if session_id != expected_session_id {
345            let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
346            log_relay_client_failure(self, "hello", "relay-hello", &error);
347            return Err(error);
348        }
349        if !RelayVersionRange::CURRENT.contains(negotiated) {
350            let error = anyhow!(
351                "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
352                RELAY_MIN_PROTOCOL_VERSION,
353                RELAY_PROTOCOL_VERSION
354            );
355            log_relay_client_failure(self, "hello", "relay-hello", &error);
356            return Err(error);
357        }
358        self.protocol_version = negotiated;
359        self.session_id = session_id;
360        self.relay_version = relay_version;
361        self.worker_build = worker_build;
362        Ok(())
363    }
364}