Skip to main content

netget/server/grpc/
mod.rs

1//! gRPC server implementation with dynamic schema support
2//!
3//! Implements a gRPC server where the LLM provides protobuf schema definitions
4//! and controls RPC request/response handling through JSON.
5
6pub mod actions;
7#[cfg(not(target_arch = "wasm32"))]
8pub mod http1;
9mod reflection;
10pub(crate) mod schema;
11pub mod stream_codec;
12pub(crate) mod streaming;
13pub mod value_codec;
14pub use value_codec::{dynamic_message_to_json, json_to_dynamic_message, proto_value_to_json};
15
16use anyhow::{bail, Context, Result};
17use std::net::SocketAddr;
18use std::sync::Arc;
19use tokio::sync::mpsc;
20use tracing::{debug, info, trace, warn};
21
22#[cfg(not(target_arch = "wasm32"))]
23use crate::llm::action_helper::call_llm;
24#[cfg(not(target_arch = "wasm32"))]
25use crate::llm::ollama_client::OllamaClient;
26#[cfg(not(target_arch = "wasm32"))]
27use crate::llm::ActionResult;
28#[cfg(not(target_arch = "wasm32"))]
29use crate::logging::emit::Log;
30#[cfg(not(target_arch = "wasm32"))]
31use crate::protocol::Event;
32#[cfg(not(target_arch = "wasm32"))]
33use crate::server::grpc::actions::GRPC_UNARY_REQUEST_EVENT;
34#[cfg(not(target_arch = "wasm32"))]
35use crate::server::GrpcProtocol;
36#[cfg(not(target_arch = "wasm32"))]
37use crate::state::app_state::AppState;
38use crate::{console_error, console_info};
39#[cfg(not(target_arch = "wasm32"))]
40use bytes::Bytes;
41#[cfg(not(target_arch = "wasm32"))]
42use http_body_util::{BodyExt, Limited};
43#[cfg(not(target_arch = "wasm32"))]
44use hyper::{body::Incoming, header::HeaderValue, Request, Response, StatusCode};
45#[cfg(not(target_arch = "wasm32"))]
46use prost::Message;
47#[cfg(not(target_arch = "wasm32"))]
48use prost_reflect::{DescriptorPool, DynamicMessage};
49#[cfg(not(target_arch = "wasm32"))]
50use serde_json::json;
51
52/// Largest request body buffered, before gRPC framing. Matches gRPC's own default
53/// `maxReceiveMessageLength` of 4 MiB.
54#[cfg(not(target_arch = "wasm32"))]
55pub const MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
56
57/// How long to wait for a peer's first byte after it connects.
58///
59/// HTTP/2 is client-speaks-first — the connection preface and the client's SETTINGS frame are
60/// the first thing on the wire and the server says nothing before them — and every gRPC client
61/// sends them inside its dial path. A peer that has connected and sent nothing has begun no
62/// connection at all, which is the state an unauthenticated flood lives in. Enforced with
63/// `TcpStream::peek` *before* the socket reaches hyper, so the preface is still there for hyper
64/// afterwards.
65#[cfg(not(target_arch = "wasm32"))]
66const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
67
68/// How long a connection may do nothing at all once it is up.
69///
70/// Keepalive is off by default. This watchdog runs only while there are no live
71/// RPCs: a response body retains its busy guard through EOS/drop, including
72/// a subscription, flow-control stall or parked handler. Each stream separately
73/// has a whole-operation deadline.
74#[cfg(not(target_arch = "wasm32"))]
75const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
76
77/// Concurrent connections this server admits.
78///
79/// Each admitted connection may buffer a request of up to [`MAX_REQUEST_BYTES`] and holds an
80/// HTTP/2 session with its own flow-control windows, so the cap is what turns that
81/// per-connection bound into a total one.
82#[cfg(not(target_arch = "wasm32"))]
83const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
84
85/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
86///
87/// HTTP/1.1 `503 Service Unavailable` with a `Retry-After`, deliberately in the older protocol —
88/// the same choice `src/server/etcd/mod.rs` makes and for the same reason. A refused peer has
89/// not sent the HTTP/2 preface yet, so nothing has been negotiated, and a GOAWAY would have to
90/// follow a SETTINGS exchange this server is declining to perform. A gRPC client reports a
91/// transport failure either way, but the bytes on the wire now say why, and `curl` or a human
92/// can read it.
93#[cfg(not(target_arch = "wasm32"))]
94const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
95    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
96
97/// gRPC status codes (`google.rpc.Code`) this server produces.
98///
99/// `grpc_error`'s documented `code` string maps onto these. It previously did not map onto
100/// anything: the code was parsed, logged, and then folded into a `bail!` message, so every
101/// error reached the client as `13 INTERNAL` with the real code smuggled into the text of
102/// `grpc-message`.
103#[cfg(not(target_arch = "wasm32"))]
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105#[repr(i32)]
106pub enum GrpcStatus {
107    Ok = 0,
108    Cancelled = 1,
109    Unknown = 2,
110    InvalidArgument = 3,
111    DeadlineExceeded = 4,
112    NotFound = 5,
113    AlreadyExists = 6,
114    PermissionDenied = 7,
115    ResourceExhausted = 8,
116    FailedPrecondition = 9,
117    Aborted = 10,
118    OutOfRange = 11,
119    Unimplemented = 12,
120    Internal = 13,
121    Unavailable = 14,
122    DataLoss = 15,
123    Unauthenticated = 16,
124}
125
126#[cfg(not(target_arch = "wasm32"))]
127impl GrpcStatus {
128    /// Parse the `code` field of a `grpc_error` action. Accepts the canonical spec spellings
129    /// (`NOT_FOUND`), lowercase, and the bare integer. Anything unrecognized is `UNKNOWN`
130    /// rather than a silent `INTERNAL`, so a typo is visible as a typo.
131    fn parse(code: &str) -> Self {
132        if let Ok(n) = code.trim().parse::<i32>() {
133            return Self::from_i32(n);
134        }
135        match code.trim().to_ascii_uppercase().replace('-', "_").as_str() {
136            "OK" => Self::Ok,
137            "CANCELLED" | "CANCELED" => Self::Cancelled,
138            "UNKNOWN" => Self::Unknown,
139            "INVALID_ARGUMENT" => Self::InvalidArgument,
140            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
141            "NOT_FOUND" => Self::NotFound,
142            "ALREADY_EXISTS" => Self::AlreadyExists,
143            "PERMISSION_DENIED" => Self::PermissionDenied,
144            "RESOURCE_EXHAUSTED" => Self::ResourceExhausted,
145            "FAILED_PRECONDITION" => Self::FailedPrecondition,
146            "ABORTED" => Self::Aborted,
147            "OUT_OF_RANGE" => Self::OutOfRange,
148            "UNIMPLEMENTED" | "NOT_IMPLEMENTED" => Self::Unimplemented,
149            "INTERNAL" => Self::Internal,
150            "UNAVAILABLE" => Self::Unavailable,
151            "DATA_LOSS" => Self::DataLoss,
152            "UNAUTHENTICATED" => Self::Unauthenticated,
153            _ => Self::Unknown,
154        }
155    }
156
157    fn from_i32(n: i32) -> Self {
158        match n {
159            0 => Self::Ok,
160            1 => Self::Cancelled,
161            2 => Self::Unknown,
162            3 => Self::InvalidArgument,
163            4 => Self::DeadlineExceeded,
164            5 => Self::NotFound,
165            6 => Self::AlreadyExists,
166            7 => Self::PermissionDenied,
167            8 => Self::ResourceExhausted,
168            9 => Self::FailedPrecondition,
169            10 => Self::Aborted,
170            11 => Self::OutOfRange,
171            12 => Self::Unimplemented,
172            14 => Self::Unavailable,
173            15 => Self::DataLoss,
174            16 => Self::Unauthenticated,
175            _ => Self::Internal,
176        }
177    }
178}
179
180/// The gRPC status a failed handler call must be reported as.
181///
182/// `UNAVAILABLE` (14) when the failure was the LLM backend being saturated, `INTERNAL` (13)
183/// otherwise. The distinction is what gRPC client retry policies key on: `UNAVAILABLE` is in
184/// the default retryable set and is the code a `grpc-service-config` `retryableStatusCodes`
185/// list names, while `INTERNAL` is explicitly *not* retryable. Reporting a transient
186/// rate-limiter refusal as `INTERNAL` converts a backlog that clears in seconds into a hard
187/// application error for every caller.
188///
189/// Both are non-zero and both travel through `grpc_error_response`, which sends an empty body,
190/// so neither can be mistaken for the `grpc-status: 0` + encoded message a success carries.
191///
192/// `pub` so `tests/` can exercise the classification directly — the project forbids
193/// `#[cfg(test)]` modules in `src/`, and driving the rate limiter to refusal through a spawned
194/// binary needs bounds the E2E harness cannot set.
195#[cfg(not(target_arch = "wasm32"))]
196pub fn grpc_status_for_llm_failure(err: &anyhow::Error) -> GrpcStatus {
197    if crate::llm::is_overload_error(err) {
198        GrpcStatus::Unavailable
199    } else {
200        GrpcStatus::Internal
201    }
202}
203
204/// Classify a peer value conversion refusal without exposing its data in an error header.
205#[cfg(not(target_arch = "wasm32"))]
206pub fn grpc_status_for_value_failure(error: &anyhow::Error) -> GrpcStatus {
207    if error
208        .downcast_ref::<value_codec::ValueLimitExceeded>()
209        .is_some()
210    {
211        GrpcStatus::ResourceExhausted
212    } else {
213        GrpcStatus::InvalidArgument
214    }
215}
216
217/// Reduce a status message to the visible-ASCII subset `grpc-message` can carry.
218///
219/// `HeaderValue` accepts only visible ASCII, and these messages come from LLM output and
220/// `anyhow` chains — netget's own LLM errors begin with a literal `✗`, and multi-line `anyhow`
221/// context chains are routine. Substitution rather than rejection: a mangled character is a far
222/// better outcome than a discarded explanation, which is what the static fallback in
223/// `grpc_error_response` amounts to. Capped at 512 characters because a status message is a
224/// diagnostic, not a payload, and everything is ASCII by then so the cut cannot split a
225/// codepoint.
226#[cfg(not(target_arch = "wasm32"))]
227fn header_safe(message: &str) -> String {
228    let mut out = String::with_capacity(message.len().min(512));
229    let mut last_was_space = false;
230    for c in message.chars() {
231        let c = if (' '..='~').contains(&c) { c } else { ' ' };
232        if c == ' ' {
233            if last_was_space {
234                continue;
235            }
236            last_was_space = true;
237        } else {
238            last_was_space = false;
239        }
240        if out.chars().count() >= 512 {
241            break;
242        }
243        out.push(c);
244    }
245    let trimmed = out.trim();
246    if trimmed.is_empty() {
247        "internal error".to_string()
248    } else {
249        trimmed.to_string()
250    }
251}
252
253/// A handler failure carrying the gRPC status it should be reported as.
254#[cfg(not(target_arch = "wasm32"))]
255struct GrpcFailure {
256    status: GrpcStatus,
257    message: String,
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261impl GrpcFailure {
262    fn new(status: GrpcStatus, message: impl Into<String>) -> Self {
263        Self {
264            status,
265            message: message.into(),
266        }
267    }
268}
269
270#[cfg(not(target_arch = "wasm32"))]
271impl From<anyhow::Error> for GrpcFailure {
272    fn from(e: anyhow::Error) -> Self {
273        // Classify here too, not only at the explicit call site: `?` on an anyhow error is
274        // reachable from several places in this module and an overload must never be reported
275        // as a non-retryable INTERNAL just because it took the implicit path.
276        //
277        // `grpc-message` is a client-visible header, so it carries the category and not the
278        // error - see `crate::utils::wire_failure`. The error itself is logged at the call
279        // sites that have a status channel.
280        let status = grpc_status_for_llm_failure(&e);
281        Self::new(status, crate::utils::WireFailure::classify(&e).text())
282    }
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286impl From<serde_json::Error> for GrpcFailure {
287    fn from(e: serde_json::Error) -> Self {
288        // Logged in full; the peer gets the category only.
289        debug!("gRPC JSON conversion failed: {e}");
290        Self::new(GrpcStatus::Internal, "request could not be processed")
291    }
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295impl From<prost::EncodeError> for GrpcFailure {
296    fn from(e: prost::EncodeError) -> Self {
297        debug!("gRPC response encoding failed: {e}");
298        Self::new(GrpcStatus::Internal, "could not encode response")
299    }
300}
301
302/// gRPC server with dynamic schema support
303pub struct GrpcServer;
304
305#[cfg(not(target_arch = "wasm32"))]
306impl GrpcServer {
307    /// Spawn gRPC server with LLM-provided schema and actions
308    ///
309    /// The LLM provides a protobuf schema definition (as a string) via startup_params.
310    /// The server parses requests into JSON, sends to LLM, and encodes JSON responses back to protobuf.
311    pub async fn spawn_with_llm_actions(
312        listen_addr: SocketAddr,
313        llm_client: OllamaClient,
314        app_state: Arc<AppState>,
315        status_tx: mpsc::UnboundedSender<String>,
316        server_id: crate::state::ServerId,
317        startup_params: Option<crate::protocol::StartupParams>,
318    ) -> Result<SocketAddr> {
319        // Extract proto schema from startup params
320        let proto_schema = startup_params
321            .as_ref()
322            .map(|p| p.get_string("proto_schema"))
323            .transpose()?
324            .context(
325                "Missing 'proto_schema' in startup_params. LLM must provide protobuf definition.",
326            )?;
327
328        trace!("Proto schema:\n{}", proto_schema);
329        Log::new(Some(&status_tx)).debug("Compiling protobuf schema for gRPC server");
330
331        let descriptor_pool = schema::load(&proto_schema)
332            .await
333            .context("Failed to compile/load protobuf schema")?;
334        let reflection_enabled = startup_params
335            .as_ref()
336            .map(|p| p.get_optional_bool("enable_reflection"))
337            .transpose()?
338            .flatten()
339            .unwrap_or(streaming::DEFAULT_REFLECTION);
340        let timeout_secs = startup_params
341            .as_ref()
342            .map(|p| p.get_optional_u64("stream_timeout_secs"))
343            .transpose()?
344            .flatten()
345            .unwrap_or(streaming::DEFAULT_TIMEOUT_SECS);
346        anyhow::ensure!(
347            (1..=3600).contains(&timeout_secs),
348            "stream_timeout_secs must be 1..3600"
349        );
350        let reflection = if reflection_enabled {
351            Some(reflection::Reflection::new(&descriptor_pool)?)
352        } else {
353            None
354        };
355
356        let services = descriptor_pool.services().collect::<Vec<_>>();
357
358        if services.is_empty() {
359            bail!("No services found in protobuf schema. Schema must define at least one service.");
360        }
361
362        info!("gRPC server starting with {} service(s)", services.len());
363        let log = Log::new(Some(&status_tx));
364        for service in &services {
365            log.info(format!(
366                "gRPC service: {} ({} methods)",
367                service.full_name(),
368                service.methods().count()
369            ));
370        }
371
372        // Create gRPC server with dynamic handler
373        let protocol = Arc::new(GrpcProtocol::new());
374        let descriptor_pool_arc = Arc::new(descriptor_pool.clone());
375
376        // Create dynamic gRPC service
377        let dynamic_service = DynamicGrpcService {
378            llm_client: llm_client.clone(),
379            app_state: app_state.clone(),
380            status_tx: status_tx.clone(),
381            server_id,
382            descriptor_pool: descriptor_pool_arc,
383            protocol,
384            reflection,
385            stream_timeout: std::time::Duration::from_secs(timeout_secs),
386            streams: Arc::new(tokio::sync::Semaphore::new(streaming::MAX_ACTIVE)),
387            next_stream_id: Arc::new(std::sync::atomic::AtomicU32::new(1)),
388        };
389
390        // Start HTTP/2 server for gRPC
391        let listener =
392            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
393        let actual_addr = listener.local_addr()?;
394
395        console_info!(status_tx, "gRPC server listening on {}", actual_addr);
396
397        // Spawn server loop
398        let service = Arc::new(dynamic_service);
399        let task_registrar = app_state.clone();
400        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
401        let accept_handle = tokio::spawn(async move {
402            loop {
403                match crate::server::accept_bounded::accept_bounded(
404                    &listener,
405                    &limiter,
406                    CONNECTION_CAP_REFUSAL,
407                    "gRPC",
408                    Some(&status_tx),
409                )
410                .await
411                {
412                    Ok((stream, remote_addr, permit)) => {
413                        let connection_id = crate::server::connection::ConnectionId::new(
414                            service.app_state.get_next_unified_id().await,
415                        );
416                        debug!("gRPC connection {} from {}", connection_id, remote_addr);
417                        Log::new(Some(&status_tx))
418                            .debug(format!("gRPC connection from {}", remote_addr));
419
420                        // Add connection to server state
421                        use crate::state::server::{
422                            ConnectionState as ServerConnectionState, ConnectionStatus,
423                            ProtocolConnectionInfo,
424                        };
425                        let now = crate::utils::clock::Instant::now();
426                        let conn_state = ServerConnectionState {
427                            id: connection_id,
428                            remote_addr,
429                            local_addr: actual_addr,
430                            bytes_sent: 0,
431                            bytes_received: 0,
432                            packets_sent: 0,
433                            packets_received: 0,
434                            last_activity: now,
435                            status: ConnectionStatus::Active,
436                            status_changed_at: now,
437                            protocol_info: ProtocolConnectionInfo::empty(),
438                        };
439                        app_state
440                            .add_connection_to_server(server_id, conn_state)
441                            .await;
442                        let _ = status_tx.send("__UPDATE_UI__".to_string());
443
444                        let service_clone = service.clone();
445                        let app_state_clone = app_state.clone();
446                        let status_tx_clone = status_tx.clone();
447
448                        // Spawn connection handler
449                        // Tracked, not detached: stop_server must abort this task too.
450                        let task_owner = app_state.clone();
451                        task_owner
452                            .spawn_server_task(server_id, async move {
453                                // Held for the life of the connection: dropping it early releases
454                                // the slot while the peer is still here, which silently un-caps
455                                // the server.
456                                let _permit = permit;
457
458                                // First-byte bound, before hyper sees the socket. hyper owns every
459                                // read once `serve_connection` starts and keeps polling the
460                                // connection for frames *while a request is being answered*, so a
461                                // deadline on reads would fire in the middle of an LLM round-trip.
462                                // `peek` waits for data without consuming it, so the HTTP/2 preface
463                                // is still there for hyper afterwards.
464                                match tokio::time::timeout(
465                                    FIRST_BYTE_READ_TIMEOUT,
466                                    stream.peek(&mut [0u8; 1]),
467                                )
468                                .await
469                                {
470                                    Ok(Ok(0)) | Ok(Err(_)) => {
471                                        app_state_clone
472                                            .remove_connection_from_server(server_id, connection_id)
473                                            .await;
474                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
475                                        return;
476                                    }
477                                    Ok(Ok(_)) => {}
478                                    Err(_) => {
479                                        Log::new(Some(&status_tx_clone)).debug(format!(
480                                        "gRPC peer {} sent nothing for {}s; closing before the \
481                                         HTTP/2 preface",
482                                        remote_addr,
483                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
484                                    ));
485                                        app_state_clone
486                                            .remove_connection_from_server(server_id, connection_id)
487                                            .await;
488                                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
489                                        return;
490                                    }
491                                }
492
493                                let io = hyper_util::rt::TokioIo::new(stream);
494
495                                // Tracks whether this connection is answering anything. A request
496                                // waiting on the model, or parked for a human at the dashboard,
497                                // holds the count above zero, so the idle watchdog below cannot
498                                // close the connection the answer belongs to however long it takes
499                                // — only genuine silence counts.
500                                let activity = Arc::new(
501                                    crate::server::accept_bounded::ConnectionActivity::new(),
502                                );
503                                let activity_for_service = Arc::clone(&activity);
504                                let executor = streaming::OwnedExecutor::default();
505                                let _children = streaming::ConnectionTasks(executor.clone());
506                                let registry = streaming::Registry::default();
507                                let peer_rx = crate::server::peer_support::register_peer_channel(
508                                    &app_state_clone,
509                                    server_id,
510                                    connection_id.as_u32(),
511                                )
512                                .await;
513                                let _ = executor.spawn(streaming::command_loop(
514                                    peer_rx,
515                                    registry.clone(),
516                                    service_clone.clone(),
517                                    connection_id,
518                                ));
519                                let executor_for_service = executor.clone();
520
521                                // Create service function for this connection
522                                let grpc_service = hyper::service::service_fn(move |req| {
523                                    let service = service_clone.clone();
524                                    let conn_id = connection_id;
525                                    let activity = Arc::clone(&activity_for_service);
526                                    let registry = registry.clone();
527                                    let executor = executor_for_service.clone();
528                                    async move {
529                                        let busy = activity.busy();
530                                        service
531                                            .handle_grpc_request(
532                                                req, conn_id, busy, registry, executor,
533                                            )
534                                            .await
535                                    }
536                                });
537
538                                // Serve HTTP/2 connection
539                                let conn = hyper::server::conn::http2::Builder::new(executor)
540                                    .max_concurrent_streams(16)
541                                    .initial_stream_window_size(65536)
542                                    .initial_connection_window_size(1048576)
543                                    .max_header_list_size(32768)
544                                    .max_frame_size(16384)
545                                    .serve_connection(io, grpc_service);
546                                tokio::pin!(conn);
547                                tokio::select! {
548                                    result = &mut conn => {
549                                        if let Err(e) = result {
550                                            Log::new(Some(&status_tx_clone))
551                                                .debug(format!("gRPC connection error: {}", e));
552                                        }
553                                    }
554                                    _ = crate::server::accept_bounded::watch_idle(
555                                        Arc::clone(&activity),
556                                        IDLE_BETWEEN_REQUESTS_TIMEOUT,
557                                    ) => {
558                                        debug!(
559                                            "gRPC connection {} idle for {}s; closing",
560                                            connection_id,
561                                            IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
562                                        );
563                                    }
564                                }
565
566                                // Clean up connection
567                                app_state_clone
568                                    .remove_peer_handle(server_id, connection_id.as_u32())
569                                    .await;
570                                app_state_clone
571                                    .remove_connection_from_server(server_id, connection_id)
572                                    .await;
573                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
574                            })
575                            .await;
576                    }
577                    Err(e) => {
578                        console_error!(status_tx, "Failed to accept gRPC connection: {}", e);
579                        break;
580                    }
581                }
582            }
583        });
584
585        task_registrar
586            .register_server_task(server_id, accept_handle)
587            .await;
588
589        Ok(actual_addr)
590    }
591}
592
593/// Dynamic gRPC service that handles requests using LLM
594#[cfg(not(target_arch = "wasm32"))]
595#[derive(Clone)]
596struct DynamicGrpcService {
597    llm_client: OllamaClient,
598    app_state: Arc<AppState>,
599    status_tx: mpsc::UnboundedSender<String>,
600    server_id: crate::state::ServerId,
601    descriptor_pool: Arc<DescriptorPool>,
602    protocol: Arc<dyn crate::llm::actions::protocol_trait::Server>,
603    reflection: Option<Arc<reflection::Reflection>>,
604    stream_timeout: std::time::Duration,
605    streams: Arc<tokio::sync::Semaphore>,
606    next_stream_id: Arc<std::sync::atomic::AtomicU32>,
607}
608
609/// HTTP/1 RPC bindings share typed method handling, admission and cancellation with native
610/// gRPC. Each binding owns its framing; legacy unary behavior is untouched.
611#[cfg(not(target_arch = "wasm32"))]
612#[derive(Clone)]
613pub(crate) struct HttpCore(Arc<DynamicGrpcService>);
614#[cfg(not(target_arch = "wasm32"))]
615pub(crate) struct WebAdmission {
616    pub(crate) deadline: tokio::time::Instant,
617    permit: tokio::sync::OwnedSemaphorePermit,
618    timer: streaming::DeadlineGuard,
619    pub(crate) method: prost_reflect::MethodDescriptor,
620    id: u32,
621}
622#[cfg(not(target_arch = "wasm32"))]
623impl WebAdmission {
624    /// Wrap the final status-converted body, so admission survives every reply path.
625    pub(crate) fn guard(
626        self,
627        response: Response<tonic::body::BoxBody>,
628        busy: crate::server::accept_bounded::BusyGuard,
629    ) -> Response<tonic::body::BoxBody> {
630        response.map(|body| http1::hold_body(body, (self.permit, busy, self.timer)))
631    }
632}
633#[cfg(not(target_arch = "wasm32"))]
634impl HttpCore {
635    pub(crate) fn new(
636        ctx: &crate::protocol::SpawnContext,
637        pool: DescriptorPool,
638        protocol: Arc<dyn crate::llm::actions::protocol_trait::Server>,
639        timeout: std::time::Duration,
640    ) -> Self {
641        Self(Arc::new(DynamicGrpcService {
642            llm_client: ctx.llm_client.clone(),
643            app_state: ctx.state.clone(),
644            status_tx: ctx.status_tx.clone(),
645            server_id: ctx.server_id,
646            descriptor_pool: Arc::new(pool),
647            protocol,
648            reflection: None,
649            stream_timeout: timeout,
650            streams: Arc::new(tokio::sync::Semaphore::new(streaming::MAX_ACTIVE)),
651            next_stream_id: Arc::new(std::sync::atomic::AtomicU32::new(1)),
652        }))
653    }
654    pub(crate) async fn commands(
655        self,
656        commands: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
657        registry: streaming::Registry,
658        connection: crate::server::connection::ConnectionId,
659    ) {
660        streaming::command_loop(commands, registry, self.0, connection).await;
661    }
662    pub(crate) fn prepare(
663        &self,
664        request: &Request<tonic::body::BoxBody>,
665        executor: streaming::OwnedExecutor,
666    ) -> Result<WebAdmission, tonic::Status> {
667        let method = DynamicGrpcService::parse_grpc_path(request.uri().path())
668            .ok()
669            .and_then(|(service, method)| {
670                self.0
671                    .descriptor_pool
672                    .get_service_by_name(&service)
673                    .and_then(|service| {
674                        service
675                            .methods()
676                            .find(|candidate| candidate.name() == method)
677                    })
678            });
679        let method = method.ok_or_else(|| tonic::Status::unimplemented("unknown RPC method"))?;
680        if method.is_client_streaming() {
681            return Err(tonic::Status::unimplemented(
682                "HTTP/1 RPC binding supports unary and server-streaming methods only",
683            ));
684        }
685        let timeout = streaming::timeout(request.headers(), self.0.stream_timeout)?;
686        let deadline = tokio::time::Instant::now() + timeout;
687        let permit = self
688            .0
689            .streams
690            .clone()
691            .try_acquire_owned()
692            .map_err(|_| tonic::Status::unavailable("server at RPC capacity"))?;
693        let timer = executor.deadline(deadline)?.retain_expired();
694        let id = match self.0.next_stream_id.fetch_update(
695            std::sync::atomic::Ordering::Relaxed,
696            std::sync::atomic::Ordering::Relaxed,
697            |id| id.checked_add(1),
698        ) {
699            Ok(id) => id,
700            Err(_) => {
701                return Err(tonic::Status::resource_exhausted(
702                    "RPC identifiers exhausted",
703                ))
704            }
705        };
706        Ok(WebAdmission {
707            deadline,
708            permit,
709            timer,
710            method,
711            id,
712        })
713    }
714    pub(crate) async fn request(
715        &self,
716        request: Request<tonic::body::BoxBody>,
717        connection: crate::server::connection::ConnectionId,
718        registry: streaming::Registry,
719        admission: &WebAdmission,
720    ) -> Response<tonic::body::BoxBody> {
721        streaming::dispatch(
722            request,
723            self.0.clone(),
724            connection,
725            admission.method.clone(),
726            registry,
727            admission.deadline,
728            admission.id,
729        )
730        .await
731    }
732}
733
734/// A gRPC response body: the length-prefixed message, then a **trailing HEADERS frame**
735/// carrying `grpc-status`.
736///
737/// `Full<Bytes>` cannot emit trailers at all, which is how the status ended up in the initial
738/// headers beside a DATA frame — not gRPC, and `grpcurl` (grpc-go, the reference
739/// implementation) refuses such a stream outright:
740///
741/// ```text
742/// Internal: server closed the stream without sending trailers
743/// ```
744///
745/// The specification allows the status in the initial headers **only** for a Trailers-Only
746/// reply, which by definition carries no message. So this server could report a *failure* to a
747/// real gRPC client and could not report a *success* — every error reply is Trailers-Only by
748/// construction and was accidentally correct, while every successful unary call was malformed.
749/// That asymmetry is why the error-path test was real evidence and the success-path test had to
750/// be written `#[ignore]`d.
751///
752/// The identical defect was found and fixed in `src/server/etcd/mod.rs` at the same time.
753#[cfg(not(target_arch = "wasm32"))]
754type GrpcBody = tonic::body::BoxBody;
755
756/// A message frame followed by trailers carrying `grpc-status`.
757///
758/// Errors do not come through here — `grpc_error_response` is deliberately Trailers-Only.
759#[cfg(not(target_arch = "wasm32"))]
760fn grpc_body_with_trailers(body: Bytes, status: i32, message: &str) -> GrpcBody {
761    use http_body_util::StreamBody;
762
763    let mut trailers = hyper::HeaderMap::new();
764    trailers.insert(
765        "grpc-status",
766        HeaderValue::from_str(&status.to_string())
767            .unwrap_or_else(|_| HeaderValue::from_static("13")),
768    );
769    trailers.insert(
770        "grpc-message",
771        HeaderValue::from_str(message).unwrap_or_else(|_| HeaderValue::from_static("")),
772    );
773
774    let frames: Vec<std::result::Result<hyper::body::Frame<Bytes>, tonic::Status>> = vec![
775        Ok(hyper::body::Frame::data(body)),
776        Ok(hyper::body::Frame::trailers(trailers)),
777    ];
778
779    BodyExt::boxed_unsync(StreamBody::new(futures::stream::iter(frames)))
780}
781
782/// An empty body, for a Trailers-Only reply whose status rides in the initial headers.
783#[cfg(not(target_arch = "wasm32"))]
784fn empty_grpc_body() -> GrpcBody {
785    http_body_util::Empty::<Bytes>::new()
786        .map_err(|never| match never {})
787        .boxed_unsync()
788}
789
790#[cfg(not(target_arch = "wasm32"))]
791impl DynamicGrpcService {
792    /// Handle a gRPC HTTP/2 request
793    async fn handle_grpc_request(
794        self: Arc<Self>,
795        req: Request<Incoming>,
796        connection_id: crate::server::connection::ConnectionId,
797        busy: crate::server::accept_bounded::BusyGuard,
798        registry: streaming::Registry,
799        executor: streaming::OwnedExecutor,
800    ) -> Result<Response<GrpcBody>, hyper::Error> {
801        let reflection = matches!(
802            req.uri().path(),
803            "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo"
804                | "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo"
805        );
806        let method = Self::parse_grpc_path(req.uri().path())
807            .ok()
808            .and_then(|(service, method)| {
809                self.descriptor_pool
810                    .get_service_by_name(&service)
811                    .and_then(|service| {
812                        service
813                            .methods()
814                            .find(|candidate| candidate.name() == method)
815                    })
816            });
817        let streaming_method =
818            method.filter(|method| method.is_client_streaming() || method.is_server_streaming());
819        if reflection || streaming_method.is_some() {
820            if req.method() != hyper::Method::POST
821                || req.version() != hyper::Version::HTTP_2
822                || !req.headers().get("content-type").is_some_and(|value| {
823                    value
824                        .to_str()
825                        .is_ok_and(|value| value.starts_with("application/grpc"))
826                })
827            {
828                return Ok(tonic::Status::invalid_argument(
829                    "streaming requires POST application/grpc over HTTP/2",
830                )
831                .into_http());
832            }
833            let maximum = if reflection {
834                std::time::Duration::from_secs(30)
835            } else {
836                self.stream_timeout
837            };
838            let timeout = match streaming::timeout(req.headers(), maximum) {
839                Ok(timeout) => timeout,
840                Err(status) => return Ok(status.into_http()),
841            };
842            let deadline = tokio::time::Instant::now() + timeout;
843            let permit = match self.streams.clone().try_acquire_owned() {
844                Ok(permit) => permit,
845                Err(_) => {
846                    return Ok(tonic::Status::unavailable("server at stream capacity").into_http())
847                }
848            };
849            let timer = match executor.deadline(deadline) {
850                Ok(timer) => timer,
851                Err(status) => return Ok(status.into_http()),
852            };
853            let response = if reflection {
854                let Some(state) = self.reflection.clone() else {
855                    return Ok(tonic::Status::unimplemented("reflection disabled").into_http());
856                };
857                reflection::dispatch(req, state, deadline).await
858            } else {
859                let id = match self.next_stream_id.fetch_update(
860                    std::sync::atomic::Ordering::Relaxed,
861                    std::sync::atomic::Ordering::Relaxed,
862                    |id| id.checked_add(1),
863                ) {
864                    Ok(id) => id,
865                    Err(_) => {
866                        return Ok(tonic::Status::resource_exhausted(
867                            "stream identifiers exhausted",
868                        )
869                        .into_http())
870                    }
871                };
872                streaming::dispatch(
873                    req.map(|body| {
874                        body.map_err(|_| tonic::Status::internal("request transport failed"))
875                            .boxed_unsync()
876                    }),
877                    self.clone(),
878                    connection_id,
879                    streaming_method.unwrap(),
880                    registry,
881                    deadline,
882                    id,
883                )
884                .await
885            };
886            return Ok(response.map(|body| {
887                streaming::BodyGuard {
888                    body,
889                    _permit: permit,
890                    _busy: busy,
891                    _timer: timer,
892                }
893                .boxed_unsync()
894            }));
895        }
896        let _busy = busy;
897        // Extract service and method from path (format: /package.Service/Method)
898        let path = req.uri().path();
899        let (service_name, method_name) = match Self::parse_grpc_path(path) {
900            Ok((svc, method)) => (svc, method),
901            Err(e) => {
902                debug!("Invalid gRPC path: {} - {}", path, e);
903                return Ok(Self::grpc_error_response(
904                    GrpcStatus::Unimplemented,
905                    "path must be /package.Service/Method",
906                ));
907            }
908        };
909
910        Log::new(Some(&self.status_tx))
911            .debug(format!("gRPC request: {}/{}", service_name, method_name));
912
913        // Validate content-type
914        if let Some(content_type) = req.headers().get("content-type") {
915            if !content_type
916                .to_str()
917                .unwrap_or("")
918                .starts_with("application/grpc")
919            {
920                return Ok(Self::grpc_error_response(
921                    GrpcStatus::Internal,
922                    "expected content-type application/grpc",
923                ));
924            }
925        }
926
927        // Read request body, capped. HTTP/2 flow control bounds the window, not the total, so
928        // an unbounded collect() lets one client grow the process without limit.
929        let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
930            .collect()
931            .await
932        {
933            Ok(collected) => collected.to_bytes(),
934            Err(e) => {
935                debug!("Failed to read gRPC request body: {}", e);
936                return Ok(Self::grpc_error_response(
937                    GrpcStatus::ResourceExhausted,
938                    &format!("request body rejected (limit {} bytes)", MAX_REQUEST_BYTES),
939                ));
940            }
941        };
942
943        // Decode gRPC frame (5-byte header: compression flag + 4-byte length + payload)
944        let request_payload = match Self::decode_grpc_frame(&body_bytes) {
945            Ok(payload) => payload,
946            Err(e) => {
947                debug!("Failed to decode gRPC frame: {}", e);
948                // A set compression flag is UNIMPLEMENTED per the spec, not a generic error:
949                // it tells the client to retry without compression.
950                let status = if body_bytes.first() == Some(&1) {
951                    GrpcStatus::Unimplemented
952                } else {
953                    GrpcStatus::Internal
954                };
955                return Ok(Self::grpc_error_response(status, "malformed gRPC frame"));
956            }
957        };
958
959        trace!("gRPC request payload: {} bytes", request_payload.len());
960
961        // Handle the unary request
962        let response_payload = match self
963            .handle_unary(&service_name, &method_name, request_payload, connection_id)
964            .await
965        {
966            Ok(payload) => payload,
967            Err(failure) => {
968                // Was `debug!` on the file log but `[ERROR]` on the TUI - exactly the
969                // level drift the Log facade exists to prevent. Reclassified to ERROR on
970                // both sinks: this is a genuine failed gRPC call reaching the client.
971                Log::new(Some(&self.status_tx))
972                    .error(format!("gRPC handler error: {}", failure.message));
973                return Ok(Self::grpc_error_response(failure.status, &failure.message));
974            }
975        };
976
977        // Encode response with gRPC framing
978        let response_frame = Self::encode_grpc_frame(&response_payload);
979
980        // Build the HTTP/2 response. The status goes in TRAILERS, not here: `grpc-status` in
981        // the initial headers beside a DATA frame is what made grpcurl refuse every successful
982        // call, because the stream then ends after DATA with no trailing HEADERS at all.
983        let mut response = Response::new(grpc_body_with_trailers(
984            Bytes::from(response_frame),
985            GrpcStatus::Ok as i32,
986            "",
987        ));
988        *response.status_mut() = StatusCode::OK;
989        response
990            .headers_mut()
991            .insert("content-type", HeaderValue::from_static("application/grpc"));
992
993        Log::new(Some(&self.status_tx))
994            .debug(format!("gRPC response: {} bytes", response_payload.len()));
995
996        Ok(response)
997    }
998
999    /// Parse gRPC path into service and method names
1000    fn parse_grpc_path(path: &str) -> Result<(String, String)> {
1001        // Format: /package.Service/Method
1002        if !path.starts_with('/') {
1003            bail!("Path must start with /");
1004        }
1005
1006        let parts: Vec<&str> = path[1..].split('/').collect();
1007        if parts.len() != 2 {
1008            bail!("Path must be /Service/Method");
1009        }
1010
1011        Ok((parts[0].to_string(), parts[1].to_string()))
1012    }
1013
1014    /// Decode gRPC frame from bytes
1015    /// Frame format: 1 byte compression flag + 4 bytes length (big-endian) + payload
1016    fn decode_grpc_frame(frame: &[u8]) -> Result<Vec<u8>> {
1017        if frame.len() < 5 {
1018            bail!("Frame too short (need at least 5 bytes)");
1019        }
1020
1021        let compressed = frame[0];
1022        if compressed != 0 {
1023            bail!("Compression not supported");
1024        }
1025
1026        let length = u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]) as usize;
1027
1028        // Compare against the bytes remaining rather than computing `5 + length`. On a 32-bit
1029        // target that addition wraps: a length of 0xFFFFFFFF gives `5 + length == 4`, the
1030        // guard passes, and `frame[5..4]` panics with start > end.
1031        let available = frame.len() - 5;
1032        if length > available {
1033            bail!(
1034                "frame declares {} bytes but only {} follow the header",
1035                length,
1036                available
1037            );
1038        }
1039
1040        Ok(frame[5..5 + length].to_vec())
1041    }
1042
1043    /// Encode payload into gRPC frame
1044    fn encode_grpc_frame(payload: &[u8]) -> Vec<u8> {
1045        let length = payload.len() as u32;
1046        let mut frame = Vec::with_capacity(5 + payload.len());
1047
1048        // Compression flag (0 = not compressed)
1049        frame.push(0);
1050
1051        // Length (4 bytes, big-endian)
1052        frame.extend_from_slice(&length.to_be_bytes());
1053
1054        // Payload
1055        frame.extend_from_slice(payload);
1056
1057        frame
1058    }
1059
1060    /// Create a gRPC error reply.
1061    ///
1062    /// Two things this must get right, both of which it used to get wrong:
1063    ///
1064    /// * **HTTP 200.** gRPC carries application failures in `grpc-status`, not in the HTTP
1065    ///   status line. A non-200 makes a conformant client discard `grpc-message` and
1066    ///   synthesize `UNAVAILABLE`/`UNKNOWN`, so the code and text chosen here never reach the
1067    ///   caller. The old signature took a `StatusCode` and returned 500/404/400/415.
1068    /// * **No `unwrap()` on the message.** `message` comes from LLM output and `anyhow`
1069    ///   chains. `HeaderValue` accepts only visible ASCII, so a single non-ASCII character or
1070    ///   newline in a model's error text made `Builder::body` return `Err` and panicked the
1071    ///   connection task — skipping the connection cleanup that runs after `serve_connection`
1072    ///   and leaving the entry permanently `Active`.
1073    ///
1074    /// The message is now put through [`header_safe`] first, so the caller keeps the
1075    /// explanation instead of the static fallback. That fallback had become the *common* case
1076    /// rather than the last resort: netget's own LLM error strings begin with a literal `✗`, so
1077    /// every backend failure reached the client with its reason replaced by "internal error".
1078    fn grpc_error_response(status: GrpcStatus, message: &str) -> Response<GrpcBody> {
1079        let message = &header_safe(message);
1080        // **Trailers-Only**, and deliberately so. With no message to send, the specification
1081        // puts the status in the initial headers and ends the stream there. This is the one
1082        // placement that is correct; the bug was applying it to replies that *do* carry a body.
1083        let mut res = Response::new(empty_grpc_body());
1084        *res.status_mut() = StatusCode::OK;
1085        let headers = res.headers_mut();
1086        headers.insert("content-type", HeaderValue::from_static("application/grpc"));
1087        headers.insert(
1088            "grpc-status",
1089            HeaderValue::from_str(&(status as i32).to_string())
1090                .unwrap_or_else(|_| HeaderValue::from_static("13")),
1091        );
1092        headers.insert(
1093            "grpc-message",
1094            HeaderValue::from_str(message)
1095                .unwrap_or_else(|_| HeaderValue::from_static("internal error")),
1096        );
1097        res
1098    }
1099
1100    /// Handle a gRPC unary request
1101    async fn handle_unary(
1102        &self,
1103        service_name: &str,
1104        method_name: &str,
1105        request_bytes: Vec<u8>,
1106        connection_id: crate::server::connection::ConnectionId,
1107    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
1108        // Find service and method descriptors. An unknown service or method is UNIMPLEMENTED,
1109        // which is what a client expects and what `grpcurl` prints usefully; it used to be
1110        // reported as INTERNAL over HTTP 500.
1111        let service_desc = self
1112            .descriptor_pool
1113            .services()
1114            .find(|s| s.full_name() == service_name)
1115            .ok_or_else(|| {
1116                GrpcFailure::new(
1117                    GrpcStatus::Unimplemented,
1118                    format!("unknown service {}", service_name),
1119                )
1120            })?;
1121
1122        let method_desc = service_desc
1123            .methods()
1124            .find(|m| m.name() == method_name)
1125            .ok_or_else(|| {
1126                GrpcFailure::new(
1127                    GrpcStatus::Unimplemented,
1128                    format!("unknown method {}/{}", service_name, method_name),
1129                )
1130            })?;
1131
1132        let input_desc = method_desc.input();
1133        let output_desc = method_desc.output();
1134
1135        Log::new(Some(&self.status_tx))
1136            .debug(format!("gRPC unary call: {}/{}", service_name, method_name));
1137
1138        // Decode request using dynamic message. A body this server cannot decode is the
1139        // client's malformed input, not a server fault.
1140        let request_msg = DynamicMessage::decode(input_desc.clone(), request_bytes.as_slice())
1141            .map_err(|e| {
1142                debug!("gRPC request message did not decode: {e}");
1143                GrpcFailure::new(
1144                    GrpcStatus::InvalidArgument,
1145                    "could not decode request message",
1146                )
1147            })?;
1148
1149        // Convert through the shared bounded field-name JSON codec.
1150        let request_json = dynamic_message_to_json(&request_msg).map_err(|error| {
1151            debug!("gRPC request value conversion failed: {error:#}");
1152            GrpcFailure::new(
1153                grpc_status_for_value_failure(&error),
1154                "request value exceeds conversion limits or does not fit field-name JSON",
1155            )
1156        })?;
1157
1158        Log::new(Some(&self.status_tx)).trace(format!(
1159            "gRPC request JSON: {}",
1160            serde_json::to_string_pretty(&request_json)?
1161        ));
1162
1163        // Build response schema description for LLM
1164        let response_schema = Self::build_message_schema(&output_desc);
1165
1166        // Create event for LLM
1167        let event = Event::new(
1168            &GRPC_UNARY_REQUEST_EVENT,
1169            json!({
1170                "service": service_name,
1171                "method": method_name,
1172                "request": request_json,
1173                "expected_response_schema": response_schema,
1174            }),
1175        );
1176
1177        // Call LLM.
1178        //
1179        // A failure here is answered, never swallowed: the caller gets a non-zero grpc-status
1180        // with an empty body, which no client can confuse with the encoded response message a
1181        // success carries. `UNAVAILABLE` when the backend was merely saturated so a retry
1182        // policy can act on it, `INTERNAL` otherwise.
1183        let execution_result = call_llm(
1184            &self.llm_client,
1185            &self.app_state,
1186            self.server_id,
1187            Some(connection_id),
1188            &event,
1189            self.protocol.as_ref(),
1190        )
1191        .await
1192        .map_err(|e| {
1193            let status = grpc_status_for_llm_failure(&e);
1194            console_error!(
1195                self.status_tx,
1196                "gRPC {}/{} could not be answered: the handler failed ({}). Replying \
1197                 grpc-status {} ({:?}); no response message is being invented.",
1198                service_name,
1199                method_name,
1200                e,
1201                status as i32,
1202                status
1203            );
1204            GrpcFailure::new(
1205                status,
1206                crate::utils::WireFailure::classify(&e).prefixed_text(),
1207            )
1208        })?;
1209
1210        // Process action results
1211        for protocol_result in execution_result.protocol_results {
1212            match protocol_result {
1213                ActionResult::Custom { name, data } if name == "grpc_unary_response" => {
1214                    // Extract response message from LLM
1215                    let response_json = data
1216                        .get("message")
1217                        .context("Missing 'message' in grpc_unary_response")?;
1218
1219                    // Convert JSON to DynamicMessage
1220                    let response_msg = json_to_dynamic_message(response_json, &output_desc)
1221                        .map_err(|e| {
1222                            debug!("gRPC handler response did not fit the schema: {e}");
1223                            GrpcFailure::new(
1224                                GrpcStatus::Internal,
1225                                "handler's response does not fit the schema",
1226                            )
1227                        })?;
1228
1229                    // Encode to protobuf bytes
1230                    let mut response_bytes = Vec::new();
1231                    response_msg
1232                        .encode(&mut response_bytes)
1233                        .map_err(anyhow::Error::from)?;
1234
1235                    Log::new(Some(&self.status_tx))
1236                        .debug(format!("gRPC response: {} bytes", response_bytes.len()));
1237
1238                    return Ok(response_bytes);
1239                }
1240                ActionResult::Custom { name, data } if name == "grpc_error" => {
1241                    // The documented `code` now reaches the wire. It used to be parsed,
1242                    // logged and then folded into a bail! message, so every error was sent
1243                    // as 13 INTERNAL over HTTP 500 with the real code as text inside
1244                    // grpc-message.
1245                    let code = data
1246                        .get("code")
1247                        .and_then(|c| c.as_str())
1248                        .unwrap_or("INTERNAL");
1249                    let message = data
1250                        .get("message")
1251                        .and_then(|m| m.as_str())
1252                        .unwrap_or("Internal error")
1253                        .to_string();
1254
1255                    // `GrpcStatus::parse` maps "OK" (and "0") to GrpcStatus::Ok, so a model
1256                    // answering the *error* action with code "OK" produced grpc-status 0 — a
1257                    // successful RPC with an empty body, carrying the error text in
1258                    // grpc-message where no client looks. An action whose entire purpose is to
1259                    // fail cannot be allowed to report success; that is the same confusion
1260                    // between a refusal and an approval the root CLAUDE.md warns about, just
1261                    // arriving via contradictory model output rather than via silence.
1262                    let parsed = GrpcStatus::parse(code);
1263                    let status = if parsed == GrpcStatus::Ok {
1264                        warn!(
1265                            "gRPC grpc_error asked for status OK; sending UNKNOWN instead \
1266                             (decision=model_reject code_coerced): {}",
1267                            message
1268                        );
1269                        GrpcStatus::Unknown
1270                    } else {
1271                        parsed
1272                    };
1273                    debug!("gRPC error: {} ({:?}) - {}", code, status, message);
1274                    return Err(GrpcFailure::new(status, message));
1275                }
1276                _ => {
1277                    // Ignore other action results
1278                }
1279            }
1280        }
1281
1282        // The handler ran but produced no usable action — neither `grpc_unary_response` nor
1283        // `grpc_error`. This is the fail-open trap: returning an empty message with
1284        // `grpc-status: 0` here is indistinguishable, to the caller, from a handler that
1285        // *deliberately* returned an empty response (`grpc_unary_response` with `{}`). A model
1286        // that never answered would then look like a successful empty reply.
1287        //
1288        // Answer it as a failure instead. `INTERNAL` (13), not `UNAVAILABLE`: the backend was
1289        // reachable and did respond — the response was simply unusable — so this is a genuine
1290        // application error, not a transient condition a retry policy should hammer. The
1291        // deliberate-empty case stays structurally distinct: it travels through
1292        // `grpc_unary_response` above and reaches the wire as `grpc-status: 0`.
1293        console_error!(
1294            self.status_tx,
1295            "gRPC {}/{} could not be answered: the handler returned no usable action \
1296             (no grpc_unary_response, no grpc_error). Replying grpc-status 13 (INTERNAL); no \
1297             empty response is being invented.",
1298            service_name,
1299            method_name
1300        );
1301        Err(GrpcFailure::new(
1302            GrpcStatus::Internal,
1303            format!(
1304                "netget gRPC: handler produced no usable response for {}/{}",
1305                service_name, method_name
1306            ),
1307        ))
1308    }
1309
1310    /// Build a JSON schema description of a message type
1311    fn build_message_schema(message_desc: &prost_reflect::MessageDescriptor) -> serde_json::Value {
1312        let mut fields = serde_json::Map::new();
1313
1314        for field in message_desc.fields() {
1315            let field_type = match field.kind() {
1316                prost_reflect::Kind::Double => "number (double)",
1317                prost_reflect::Kind::Float => "number (float)",
1318                prost_reflect::Kind::Int32
1319                | prost_reflect::Kind::Sint32
1320                | prost_reflect::Kind::Sfixed32 => "int32",
1321                prost_reflect::Kind::Int64
1322                | prost_reflect::Kind::Sint64
1323                | prost_reflect::Kind::Sfixed64 => "int64",
1324                prost_reflect::Kind::Uint32 | prost_reflect::Kind::Fixed32 => "uint32",
1325                prost_reflect::Kind::Uint64 | prost_reflect::Kind::Fixed64 => "uint64",
1326                prost_reflect::Kind::Bool => "boolean",
1327                prost_reflect::Kind::String => "string",
1328                prost_reflect::Kind::Bytes => "bytes (base64)",
1329                prost_reflect::Kind::Message(_) => "object",
1330                prost_reflect::Kind::Enum(_) => "enum (string)",
1331            };
1332
1333            let cardinality = match field.cardinality() {
1334                prost_reflect::Cardinality::Optional => "optional",
1335                prost_reflect::Cardinality::Required => "required",
1336                prost_reflect::Cardinality::Repeated => "repeated",
1337            };
1338
1339            fields.insert(
1340                field.name().to_string(),
1341                json!({
1342                    "type": field_type,
1343                    "cardinality": cardinality,
1344                }),
1345            );
1346        }
1347
1348        json!({
1349            "type": "object",
1350            "fields": fields,
1351        })
1352    }
1353}