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 prepared = tokio::task::spawn_blocking(move || {
129            // Lease before taking the permit: opening a master takes a
130            // permit of its own.
131            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            // Never inherit: the controller owns a TUI alternate screen, so a
176            // child writing to the shared stderr corrupts the display outside
177            // the renderer's buffer. Drain it into the log instead.
178            .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            // Keep the expected identity from process creation onward so a
256            // handshake failure and the dropped proxy that follows it remain
257            // attributable even when Hello never returns a session ID.
258            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                // Hello succeeded, so this connection is past authentication
271                // and no longer counts against the server's startup budget.
272                drop(permit);
273                handshake_done.store(true, Ordering::Release);
274                // The drain task keeps logging for the life of the connection.
275                Ok(client)
276            }
277            Err(error) => {
278                // Read the proxy's exit status before killing it: a connection
279                // the server dropped has already exited 255, and that status
280                // is what separates a refused connection from a broken worker.
281                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                            // Still running, or unwaitable. Stop the proxy so
286                            // it closes stderr; otherwise a proxy that is
287                            // merely slow would hold the drain task open past
288                            // its grace period and the tail would be lost. The
289                            // child stays in place so dropping `client` reaps
290                            // it as usual.
291                            _ => {
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                // A session turned away by the transport usually means its
307                // master died; make the retry check and reopen it.
308                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                    // A launch that will be retried is logged by the retry.
323                    // Its proxy has exited and was reaped above, so dropping
324                    // `client` must not report that exit as a second warning.
325                    Some(_) => drop(client.child.take()),
326                    // Anything else is a failure of this proxy or its worker.
327                    None => log_relay_client_failure(&client, "hello", "relay-hello", &error),
328                }
329                Err(ConnectFailure { error, retry })
330            }
331        }
332    }
333
334    /// Collect the proxy's trailing stderr.
335    ///
336    /// The caller has already waited for the proxy or killed it, so the drain
337    /// normally reaches EOF at once. The grace period covers the case it
338    /// cannot: a grandchild that inherited stderr keeps the pipe open for as
339    /// long as it lives. Either way the lines already read are returned, since
340    /// the drain publishes them as it goes.
341    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    /// Attach the proxy's own stderr tail to a failed connect. The proxy
360    /// explains failures the controller cannot see any other way, such as a
361    /// worker socket path longer than `sun_path`.
362    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    /// Exchange `Hello` and record what the relay negotiated.
374    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
415/// Whether a relay proxy that exited during hello found no control socket to
416/// connect to. The worker's proxy reports a failed connect as "connect worker
417/// socket <path>" (`mj-worker`'s `proxy`), and a socket file that does not
418/// exist yet as ENOENT.
419fn worker_socket_missing(stderr: &str) -> bool {
420    stderr.contains("connect worker socket") && stderr.contains("No such file or directory")
421}