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-console/ui-search/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}