Skip to main content

mj_controller/worker_client/
transport.rs

1use super::*;
2
3/// Keep transport, protocol, and explicit relay rejections visible at the
4/// point where a request fails. Callers often turn these into a user-facing
5/// string or a retry, which otherwise loses the operation and request ID that
6/// make concurrent session failures diagnosable.
7pub(super) fn log_relay_client_failure(
8    client: &RelayClient,
9    operation: &str,
10    request_id: &str,
11    error: &anyhow::Error,
12) {
13    let rejection = error.chain().find_map(|cause| {
14        cause
15            .downcast_ref::<RelayRejected>()
16            .map(|rejected| &rejected.0)
17    });
18    let transport_dead = RelayTransportDead::marks(error);
19    match rejection {
20        Some(rejection) => tracing::warn!(
21            session_id = %client.session_id,
22            relay_version = %client.relay_version,
23            %operation,
24            %request_id,
25            relay_error_code = ?rejection.code,
26            relay_retryable = rejection.retryable,
27            transport_dead,
28            error = %error,
29            "relay request rejected"
30        ),
31        None => tracing::warn!(
32            session_id = %client.session_id,
33            relay_version = %client.relay_version,
34            %operation,
35            %request_id,
36            transport_dead,
37            error = %error,
38            "relay request failed"
39        ),
40    }
41}
42
43impl Drop for RelayClient {
44    fn drop(&mut self) {
45        // Async owners call `detach` so EOF has a bounded chance to propagate
46        // through Podman or SSH before the launcher is stopped. Drop is the
47        // shutdown-safe fallback: it may run while Tokio's drivers are already
48        // gone, so its bounded reaper cannot use runtime work or Tokio timers.
49        drop(self.input.take());
50        let Some(child) = self.child.take() else {
51            return;
52        };
53        let session_id = self.session_id.clone();
54        // The proxy's SSH session stays leased until the reaper is done with
55        // the child.
56        let ssh_session = self.ssh_session.take();
57        if let Err(error) = std::thread::Builder::new()
58            .name("hel-relay-reaper".into())
59            .spawn(move || {
60                reap_dropped_relay_proxy(child, session_id);
61                // Hold the lease until this closure returns, which is after the
62                // reaper has finished with the child. The hold is a binding
63                // rather than an explicit `drop` because on a platform with no
64                // connection sharing the lease has no destructor, so dropping
65                // it explicitly is a no-op that clippy denies.
66                let _lease = ssh_session;
67            })
68        {
69            tracing::warn!(
70                session_id = %self.session_id,
71                %error,
72                "could not start dropped relay proxy reaper"
73            );
74        }
75    }
76}
77
78/// Let EOF traverse a proxy launcher, then stop and reap it without relying on
79/// an async runtime that may already be shutting down.
80pub(super) fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
81    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
82    loop {
83        match child.try_wait() {
84            Ok(Some(status)) => {
85                if !status.success() {
86                    tracing::warn!(
87                        %session_id,
88                        %status,
89                        "dropped relay proxy exited unsuccessfully"
90                    );
91                }
92                return;
93            }
94            Ok(None) if Instant::now() < deadline => {
95                std::thread::sleep(RELAY_PROXY_REAP_POLL);
96            }
97            Ok(None) => break,
98            Err(error) => {
99                tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
100                return;
101            }
102        }
103    }
104
105    if let Err(error) = child.start_kill()
106        && error.kind() != std::io::ErrorKind::NotFound
107    {
108        tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
109        return;
110    }
111    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
112    loop {
113        match child.try_wait() {
114            Ok(Some(_)) => return,
115            Ok(None) if Instant::now() < deadline => {
116                std::thread::sleep(RELAY_PROXY_REAP_POLL);
117            }
118            Ok(None) => {
119                tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
120                return;
121            }
122            Err(error) => {
123                tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
124                return;
125            }
126        }
127    }
128}
129
130pub(super) fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
131    match payload {
132        RelayResponsePayload::CredentialState {
133            present,
134            fingerprint,
135            freshness_epoch_ms,
136        } => Ok(CredentialSnapshot {
137            present,
138            fingerprint,
139            freshness_epoch_ms,
140        }),
141        _ => bail!("relay returned an unexpected credential state response"),
142    }
143}
144
145pub(super) fn skills_sync_state(
146    payload: RelayResponsePayload,
147) -> Result<mj_core::skills::SkillsSyncState> {
148    match payload {
149        RelayResponsePayload::SkillsState {
150            present,
151            fingerprint,
152        } => Ok(mj_core::skills::SkillsSyncState {
153            present,
154            fingerprint,
155        }),
156        _ => bail!("relay returned an unexpected skills state response"),
157    }
158}
159
160pub(super) fn github_token_snapshot(
161    payload: RelayResponsePayload,
162) -> Result<mj_core::credentials::GithubTokenSnapshot> {
163    match payload {
164        RelayResponsePayload::GithubTokenState {
165            present,
166            fingerprint,
167        } => Ok(mj_core::credentials::GithubTokenSnapshot {
168            present,
169            fingerprint,
170        }),
171        _ => bail!("relay returned an unexpected GitHub token state response"),
172    }
173}
174
175pub(super) async fn read_bounded_frame(
176    reader: &mut (impl AsyncBufRead + Unpin),
177    kind: ExchangeKind,
178) -> Result<Option<String>> {
179    read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
180}
181
182pub(super) async fn read_bounded_frame_with_limit(
183    reader: &mut (impl AsyncBufRead + Unpin),
184    maximum_bytes: usize,
185    kind: ExchangeKind,
186) -> Result<Option<String>> {
187    use mj_core::bounded_frame::{BoundedFrame, BoundedFrameError};
188    // A failed read and a half-written frame are transport deaths; the limit
189    // and encoding failures are protocol violations that a worker restart
190    // would not fix, so only the first two carry the marker.
191    let mut frame = match mj_core::bounded_frame::read_bounded_frame(reader, maximum_bytes).await {
192        Ok(BoundedFrame::Line(frame)) => frame,
193        Ok(BoundedFrame::End) => return Ok(None),
194        Ok(BoundedFrame::Truncated(_)) => {
195            return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
196                "relay proxy disconnected in the middle of a response frame",
197                kind,
198            )));
199        }
200        Err(BoundedFrameError::Io(error)) => {
201            return Err(RelayTransportDead::from_io(error, kind).into());
202        }
203        Err(BoundedFrameError::TooLarge) => bail!("relay response frame is too large"),
204    };
205    if frame.last() == Some(&b'\r') {
206        frame.pop();
207    }
208    String::from_utf8(frame)
209        .context("relay response is not UTF-8")
210        .map(Some)
211}
212
213pub(super) fn clip_catch_up_page(
214    page: RelayAttachment,
215    previous: &RelayCursor,
216    frontier: &RelayCursor,
217) -> Result<RelayEventPage> {
218    if previous.ordinal > frontier.ordinal {
219        bail!("relay catch-up starts beyond its fixed frontier");
220    }
221    if previous.ordinal == frontier.ordinal {
222        if previous != frontier {
223            bail!("relay catch-up cursor digest differs from its fixed frontier");
224        }
225        if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
226            bail!("relay attachment advanced beyond its advertised frontier");
227        }
228        return Ok(RelayEventPage {
229            events: Vec::new(),
230            through_ordinal: previous.ordinal,
231            through_digest: previous.digest.clone(),
232        });
233    }
234    if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
235        bail!("relay catch-up page did not advance");
236    }
237    if page.through_ordinal <= frontier.ordinal {
238        let through = RelayCursor {
239            ordinal: page.through_ordinal,
240            digest: page.through_digest.clone(),
241        };
242        if through.ordinal == frontier.ordinal && through != *frontier {
243            bail!("relay catch-up page digest differs from its fixed frontier");
244        }
245        return Ok(RelayEventPage {
246            events: page.events,
247            through_ordinal: through.ordinal,
248            through_digest: through.digest,
249        });
250    }
251
252    let events = page
253        .events
254        .into_iter()
255        .take_while(|event| event.ordinal <= frontier.ordinal)
256        .collect::<Vec<_>>();
257    let reached = events
258        .last()
259        .map(|event| RelayCursor {
260            ordinal: event.ordinal,
261            digest: event.digest.clone(),
262        })
263        .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
264    if reached != *frontier {
265        bail!("relay catch-up page does not contain its fixed frontier");
266    }
267    Ok(RelayEventPage {
268        events,
269        through_ordinal: reached.ordinal,
270        through_digest: reached.digest,
271    })
272}
273
274pub(super) fn decode_relay_response(
275    line: &str,
276    request_id: &str,
277    protocol: u32,
278) -> Result<RelayResponsePayload> {
279    let response: RelayResponseEnvelope =
280        serde_json::from_str(line).context("decode relay response")?;
281    if response.request_id != request_id {
282        bail!(
283            "relay response ID mismatch: expected {request_id}, got {}",
284            response.request_id
285        );
286    }
287    if response.protocol_version != protocol {
288        bail!(
289            "relay response protocol mismatch: expected {protocol}, got {}",
290            response.protocol_version
291        );
292    }
293    match response.body {
294        RelayResponseBody::Ok { payload } => Ok(payload),
295        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
296    }
297}
298
299pub(super) fn decode_relay_hello_response(
300    line: &str,
301    request_id: &str,
302) -> Result<RelayResponsePayload> {
303    let response: RelayResponseEnvelope =
304        serde_json::from_str(line).context("decode relay hello response")?;
305    if response.request_id != request_id {
306        bail!(
307            "relay response ID mismatch: expected {request_id}, got {}",
308            response.request_id
309        );
310    }
311    match response.body {
312        RelayResponseBody::Ok {
313            payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
314        } => {
315            if response.protocol_version != negotiated {
316                bail!(
317                    "relay hello envelope uses protocol {}, negotiated {negotiated}",
318                    response.protocol_version
319                );
320            }
321            Ok(payload)
322        }
323        RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
324        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
325    }
326}