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        if let Err(error) = std::thread::Builder::new()
55            .name("hel-relay-reaper".into())
56            .spawn(move || reap_dropped_relay_proxy(child, session_id))
57        {
58            tracing::warn!(
59                session_id = %self.session_id,
60                %error,
61                "could not start dropped relay proxy reaper"
62            );
63        }
64    }
65}
66
67/// Let EOF traverse a proxy launcher, then stop and reap it without relying on
68/// an async runtime that may already be shutting down.
69pub(super) fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
70    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
71    loop {
72        match child.try_wait() {
73            Ok(Some(status)) => {
74                if !status.success() {
75                    tracing::warn!(
76                        %session_id,
77                        %status,
78                        "dropped relay proxy exited unsuccessfully"
79                    );
80                }
81                return;
82            }
83            Ok(None) if Instant::now() < deadline => {
84                std::thread::sleep(RELAY_PROXY_REAP_POLL);
85            }
86            Ok(None) => break,
87            Err(error) => {
88                tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
89                return;
90            }
91        }
92    }
93
94    if let Err(error) = child.start_kill()
95        && error.kind() != std::io::ErrorKind::NotFound
96    {
97        tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
98        return;
99    }
100    let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
101    loop {
102        match child.try_wait() {
103            Ok(Some(_)) => return,
104            Ok(None) if Instant::now() < deadline => {
105                std::thread::sleep(RELAY_PROXY_REAP_POLL);
106            }
107            Ok(None) => {
108                tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
109                return;
110            }
111            Err(error) => {
112                tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
113                return;
114            }
115        }
116    }
117}
118
119pub(super) fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
120    match payload {
121        RelayResponsePayload::CredentialState {
122            present,
123            fingerprint,
124            freshness_epoch_ms,
125        } => Ok(CredentialSnapshot {
126            present,
127            fingerprint,
128            freshness_epoch_ms,
129        }),
130        _ => bail!("relay returned an unexpected credential state response"),
131    }
132}
133
134pub(super) fn skills_sync_state(
135    payload: RelayResponsePayload,
136) -> Result<mj_core::skills::SkillsSyncState> {
137    match payload {
138        RelayResponsePayload::SkillsState {
139            present,
140            fingerprint,
141        } => Ok(mj_core::skills::SkillsSyncState {
142            present,
143            fingerprint,
144        }),
145        _ => bail!("relay returned an unexpected skills state response"),
146    }
147}
148
149pub(super) fn github_token_snapshot(
150    payload: RelayResponsePayload,
151) -> Result<mj_core::credentials::GithubTokenSnapshot> {
152    match payload {
153        RelayResponsePayload::GithubTokenState {
154            present,
155            fingerprint,
156        } => Ok(mj_core::credentials::GithubTokenSnapshot {
157            present,
158            fingerprint,
159        }),
160        _ => bail!("relay returned an unexpected GitHub token state response"),
161    }
162}
163
164pub(super) async fn read_bounded_frame(
165    reader: &mut (impl AsyncBufRead + Unpin),
166    kind: ExchangeKind,
167) -> Result<Option<String>> {
168    read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
169}
170
171pub(super) async fn read_bounded_frame_with_limit(
172    reader: &mut (impl AsyncBufRead + Unpin),
173    maximum_bytes: usize,
174    kind: ExchangeKind,
175) -> Result<Option<String>> {
176    use mj_core::bounded_frame::{BoundedFrame, BoundedFrameError};
177    // A failed read and a half-written frame are transport deaths; the limit
178    // and encoding failures are protocol violations that a worker restart
179    // would not fix, so only the first two carry the marker.
180    let mut frame = match mj_core::bounded_frame::read_bounded_frame(reader, maximum_bytes).await {
181        Ok(BoundedFrame::Line(frame)) => frame,
182        Ok(BoundedFrame::End) => return Ok(None),
183        Ok(BoundedFrame::Truncated(_)) => {
184            return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
185                "relay proxy disconnected in the middle of a response frame",
186                kind,
187            )));
188        }
189        Err(BoundedFrameError::Io(error)) => {
190            return Err(RelayTransportDead::from_io(error, kind).into());
191        }
192        Err(BoundedFrameError::TooLarge) => bail!("relay response frame is too large"),
193    };
194    if frame.last() == Some(&b'\r') {
195        frame.pop();
196    }
197    String::from_utf8(frame)
198        .context("relay response is not UTF-8")
199        .map(Some)
200}
201
202pub(super) fn clip_catch_up_page(
203    page: RelayAttachment,
204    previous: &RelayCursor,
205    frontier: &RelayCursor,
206) -> Result<RelayEventPage> {
207    if previous.ordinal > frontier.ordinal {
208        bail!("relay catch-up starts beyond its fixed frontier");
209    }
210    if previous.ordinal == frontier.ordinal {
211        if previous != frontier {
212            bail!("relay catch-up cursor digest differs from its fixed frontier");
213        }
214        if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
215            bail!("relay attachment advanced beyond its advertised frontier");
216        }
217        return Ok(RelayEventPage {
218            events: Vec::new(),
219            through_ordinal: previous.ordinal,
220            through_digest: previous.digest.clone(),
221        });
222    }
223    if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
224        bail!("relay catch-up page did not advance");
225    }
226    if page.through_ordinal <= frontier.ordinal {
227        let through = RelayCursor {
228            ordinal: page.through_ordinal,
229            digest: page.through_digest.clone(),
230        };
231        if through.ordinal == frontier.ordinal && through != *frontier {
232            bail!("relay catch-up page digest differs from its fixed frontier");
233        }
234        return Ok(RelayEventPage {
235            events: page.events,
236            through_ordinal: through.ordinal,
237            through_digest: through.digest,
238        });
239    }
240
241    let events = page
242        .events
243        .into_iter()
244        .take_while(|event| event.ordinal <= frontier.ordinal)
245        .collect::<Vec<_>>();
246    let reached = events
247        .last()
248        .map(|event| RelayCursor {
249            ordinal: event.ordinal,
250            digest: event.digest.clone(),
251        })
252        .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
253    if reached != *frontier {
254        bail!("relay catch-up page does not contain its fixed frontier");
255    }
256    Ok(RelayEventPage {
257        events,
258        through_ordinal: reached.ordinal,
259        through_digest: reached.digest,
260    })
261}
262
263pub(super) fn decode_relay_response(
264    line: &str,
265    request_id: &str,
266    protocol: u32,
267) -> Result<RelayResponsePayload> {
268    let response: RelayResponseEnvelope =
269        serde_json::from_str(line).context("decode relay response")?;
270    if response.request_id != request_id {
271        bail!(
272            "relay response ID mismatch: expected {request_id}, got {}",
273            response.request_id
274        );
275    }
276    if response.protocol_version != protocol {
277        bail!(
278            "relay response protocol mismatch: expected {protocol}, got {}",
279            response.protocol_version
280        );
281    }
282    match response.body {
283        RelayResponseBody::Ok { payload } => Ok(payload),
284        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
285    }
286}
287
288pub(super) fn decode_relay_hello_response(
289    line: &str,
290    request_id: &str,
291) -> Result<RelayResponsePayload> {
292    let response: RelayResponseEnvelope =
293        serde_json::from_str(line).context("decode relay hello response")?;
294    if response.request_id != request_id {
295        bail!(
296            "relay response ID mismatch: expected {request_id}, got {}",
297            response.request_id
298        );
299    }
300    match response.body {
301        RelayResponseBody::Ok {
302            payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
303        } => {
304            if response.protocol_version != negotiated {
305                bail!(
306                    "relay hello envelope uses protocol {}, negotiated {negotiated}",
307                    response.protocol_version
308                );
309            }
310            Ok(payload)
311        }
312        RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
313        RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
314    }
315}