Skip to main content

trusty_console/search_uds/
routes.rs

1//! `/api/search/{*path}` — the HTTP face of trusty-search's socket (#6285).
2//!
3//! Why: the console-served dashboard at `/tools/search/` (#6155, PR #6384) is
4//! plain browser JavaScript. It speaks HTTP and Server-Sent Events and nothing
5//! else, while trusty-search now speaks framed JSON-RPC over a Unix socket. This
6//! module is the whole of the translation, so the SPA needs no fork and
7//! trusty-search needs no HTTP.
8//!
9//! What: one handler. It maps the request through [`super::map::map_request`],
10//! dials the socket, and answers either a JSON body or an open
11//! `text/event-stream`.
12//!
13//! ## The SSE bridge, frame for frame
14//!
15//! `trusty_search::service::rpc::streams` states the contract this side has to
16//! honour: one stream ITEM is exactly the JSON document one SSE `data:` line
17//! carried, parsed rather than prefixed. So the bridge re-prefixes it and adds
18//! nothing — the `{"type":"connected"}` opener, every `DaemonEvent`, every
19//! reindex progress event and the `{"type":"lag","skipped":N}` frame reach the
20//! browser byte-identical to what the daemon's own SSE route wrote.
21//!
22//! Two things the daemon's SSE route emitted that its RPC stream deliberately
23//! does not, and what happens to them here:
24//!
25//! - the `: heartbeat\n\n` comment every 20 s. It exists so an idle TCP body is
26//!   not torn down, and the browser hop is still TCP — so this module emits it,
27//!   on the same interval, rather than changing what the browser receives.
28//! - the terminal `data:` framing of a failure. A mid-stream failure becomes one
29//!   `{"type":"error","message":…}` event before the body closes, because the
30//!   SPA reads a closed reindex stream as a COMPLETED reindex
31//!   (`crates/trusty-search/ui/src/lib/views/Indexes.svelte`) and a silent close
32//!   would report a broken reindex as a finished one.
33//!
34//! A refusal that arrives BEFORE the first item — the reindex stream's "no
35//! progress record for this index" — becomes an HTTP status, not a `200`
36//! carrying an error event, which is what `GET /indexes/{id}/reindex/stream`
37//! answered over HTTP. That is why the first frame is read before the response
38//! head is built.
39//!
40//! Test: `tests` below, plus `tests/search_uds_bridge.rs`, which drives the
41//! whole router against a stub daemon socket.
42
43use std::path::{Path, PathBuf};
44
45use axum::body::{Body, Bytes};
46use axum::extract::{Path as AxumPath, Request, State};
47use axum::http::{StatusCode, header};
48use axum::response::{IntoResponse, Response};
49use futures_util::StreamExt as _;
50use serde_json::{Value, json};
51use tokio::sync::mpsc;
52use tracing::{debug, warn};
53use trusty_common::uds::UdsRpcError;
54use trusty_common::uds::stream_client::FramedStream;
55
56use super::map::{Call, map_request};
57use super::{
58    CALL_TIMEOUT, MAX_FRAME_BYTES, STREAM_OPEN_TIMEOUT, SearchRpcError, call, json_response,
59    open_stream,
60};
61use crate::server::AppState;
62
63/// How often an open stream emits an SSE keep-alive comment.
64///
65/// The same 20 s `trusty_search::service::server::reindex_handlers` used, so an
66/// idle browser connection sees the byte sequence it saw before the migration.
67const SSE_HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
68
69/// How many stream items may buffer between the socket reader and the browser.
70///
71/// Matches the daemon-side producer buffer (`streams::STREAM_BUFFER`), so
72/// neither side is the first to accumulate behind a slow reader.
73const SSE_BUFFER: usize = 64;
74
75/// `ANY /api/search/{*path}` — reach trusty-search over its socket.
76///
77/// Why a dedicated handler rather than a row in `proxy::routes::full_id`: that
78/// proxy forwards bytes to a base URL resolved from an `http_addr` discovery
79/// file, and trusty-search stops writing one (#6285, ADR-0032). #6286 and #6287
80/// deleted the memory and analyze rows for the same reason; the search row would
81/// have been deleted too, taking the dashboard with it.
82///
83/// What: map the request, resolve the socket, then either one unary exchange
84/// rendered as JSON or one stream bridged to Server-Sent Events. An unmapped
85/// path is `501` naming itself; an unresolvable or unreachable socket is `502`;
86/// a refusal is the status the same refusal carried over HTTP.
87///
88/// Test: `an_unmapped_path_is_not_implemented_and_names_itself` and
89/// `a_dead_socket_is_a_bad_gateway_not_an_empty_success`, plus every other
90/// case in `tests/search_uds_bridge.rs`.
91pub async fn search_api_handler(
92    State(state): State<AppState>,
93    AxumPath(path): AxumPath<String>,
94    req: Request,
95) -> Response {
96    let (parts, body) = req.into_parts();
97    let query = parts.uri.query().map(str::to_owned);
98
99    // #6285: the body becomes one request FRAME, so the frame budget is the body
100    // limit — one constant, not a second literal that can drift from it.
101    let body_limit = usize::try_from(MAX_FRAME_BYTES).unwrap_or(usize::MAX);
102    let body_bytes: Bytes = match axum::body::to_bytes(body, body_limit).await {
103        Ok(b) => b,
104        Err(e) => {
105            warn!("search_uds: could not read the request body: {e}");
106            return (
107                StatusCode::PAYLOAD_TOO_LARGE,
108                format!("request body exceeds the {MAX_FRAME_BYTES}-byte frame budget"),
109            )
110                .into_response();
111        }
112    };
113
114    let mapped = match map_request(&parts.method, &path, query.as_deref(), &body_bytes) {
115        Ok(call) => call,
116        Err(reason) => {
117            warn!(
118                "search_uds: {} /{path} is not mapped: {reason}",
119                parts.method
120            );
121            return (
122                StatusCode::NOT_IMPLEMENTED,
123                axum::Json(json!({ "error": reason, "service": super::SEARCH_SERVICE })),
124            )
125                .into_response();
126        }
127    };
128
129    let socket = match state.search_socket_path() {
130        Ok(p) => p,
131        Err(reason) => return SearchRpcError::Unresolved(reason).into_response(),
132    };
133
134    match mapped {
135        Call::Unary { method, params } => {
136            debug!("search_uds: {} /{path} → {method}", parts.method);
137            match call(&socket, method, params, CALL_TIMEOUT).await {
138                Ok(result) => json_response(&result),
139                Err(e) => e.into_response(),
140            }
141        }
142        Call::Stream { method, params } => {
143            debug!("search_uds: {} /{path} → {method} (stream)", parts.method);
144            stream_response(&socket, method, params, STREAM_OPEN_TIMEOUT).await
145        }
146    }
147}
148
149/// `ANY /proxy/search/{*path}` — the deprecated alias, kept working (#1849).
150///
151/// Why kept: the trusty-search SPA's own `base.js` documents `/proxy/search/` as
152/// a supported mount, so callers exist that predate the `/api/` rename. They
153/// would otherwise get `400 unknown daemon` once the search row leaves
154/// `proxy::routes::full_id`.
155/// Test: `deprecated_alias_reaches_the_same_handler` in
156/// `tests/search_uds_bridge.rs`.
157pub async fn deprecated_search_api_handler(
158    state: State<AppState>,
159    path: AxumPath<String>,
160    req: Request,
161) -> Response {
162    tracing::trace!("search_uds: DEPRECATED /proxy/search/… — use /api/search/… instead (#1849)");
163    search_api_handler(state, path, req).await
164}
165
166/// Open one stream and answer it as Server-Sent Events.
167///
168/// Why the first frame is read before the head is built: a streaming method can
169/// refuse — `search.index.reindex.stream` answers `404` for an index with no
170/// progress record — and that refusal arrives as the stream's FIRST frame, after
171/// the dial has already succeeded. Committing to `200 text/event-stream` before
172/// reading it would turn every such refusal into an empty stream, which the SPA
173/// reads as a finished reindex.
174/// What: peek, then either the refusal's own status or a `200` whose body starts
175/// with the peeked item.
176///
177/// `open_timeout` bounds everything up to that peek — the dial, the request
178/// write, and the first frame read, which share ONE deadline computed here. A
179/// listener whose backlog is full accepts the connection and then reads nothing,
180/// and without this bound the browser waits out
181/// [`super::STREAM_FRAME_TIMEOUT`]'s day for a response head. Abandoning the read
182/// is safe: the whole `FramedStream` is dropped on the timeout path, so no
183/// half-read line is left for anyone to resume. The parameter exists so a test
184/// need not wait the production minute.
185/// Test: `tests/search_uds_bridge.rs`'s
186/// `a_stream_refusal_before_the_first_item_is_an_http_status`, plus
187/// `a_socket_that_never_answers_is_a_prompt_bad_gateway` and
188/// `a_slow_open_and_a_silent_first_frame_share_one_budget` below.
189async fn stream_response(
190    socket: &Path,
191    method: &'static str,
192    params: Value,
193    open_timeout: std::time::Duration,
194) -> Response {
195    // #6285: one deadline, taken before the dial and reused for the first-frame
196    // read. Giving each step its own `open_timeout` let the two together run to
197    // twice the figure the constant names.
198    let deadline = tokio::time::Instant::now() + open_timeout;
199
200    let mut stream = match open_stream(socket, method, params, open_timeout).await {
201        Ok(s) => s,
202        Err(e) => return e.into_response(),
203    };
204
205    let peeked = match tokio::time::timeout_at(deadline, stream.next_frame()).await {
206        Ok(frame) => frame,
207        Err(_) => {
208            return SearchRpcError::Unreachable(format!(
209                "{} did not answer {method} within {}s",
210                super::SEARCH_SERVICE,
211                open_timeout.as_secs_f32()
212            ))
213            .into_response();
214        }
215    };
216
217    let first = match peeked {
218        Some(Ok(item)) => Some(item),
219        Some(Err(e)) => return stream_error(method, e).into_response(),
220        // A stream that ended with no items at all is still a well-formed empty
221        // answer; the browser gets an immediately-closed event stream, which is
222        // what the HTTP route did for a completed reindex with an empty replay.
223        None => None,
224    };
225
226    let head = futures_util::stream::iter(
227        first
228            .into_iter()
229            .map(|item| Ok::<Bytes, std::convert::Infallible>(sse_data(&item))),
230    );
231
232    Response::builder()
233        .status(StatusCode::OK)
234        .header(header::CONTENT_TYPE, "text/event-stream")
235        .header(header::CACHE_CONTROL, "no-cache")
236        // The same header the daemon's SSE routes set, so a reverse proxy in
237        // front of the console does not buffer the stream into uselessness.
238        .header("X-Accel-Buffering", "no")
239        .body(Body::from_stream(head.chain(sse_tail(stream, method))))
240        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
241}
242
243/// The rest of an open stream, as SSE frames plus keep-alive comments.
244///
245/// Why the reader runs in its own task rather than inside the `select!`:
246/// `FramedStream::next_frame` reads a line off a `BufReader`, and cancelling
247/// that mid-line — which a heartbeat tick would do — discards the bytes already
248/// read. Moving the read behind an `mpsc` makes both arms of the select
249/// cancel-safe, since `Receiver::recv` and `Interval::tick` both are.
250///
251/// The task also carries the disconnect signal: when the browser goes, axum
252/// drops this body and the receiver drops. The read itself selects on
253/// `Sender::closed()` so the task notices immediately rather than at the next
254/// frame — a status stream can be silent for minutes, and waiting for a frame
255/// that will never come would hold the socket, and the daemon's producer behind
256/// it, open for exactly that long. The same shape the daemon's own producer uses
257/// (`trusty_search::service::rpc::streams`). Either way the task returns,
258/// dropping the `FramedStream` and closing the socket, which is what ends the
259/// producer (`streams`'s "a dropped client stops the producer").
260///
261/// Test: `tests/search_uds_bridge.rs`'s `a_stream_reaches_the_browser_frame_for_frame`,
262/// `a_mid_stream_failure_becomes_an_error_event`, and
263/// `a_browser_disconnect_releases_the_daemon_socket` for the disconnect arm.
264fn sse_tail(
265    mut stream: FramedStream<Value>,
266    method: &'static str,
267) -> impl futures_util::Stream<Item = Result<Bytes, std::convert::Infallible>> {
268    let (tx, rx) = mpsc::channel::<Result<Value, UdsRpcError>>(SSE_BUFFER);
269    tokio::spawn(async move {
270        loop {
271            // Cancelling `next_frame` mid-line discards the bytes already read,
272            // which only matters to a reader that resumes. This arm never
273            // resumes: it returns, and the `FramedStream` is dropped with it.
274            let item = tokio::select! {
275                biased;
276                () = tx.closed() => return,
277                item = stream.next_frame() => item,
278            };
279            let Some(item) = item else { return };
280            let terminal = item.is_err();
281            if tx.send(item).await.is_err() || terminal {
282                return;
283            }
284        }
285    });
286
287    let heartbeat = tokio::time::interval_at(
288        tokio::time::Instant::now() + SSE_HEARTBEAT_INTERVAL,
289        SSE_HEARTBEAT_INTERVAL,
290    );
291
292    futures_util::stream::unfold(Some((rx, heartbeat)), move |state| async move {
293        let (mut rx, mut heartbeat) = state?;
294        tokio::select! {
295            biased;
296            item = rx.recv() => match item {
297                Some(Ok(value)) => Some((Ok(sse_data(&value)), Some((rx, heartbeat)))),
298                Some(Err(e)) => {
299                    // #6285: never a silent close. See the module docs.
300                    warn!("search_uds: {method} failed mid-stream: {e}");
301                    let event = json!({ "type": "error", "message": e.to_string() });
302                    Some((Ok(sse_data(&event)), None))
303                }
304                None => None,
305            },
306            _ = heartbeat.tick() => Some((
307                Ok(Bytes::from_static(b": heartbeat\n\n")),
308                Some((rx, heartbeat)),
309            )),
310        }
311    })
312}
313
314/// Encode one stream item as an SSE `data:` frame.
315///
316/// Why `to_string` on a `Value` rather than passing the raw line through: the
317/// stream carries parsed JSON, and re-serialising is what puts it back on one
318/// line — an embedded newline would split one event into two.
319/// Test: `sse_data_is_one_line_per_event`.
320fn sse_data(value: &Value) -> Bytes {
321    Bytes::from(format!("data: {value}\n\n"))
322}
323
324/// Turn a stream failure into the console's verdict about it.
325///
326/// Why: `UdsRpcError::Stream` is the daemon's own terminal error frame and
327/// carries its code, so it maps to the HTTP status that refusal had. Every other
328/// variant is a transport problem the console observed, which is a `502`.
329/// Test: `stream_error_carries_the_daemon_code`.
330fn stream_error(method: &str, e: UdsRpcError) -> SearchRpcError {
331    match e {
332        UdsRpcError::Stream { error, .. } => SearchRpcError::Refused {
333            code: error.code,
334            message: error.message,
335        },
336        other => SearchRpcError::Unreachable(format!(
337            "{} did not stream {method}: {other}",
338            super::SEARCH_SERVICE
339        )),
340    }
341}
342
343/// Resolve trusty-search's socket for the routes and the connector alike.
344///
345/// Why on `AppState` rather than a free call: the integration tests bind their
346/// own stub socket and need the router to dial it, and the alternative —
347/// `TRUSTY_DATA_DIR_OVERRIDE` — is process-global in a test binary that runs
348/// six connectors in parallel. The same argument `detect::AnalyzeConnector`
349/// records for taking a socket override.
350/// Test: `tests/search_uds_bridge.rs` drives every case through it.
351impl AppState {
352    /// The socket this console dials for trusty-search.
353    ///
354    /// # Errors
355    ///
356    /// When the data directory cannot be resolved or created.
357    pub(crate) fn search_socket_path(&self) -> Result<PathBuf, String> {
358        match &self.search_socket {
359            Some(p) => Ok(p.as_ref().clone()),
360            None => super::socket_path(),
361        }
362    }
363
364    /// Dial `socket` for trusty-search instead of the resolved path.
365    ///
366    /// Why: see the `AppState::search_socket` field doc — the alternative is a
367    /// process-global env var this crate's test binary cannot use safely.
368    /// Test: `tests/search_uds_bridge.rs` sets it on every case.
369    #[must_use]
370    pub fn with_search_socket(mut self, socket: PathBuf) -> Self {
371        self.search_socket = Some(std::sync::Arc::new(socket));
372        self
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use trusty_common::uds::server::RpcError;
380
381    /// Why: an event carrying a newline inside a string would split into two SSE
382    /// events and the SPA would parse neither.
383    /// Test: this is the test.
384    #[test]
385    fn sse_data_is_one_line_per_event() {
386        let framed = sse_data(&json!({ "message": "a\nb" }));
387        let text = String::from_utf8(framed.to_vec()).expect("utf-8");
388        assert_eq!(text, "data: {\"message\":\"a\\nb\"}\n\n");
389        assert_eq!(text.matches("\n\n").count(), 1);
390    }
391
392    /// Why: the daemon's terminal error frame is a refusal with a code, and it
393    /// must reach the browser as the status that code stands for — not as a
394    /// generic gateway failure that hides which index was missing.
395    /// Test: this is the test.
396    #[test]
397    fn stream_error_carries_the_daemon_code() {
398        let err = stream_error(
399            "search.index.reindex.stream",
400            UdsRpcError::Stream {
401                path: PathBuf::from("/tmp/x.sock"),
402                error: RpcError::new(-32004, "no reindex in progress for 'ghost'"),
403            },
404        );
405        assert_eq!(err.status(), StatusCode::NOT_FOUND);
406        assert!(err.message().contains("ghost"), "{err:?}");
407    }
408
409    /// Why: a listener that accepts and then never answers is the case the
410    /// per-frame budget cannot cover — that budget is a day, deliberately, so a
411    /// stalled reindex is not cut off. Without a separate bound on the OPEN, the
412    /// browser waits a day for a response head it will never get.
413    /// What: binds a socket that accepts, reads the request, and writes nothing,
414    /// then asks for a stream with a 300 ms open budget. The production budget
415    /// is [`STREAM_OPEN_TIMEOUT`]; the parameter is what lets this assert in
416    /// milliseconds.
417    /// Test: this is the test.
418    #[tokio::test(flavor = "multi_thread")]
419    async fn a_socket_that_never_answers_is_a_prompt_bad_gateway() {
420        let tmp = tempfile::TempDir::new().expect("tempdir");
421        let socket = tmp.path().join("silent.sock");
422        let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
423        let _accepting = tokio::spawn(async move {
424            let Ok((mut conn, _)) = listener.accept().await else {
425                return;
426            };
427            let mut sink = Vec::new();
428            // Read the request and answer nothing — the wedged-listener case.
429            let _ = tokio::io::AsyncReadExt::read_to_end(&mut conn, &mut sink).await;
430            std::future::pending::<()>().await;
431        });
432
433        let started = std::time::Instant::now();
434        let response = stream_response(
435            &socket,
436            super::super::METHOD_STATUS_STREAM,
437            json!({}),
438            std::time::Duration::from_millis(300),
439        )
440        .await;
441
442        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
443        assert!(
444            started.elapsed() < std::time::Duration::from_secs(5),
445            "the open must be bounded, not left to the per-frame budget: {:?}",
446            started.elapsed()
447        );
448    }
449
450    /// Bind a listener that accepts, stalls `drain_after`, then drains the
451    /// request and answers nothing at all.
452    ///
453    /// The stall is what makes the OPEN slow rather than instant: the request
454    /// frame is larger than any plausible UNIX-socket send buffer, so the
455    /// client's `write_all` cannot finish until this end starts reading.
456    fn stalls_then_drains(socket: PathBuf, drain_after: std::time::Duration) -> PathBuf {
457        let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
458        tokio::spawn(async move {
459            let Ok((mut conn, _)) = listener.accept().await else {
460                return;
461            };
462            tokio::time::sleep(drain_after).await;
463            let mut sink = Vec::new();
464            let _ = tokio::io::AsyncReadExt::read_to_end(&mut conn, &mut sink).await;
465            std::future::pending::<()>().await;
466        });
467        socket
468    }
469
470    /// A request frame past any plausible socket send buffer.
471    fn bulky_params() -> Value {
472        json!({ "blob": "x".repeat(512 * 1024) })
473    }
474
475    /// Why: the open has two waiting steps — the dial-and-write inside
476    /// [`open_stream`] and the first frame read here — and giving each its own
477    /// full `open_timeout` made the real ceiling twice the figure
478    /// [`STREAM_OPEN_TIMEOUT`], its doc comment and the changelog all name. A
479    /// browser waiting two minutes for a bound documented as one is the defect.
480    /// What: two phases against the same stub shape, because the ceiling alone
481    /// would pass vacuously if the open happened to be fast. Phase one proves
482    /// the open both SUCCEEDS and takes about `DRAIN_AFTER`. Phase two runs the
483    /// same shape through `stream_response`, whose first-frame read then waits
484    /// for a frame that never comes: one shared deadline returns at about
485    /// `BUDGET`, two separate ones would run to `DRAIN_AFTER + BUDGET`.
486    /// Test: this is the test.
487    #[tokio::test(flavor = "multi_thread")]
488    async fn a_slow_open_and_a_silent_first_frame_share_one_budget() {
489        const BUDGET: std::time::Duration = std::time::Duration::from_millis(1000);
490        const DRAIN_AFTER: std::time::Duration = std::time::Duration::from_millis(600);
491        // Above `BUDGET`, so one shared deadline clears it; below
492        // `DRAIN_AFTER + BUDGET`, so two separate deadlines cannot.
493        const CEILING: std::time::Duration = std::time::Duration::from_millis(1300);
494
495        let tmp = tempfile::TempDir::new().expect("tempdir");
496
497        let slow = stalls_then_drains(tmp.path().join("slow-open.sock"), DRAIN_AFTER);
498        let started = std::time::Instant::now();
499        let opened = open_stream(
500            &slow,
501            super::super::METHOD_STATUS_STREAM,
502            bulky_params(),
503            BUDGET,
504        )
505        .await;
506        let open_took = started.elapsed();
507        assert!(opened.is_ok(), "the open must succeed, slowly: {opened:?}");
508        assert!(
509            open_took >= DRAIN_AFTER,
510            "the write must park until the peer drains, or this test proves nothing: {open_took:?}"
511        );
512        drop(opened);
513
514        let silent = stalls_then_drains(tmp.path().join("silent-frame.sock"), DRAIN_AFTER);
515        let started = std::time::Instant::now();
516        let response = stream_response(
517            &silent,
518            super::super::METHOD_STATUS_STREAM,
519            bulky_params(),
520            BUDGET,
521        )
522        .await;
523        let elapsed = started.elapsed();
524
525        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
526        assert!(
527            elapsed >= BUDGET,
528            "a shared deadline still spends the whole budget: {elapsed:?}"
529        );
530        assert!(
531            elapsed < CEILING,
532            "the dial, the write and the first frame read must share ONE {BUDGET:?} deadline, \
533             not take one each: {elapsed:?}"
534        );
535    }
536
537    /// Why: everything that is not the daemon's own refusal is the console
538    /// failing to reach it, and must not be dressed up as a daemon verdict.
539    /// Test: this is the test.
540    #[test]
541    fn stream_error_reports_a_transport_failure_as_unreachable() {
542        let err = stream_error(
543            "search.status.stream",
544            UdsRpcError::NoResponse {
545                path: PathBuf::from("/tmp/x.sock"),
546            },
547        );
548        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
549    }
550}