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    /// An admission permit is taken before the proxy is spawned and released
76    /// once hello completes: `sshd` counts only unauthenticated connections
77    /// against `MaxStartups`, so the long-lived relay stops occupying a slot
78    /// as soon as it is authenticated and talking.
79    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            // Never inherit: the controller owns a TUI alternate screen, so a
122            // child writing to the shared stderr corrupts the display outside
123            // the renderer's buffer. Drain it into the log instead.
124            .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            // Keep the expected identity from process creation onward so a
194            // handshake failure and the dropped proxy that follows it remain
195            // attributable even when Hello never returns a session ID.
196            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                // Hello succeeded, so this connection is past authentication
208                // and no longer counts against the server's startup budget.
209                drop(permit);
210                // The drain task keeps logging for the life of the connection.
211                Ok(client)
212            }
213            Err(error) => {
214                // Read the proxy's exit status before killing it: a connection
215                // the server dropped has already exited 255, and that status
216                // is what separates a refused connection from a broken worker.
217                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                            // Still running, or unwaitable. Stop the proxy so
222                            // it closes stderr; otherwise a proxy that is
223                            // merely slow would hold the drain task open past
224                            // its grace period and the tail would be lost. The
225                            // child stays in place so dropping `client` reaps
226                            // it as usual.
227                            _ => {
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    /// Collect the proxy's trailing stderr.
249    ///
250    /// The caller has already waited for the proxy or killed it, so the drain
251    /// normally reaches EOF at once. The grace period covers the case it
252    /// cannot: a grandchild that inherited stderr keeps the pipe open for as
253    /// long as it lives. Either way the lines already read are returned, since
254    /// the drain publishes them as it goes.
255    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    /// Attach the proxy's own stderr tail to a failed connect. The proxy
274    /// explains failures the controller cannot see any other way, such as a
275    /// worker socket path longer than `sun_path`.
276    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    /// Exchange `Hello` and record what the relay negotiated.
288    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}