Skip to main content

secure_exec_sidecar/
stdio.rs

1use crate::wire::{
2    self, AuthenticatedResponse, ExtEnvelope, OwnershipScope, ProtocolCodecError, ProtocolFrame,
3    RequestFrame, RequestId, RequestPayload, ResponseFrame, ResponsePayload, SessionOpenedResponse,
4    SidecarResponseFrame, WireDispatchResult, WireFrameCodec,
5};
6use crate::{
7    EventSinkTransport, Extension, ExtensionInterruptRequest, NativeSidecar, NativeSidecarConfig,
8    SidecarError, SidecarRequestTransport,
9};
10use secure_exec_bridge::queue_tracker::{tracked_sync_channel, TrackedLimit, TrackedSyncSender};
11use secure_exec_bridge::{
12    BridgeTypes, ChmodRequest, ClockBridge, ClockRequest, CommandPermissionRequest,
13    CreateDirRequest, CreateJavascriptContextRequest, CreateWasmContextRequest, DiagnosticRecord,
14    DirectoryEntry, EnvironmentPermissionRequest, EventBridge, ExecutionBridge, ExecutionEvent,
15    ExecutionHandleRequest, FileMetadata, FilesystemBridge, FilesystemPermissionRequest,
16    FilesystemSnapshot, FlushFilesystemStateRequest, GuestContextHandle, KillExecutionRequest,
17    LifecycleEventRecord, LoadFilesystemStateRequest, LogRecord, NetworkPermissionRequest,
18    PathRequest, PermissionBridge, PermissionDecision, PersistenceBridge,
19    PollExecutionEventRequest, RandomBridge, RandomBytesRequest, ReadDirRequest, ReadFileRequest,
20    RenameRequest, ScheduleTimerRequest, ScheduledTimer, StartExecutionRequest, StartedExecution,
21    StructuredEventRecord, SymlinkRequest, TruncateRequest, WriteExecutionStdinRequest,
22    WriteFileRequest,
23};
24use std::collections::{BTreeMap, BTreeSet};
25use std::error::Error;
26use std::fmt;
27use std::fs::{self, OpenOptions};
28use std::io::{self, Read, Write};
29use std::os::unix::fs::{symlink as create_symlink, MetadataExt, PermissionsExt};
30use std::path::{Path, PathBuf};
31use std::sync::{mpsc, Arc, Mutex};
32use std::thread;
33use std::time::{Duration, Instant, SystemTime};
34use tokio::sync::mpsc::{channel, unbounded_channel, Receiver};
35use tokio::time;
36
37// Guest sync fs/module RPCs are serviced by `pump_process_events` on this timer,
38// so a blocked guest call waits up to one interval before the host even sees it.
39// At 5ms this dominated per-call latency (~5ms/stat); 250us cuts it ~11x (stat
40// 7.5s -> ~0.65s over 1500 ops) and the sub-ms tokio timer is honored. Idle
41// pumps are cheap no-ops (try_recv + zero-timeout poll), so the higher cadence
42// costs negligible CPU when no guest is issuing RPCs.
43const EVENT_PUMP_INTERVAL: Duration = Duration::from_micros(250);
44const MAX_STDIN_FRAME_QUEUE: usize = 128;
45const MAX_EVENT_READY_QUEUE: usize = 1;
46// Defense-in-depth headroom for the host-bound frame queue: a burst of output
47// frames from a busy turn should be buffered, so the writer only backpressures
48// when the host genuinely stops reading stdout rather than on every spike.
49const MAX_STDOUT_FRAME_QUEUE: usize = 4096;
50
51#[cfg(test)]
52fn request_frame(
53    request_id: RequestId,
54    ownership: OwnershipScope,
55    payload: RequestPayload,
56) -> RequestFrame {
57    RequestFrame {
58        schema: wire::protocol_schema(),
59        request_id,
60        ownership,
61        payload,
62    }
63}
64
65fn response_frame(
66    request_id: RequestId,
67    ownership: OwnershipScope,
68    payload: ResponsePayload,
69) -> ResponseFrame {
70    ResponseFrame {
71        schema: wire::protocol_schema(),
72        request_id,
73        ownership,
74        payload,
75    }
76}
77
78#[cfg(test)]
79fn connection_ownership(connection_id: &str) -> OwnershipScope {
80    OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
81        connection_id: connection_id.to_owned(),
82    })
83}
84
85fn session_ownership(connection_id: &str, session_id: &str) -> OwnershipScope {
86    OwnershipScope::SessionOwnership(wire::SessionOwnership {
87        connection_id: connection_id.to_owned(),
88        session_id: session_id.to_owned(),
89    })
90}
91
92#[cfg(test)]
93fn vm_ownership(connection_id: &str, session_id: &str, vm_id: &str) -> OwnershipScope {
94    OwnershipScope::VmOwnership(wire::VmOwnership {
95        connection_id: connection_id.to_owned(),
96        session_id: session_id.to_owned(),
97        vm_id: vm_id.to_owned(),
98    })
99}
100
101fn wire_protocol_error(error: ProtocolCodecError) -> SidecarError {
102    SidecarError::InvalidState(format!("invalid generated wire protocol frame: {error}"))
103}
104
105pub fn run() -> Result<(), Box<dyn Error>> {
106    run_with_extensions(Vec::new())
107}
108
109pub fn run_with_extensions(extensions: Vec<Box<dyn Extension>>) -> Result<(), Box<dyn Error>> {
110    // Initialize the embedded V8 runtime + platform now, on the long-lived main
111    // thread, so it is never first-initialized on a transient worker thread (e.g. a
112    // VM-create snapshot pre-warm thread that then exits — which corrupts V8's
113    // platform and wedges later isolate creation). Best-effort.
114    if let Err(error) = secure_exec_execution::v8_host::ensure_runtime_initialized() {
115        eprintln!("embedded V8 runtime init failed at startup: {error}");
116    }
117    tokio::runtime::Builder::new_current_thread()
118        .enable_all()
119        .build()?
120        .block_on(run_async(extensions))
121}
122
123async fn run_async(extensions: Vec<Box<dyn Extension>>) -> Result<(), Box<dyn Error>> {
124    let config = NativeSidecarConfig {
125        compile_cache_root: Some(default_compile_cache_root()),
126        ..NativeSidecarConfig::default()
127    };
128    let codec = WireFrameCodec::new(config.max_frame_bytes);
129    let mut sidecar =
130        NativeSidecar::with_config_and_extensions(LocalBridge::default(), config, extensions)?;
131    let mut active_sessions = BTreeSet::<SessionScope>::new();
132    let mut active_connections = BTreeSet::<String>::new();
133    let (stdin_tx, mut stdin_rx) =
134        channel::<Result<Option<ProtocolFrame>, String>>(MAX_STDIN_FRAME_QUEUE);
135    let stdin_gauge = secure_exec_bridge::queue_tracker::register_queue(
136        TrackedLimit::SidecarStdinFrames,
137        MAX_STDIN_FRAME_QUEUE,
138    );
139    let (event_ready_tx, mut event_ready_rx) = channel::<()>(MAX_EVENT_READY_QUEUE);
140    let (write_tx, write_rx) = tracked_sync_channel::<ProtocolFrame>(
141        TrackedLimit::SidecarStdoutFrames,
142        MAX_STDOUT_FRAME_QUEUE,
143    );
144    let (write_error_tx, mut write_error_rx) = unbounded_channel::<String>();
145
146    // Forward limit-registry near-capacity warnings to the host: the global sink
147    // fires (edge-triggered, from arbitrary threads) into this channel, and the
148    // event loop below drains it and emits a `StructuredEvent` (name
149    // "limit_warning"). The unbounded sender is Send+Sync and lives for the whole
150    // process inside the global handler, so the receiver never sees a hangup.
151    let (limit_warning_tx, mut limit_warning_rx) =
152        unbounded_channel::<secure_exec_bridge::queue_tracker::LimitWarning>();
153    secure_exec_bridge::queue_tracker::set_limit_warning_handler(Box::new(move |warning| {
154        let _ = limit_warning_tx.send(warning.clone());
155    }));
156    let callback_transport = Arc::new(FrameSidecarRequestTransport::new(write_tx.clone()));
157    sidecar.set_sidecar_request_transport(callback_transport.clone());
158    // Live event sink: lets an extension stream `session/update` (and other)
159    // events to stdout mid-dispatch instead of batching them until the request
160    // resolves. Shares the same outbound `write_tx` channel as the batch path, so
161    // ordering and backpressure are identical.
162    let event_transport = Arc::new(FrameEventTransport::new(write_tx.clone()));
163    sidecar.set_event_transport(event_transport);
164    let mut event_pump = time::interval(EVENT_PUMP_INTERVAL);
165    let writer_codec = codec.clone();
166    let reader_codec = codec.clone();
167    let writer_error_tx = write_error_tx.clone();
168    thread::spawn(move || {
169        let mut writer = io::BufWriter::new(io::stdout());
170        while let Ok(frame) = write_rx.recv() {
171            if let Err(error) = write_frame(&writer_codec, &mut writer, &frame) {
172                let _ = writer_error_tx.send(error.to_string());
173                break;
174            }
175        }
176    });
177
178    thread::spawn({
179        let callback_transport = callback_transport.clone();
180        let read_error_tx = write_error_tx.clone();
181        move || {
182            let mut stdin = io::stdin();
183            loop {
184                let frame = match read_frame(&reader_codec, &mut stdin) {
185                    Ok(Some(ProtocolFrame::SidecarResponseFrame(response))) => {
186                        if callback_transport.accept_response(response.clone()) {
187                            continue;
188                        }
189                        Ok(Some(ProtocolFrame::SidecarResponseFrame(response)))
190                    }
191                    Ok(Some(frame)) => Ok(Some(frame)),
192                    other => other,
193                }
194                .map_err(|error: Box<dyn Error>| error.to_string());
195                let should_stop = matches!(frame, Ok(None) | Err(_));
196                match enqueue_stdin_frame(&stdin_tx, frame) {
197                    Ok(()) => {
198                        // Sample inbound queue depth so the centralized tracker
199                        // can warn before host requests back up on the sidecar.
200                        stdin_gauge.observe_depth(
201                            stdin_tx.max_capacity().saturating_sub(stdin_tx.capacity()),
202                        );
203                    }
204                    Err(StdinFrameQueueError::Full(message)) => {
205                        let _ = read_error_tx.send(message);
206                        break;
207                    }
208                    Err(StdinFrameQueueError::Closed) => break,
209                }
210                if should_stop {
211                    break;
212                }
213            }
214        }
215    });
216
217    flush_sidecar_requests(&mut sidecar, &write_tx)?;
218    let mut pending_frame: Option<ProtocolFrame> = None;
219    let mut limit_warning_closed = false;
220
221    loop {
222        if let Some(frame) = pending_frame.take() {
223            handle_protocol_frame(
224                frame,
225                &mut sidecar,
226                &mut stdin_rx,
227                &mut pending_frame,
228                &write_tx,
229                &mut active_sessions,
230                &mut active_connections,
231            )
232            .await?;
233            continue;
234        }
235
236        tokio::select! {
237            maybe_frame = stdin_rx.recv() => {
238                let Some(frame) = maybe_frame else {
239                    break;
240                };
241                let Some(frame) = frame.map_err(io::Error::other)? else {
242                    break;
243                };
244                handle_protocol_frame(
245                    frame,
246                    &mut sidecar,
247                    &mut stdin_rx,
248                    &mut pending_frame,
249                    &write_tx,
250                    &mut active_sessions,
251                    &mut active_connections,
252                ).await?;
253            }
254            maybe_warning = limit_warning_rx.recv(), if !limit_warning_closed => {
255                match maybe_warning {
256                    Some(warning) => {
257                        // A limit warning is process-global; deliver it ONCE. The
258                        // stdio transport is single-client, so emit it to the first
259                        // active connection (if any) rather than fanning out a copy
260                        // per connection. Dropped if no client has authenticated yet
261                        // (only the tracing log survives, which is acceptable).
262                        if let Some(connection_id) = active_connections.iter().next() {
263                            let mut detail = std::collections::HashMap::new();
264                            detail.insert(String::from("limit"), warning.name.as_str().to_string());
265                            detail.insert(
266                                String::from("category"),
267                                warning.category.as_str().to_string(),
268                            );
269                            detail.insert(String::from("observed"), warning.observed.to_string());
270                            detail.insert(String::from("capacity"), warning.capacity.to_string());
271                            detail.insert(
272                                String::from("fillPercent"),
273                                warning.fill_percent.to_string(),
274                            );
275                            let frame = crate::service::structured_event_frame(
276                                connection_id,
277                                "limit_warning",
278                                detail,
279                            )?;
280                            send_output_frame(&write_tx, ProtocolFrame::EventFrame(frame))?;
281                        }
282                    }
283                    None => {
284                        // Sender dropped (only possible if another sidecar replaced
285                        // the global handler in-process). Disarm this branch so the
286                        // select! does not hot-spin on an always-ready closed
287                        // receiver; do NOT break — that would tear down the sidecar.
288                        limit_warning_closed = true;
289                    }
290                }
291            }
292            maybe_ready = event_ready_rx.recv() => {
293                let Some(()) = maybe_ready else {
294                    break;
295                };
296                loop {
297                    let mut emitted_frame = false;
298                    for session in active_sessions.iter().cloned().collect::<Vec<_>>() {
299                        if let Some(frame) = sidecar
300                            .poll_event_wire(&session.ownership_scope(), Duration::ZERO)
301                            .await?
302                        {
303                            send_output_frame(&write_tx, ProtocolFrame::EventFrame(frame))?;
304                            emitted_frame = true;
305                        }
306                    }
307
308                    if !emitted_frame {
309                        break;
310                    }
311                }
312                flush_sidecar_requests(&mut sidecar, &write_tx)?;
313            }
314            _ = event_pump.tick() => {
315                for session in active_sessions.iter().cloned().collect::<Vec<_>>() {
316                    if sidecar.pump_process_events(&session.compat_ownership_scope()).await? {
317                        let _ = event_ready_tx.try_send(());
318                    }
319                }
320                flush_sidecar_requests(&mut sidecar, &write_tx)?;
321            }
322            maybe_write_error = write_error_rx.recv() => {
323                if let Some(error) = maybe_write_error {
324                    return Err(io::Error::new(io::ErrorKind::BrokenPipe, error).into());
325                }
326            }
327        }
328    }
329
330    cleanup_connections(&mut sidecar, &active_connections, &mut active_sessions).await;
331    Ok(())
332}
333
334async fn handle_protocol_frame(
335    frame: ProtocolFrame,
336    sidecar: &mut NativeSidecar<LocalBridge>,
337    stdin_rx: &mut Receiver<Result<Option<ProtocolFrame>, String>>,
338    pending_frame: &mut Option<ProtocolFrame>,
339    write_tx: &TrackedSyncSender<ProtocolFrame>,
340    active_sessions: &mut BTreeSet<SessionScope>,
341    active_connections: &mut BTreeSet<String>,
342) -> Result<(), Box<dyn Error>> {
343    match frame {
344        ProtocolFrame::RequestFrame(request) => {
345            let (dispatch, extra_responses) =
346                dispatch_with_prompt_interrupt(sidecar, request.clone(), stdin_rx, pending_frame)
347                    .await?;
348            track_session_state(
349                &dispatch.response.payload,
350                active_sessions,
351                active_connections,
352            );
353
354            send_output_frame(write_tx, ProtocolFrame::ResponseFrame(dispatch.response))?;
355            for response in extra_responses {
356                send_output_frame(write_tx, ProtocolFrame::ResponseFrame(response))?;
357            }
358            for event in dispatch.events {
359                send_output_frame(write_tx, ProtocolFrame::EventFrame(event))?;
360            }
361            flush_sidecar_requests(sidecar, write_tx)?;
362        }
363        ProtocolFrame::SidecarResponseFrame(response) => {
364            sidecar.accept_wire_sidecar_response(response)?;
365            flush_sidecar_requests(sidecar, write_tx)?;
366        }
367        other => {
368            return Err(format!(
369                "expected request or sidecar_response frame on stdin, received {}",
370                frame_kind(&other)
371            )
372            .into());
373        }
374    }
375    // Drop any sessions the sidecar disposed while handling this frame from the
376    // active-session set so the event pump stops iterating dead sessions (M5).
377    untrack_disposed_sessions(&sidecar.take_disposed_sessions(), active_sessions);
378    Ok(())
379}
380
381/// Remove every disposed session scope from the stdio transport's active-session
382/// set. Without this the set is insert-only (`track_session_state` adds on
383/// `SessionOpenedResponse` but nothing ever removed), so it grew per session for
384/// the process lifetime and the ~250us event pump iterated every dead entry (M5).
385fn untrack_disposed_sessions(
386    disposed: &[(String, String)],
387    active_sessions: &mut BTreeSet<SessionScope>,
388) {
389    for (connection_id, session_id) in disposed {
390        active_sessions.remove(&SessionScope {
391            connection_id: connection_id.clone(),
392            session_id: session_id.clone(),
393        });
394    }
395}
396
397async fn dispatch_with_prompt_interrupt(
398    sidecar: &mut NativeSidecar<LocalBridge>,
399    request: RequestFrame,
400    stdin_rx: &mut Receiver<Result<Option<ProtocolFrame>, String>>,
401    pending_frame: &mut Option<ProtocolFrame>,
402) -> Result<(WireDispatchResult, Vec<ResponseFrame>), Box<dyn Error>> {
403    let Some(blocking_request) = blocking_extension_request(sidecar, &request) else {
404        return Ok((sidecar.dispatch_wire(request).await?, Vec::new()));
405    };
406
407    let mut dispatch = Box::pin(sidecar.dispatch_wire(request.clone()));
408    tokio::select! {
409        result = dispatch.as_mut() => Ok((result?, Vec::new())),
410        maybe_frame = stdin_rx.recv() => {
411            let frame = decode_stdin_frame(maybe_frame)?;
412            if let Some(frame) = frame {
413                if let Some(interrupt) = extension_interrupt_response(&blocking_request, &request, &frame) {
414                    drop(dispatch);
415                    let mut extra_responses = Vec::new();
416                    if let Some(response) = interrupt.interrupting_response {
417                        extra_responses.push(response);
418                    } else {
419                        *pending_frame = Some(frame);
420                    }
421                    return Ok((interrupt.interrupted_dispatch, extra_responses));
422                }
423                *pending_frame = Some(frame);
424            }
425            Ok((dispatch.await?, Vec::new()))
426        }
427    }
428}
429
430fn decode_stdin_frame(
431    maybe_frame: Option<Result<Option<ProtocolFrame>, String>>,
432) -> Result<Option<ProtocolFrame>, Box<dyn Error>> {
433    let Some(frame) = maybe_frame else {
434        return Ok(None);
435    };
436    Ok(frame.map_err(io::Error::other)?)
437}
438
439struct BlockingExtensionRequest {
440    namespace: String,
441    payload: Vec<u8>,
442    extension: Arc<dyn Extension>,
443}
444
445struct ExtensionInterruptDispatch {
446    interrupted_dispatch: WireDispatchResult,
447    interrupting_response: Option<ResponseFrame>,
448}
449
450fn blocking_extension_request(
451    sidecar: &NativeSidecar<LocalBridge>,
452    request: &RequestFrame,
453) -> Option<BlockingExtensionRequest> {
454    let RequestPayload::ExtEnvelope(envelope) = &request.payload else {
455        return None;
456    };
457    let extension = sidecar.extensions.get(&envelope.namespace)?.clone();
458    if !extension.is_blocking_request(&envelope.payload) {
459        return None;
460    }
461    Some(BlockingExtensionRequest {
462        namespace: envelope.namespace.clone(),
463        payload: envelope.payload.clone(),
464        extension,
465    })
466}
467
468fn extension_interrupt_response(
469    blocking_request: &BlockingExtensionRequest,
470    active_request: &RequestFrame,
471    frame: &ProtocolFrame,
472) -> Option<ExtensionInterruptDispatch> {
473    match frame {
474        ProtocolFrame::RequestFrame(request) => {
475            if request.ownership != active_request.ownership {
476                return None;
477            }
478            let interrupt = match &request.payload {
479                RequestPayload::ExtEnvelope(envelope)
480                    if envelope.namespace == blocking_request.namespace =>
481                {
482                    blocking_request.extension.interrupt_blocking_request(
483                        &blocking_request.payload,
484                        ExtensionInterruptRequest::ExtensionPayload(&envelope.payload),
485                    )?
486                }
487                RequestPayload::ExtEnvelope(_) => return None,
488                RequestPayload::KillProcessRequest(_) => {
489                    blocking_request.extension.interrupt_blocking_request(
490                        &blocking_request.payload,
491                        ExtensionInterruptRequest::KillProcess,
492                    )?
493                }
494                // Control-plane setup, inspection, filesystem, process plumbing, and
495                // persistence requests run concurrently with an in-flight prompt and
496                // must not interrupt it. DisposeVm is deliberately non-interrupting for
497                // now; see the todo entry about dispose racing a blocked prompt.
498                RequestPayload::AuthenticateRequest(_)
499                | RequestPayload::OpenSessionRequest(_)
500                | RequestPayload::CreateVmRequest(_)
501                | RequestPayload::DisposeVmRequest(_)
502                | RequestPayload::BootstrapRootFilesystemRequest(_)
503                | RequestPayload::ConfigureVmRequest(_)
504                | RequestPayload::RegisterHostCallbacksRequest(_)
505                | RequestPayload::CreateLayerRequest
506                | RequestPayload::SealLayerRequest(_)
507                | RequestPayload::ImportSnapshotRequest(_)
508                | RequestPayload::ExportSnapshotRequest(_)
509                | RequestPayload::CreateOverlayRequest(_)
510                | RequestPayload::GuestFilesystemCallRequest(_)
511                | RequestPayload::SnapshotRootFilesystemRequest
512                | RequestPayload::ExecuteRequest(_)
513                | RequestPayload::WriteStdinRequest(_)
514                | RequestPayload::CloseStdinRequest(_)
515                | RequestPayload::GetProcessSnapshotRequest
516                | RequestPayload::FindListenerRequest(_)
517                | RequestPayload::FindBoundUdpRequest(_)
518                | RequestPayload::VmFetchRequest(_)
519                | RequestPayload::GetSignalStateRequest(_)
520                | RequestPayload::GetZombieTimerCountRequest
521                | RequestPayload::HostFilesystemCallRequest(_)
522                | RequestPayload::PersistenceLoadRequest(_)
523                | RequestPayload::PersistenceFlushRequest(_) => return None,
524            };
525            let interrupted_dispatch = interrupted_extension_dispatch(
526                active_request,
527                &blocking_request.namespace,
528                interrupt.interrupted_response_payload,
529            );
530            let interrupting_response = interrupt.interrupting_response_payload.map(|payload| {
531                response_frame(
532                    request.request_id,
533                    request.ownership.clone(),
534                    ResponsePayload::ExtEnvelope(ExtEnvelope {
535                        namespace: blocking_request.namespace.clone(),
536                        payload,
537                    }),
538                )
539            });
540            Some(ExtensionInterruptDispatch {
541                interrupted_dispatch,
542                interrupting_response,
543            })
544        }
545        // Response, Event, and SidecarRequest frames are sidecar-to-host only. If one
546        // arrives on stdin it is requeued and rejected as a protocol error by
547        // handle_protocol_frame, so it must not synthesize a cancelled prompt first.
548        // SidecarResponse frames answer sidecar-initiated callbacks and may be the very
549        // response the blocked prompt dispatch is waiting on, so they never interrupt.
550        ProtocolFrame::ResponseFrame(_)
551        | ProtocolFrame::EventFrame(_)
552        | ProtocolFrame::SidecarRequestFrame(_)
553        | ProtocolFrame::SidecarResponseFrame(_) => None,
554    }
555}
556
557fn interrupted_extension_dispatch(
558    request: &RequestFrame,
559    namespace: &str,
560    payload: Vec<u8>,
561) -> WireDispatchResult {
562    match &request.payload {
563        RequestPayload::ExtEnvelope(_) => {
564            let response = ResponsePayload::ExtEnvelope(ExtEnvelope {
565                namespace: namespace.to_string(),
566                payload,
567            });
568            WireDispatchResult {
569                response: response_frame(request.request_id, request.ownership.clone(), response),
570                events: Vec::new(),
571            }
572        }
573        RequestPayload::AuthenticateRequest(_)
574        | RequestPayload::OpenSessionRequest(_)
575        | RequestPayload::CreateVmRequest(_)
576        | RequestPayload::DisposeVmRequest(_)
577        | RequestPayload::BootstrapRootFilesystemRequest(_)
578        | RequestPayload::ConfigureVmRequest(_)
579        | RequestPayload::RegisterHostCallbacksRequest(_)
580        | RequestPayload::CreateLayerRequest
581        | RequestPayload::SealLayerRequest(_)
582        | RequestPayload::ImportSnapshotRequest(_)
583        | RequestPayload::ExportSnapshotRequest(_)
584        | RequestPayload::CreateOverlayRequest(_)
585        | RequestPayload::GuestFilesystemCallRequest(_)
586        | RequestPayload::SnapshotRootFilesystemRequest
587        | RequestPayload::ExecuteRequest(_)
588        | RequestPayload::WriteStdinRequest(_)
589        | RequestPayload::CloseStdinRequest(_)
590        | RequestPayload::KillProcessRequest(_)
591        | RequestPayload::GetProcessSnapshotRequest
592        | RequestPayload::FindListenerRequest(_)
593        | RequestPayload::FindBoundUdpRequest(_)
594        | RequestPayload::VmFetchRequest(_)
595        | RequestPayload::GetSignalStateRequest(_)
596        | RequestPayload::GetZombieTimerCountRequest
597        | RequestPayload::HostFilesystemCallRequest(_)
598        | RequestPayload::PersistenceLoadRequest(_)
599        | RequestPayload::PersistenceFlushRequest(_) => {
600            unreachable!("interrupted extension dispatch requires an extension request");
601        }
602    }
603}
604
605async fn cleanup_connections(
606    sidecar: &mut NativeSidecar<LocalBridge>,
607    active_connections: &BTreeSet<String>,
608    active_sessions: &mut BTreeSet<SessionScope>,
609) {
610    for connection_id in active_connections {
611        let _ = sidecar.remove_connection(connection_id).await;
612    }
613    untrack_disposed_sessions(&sidecar.take_disposed_sessions(), active_sessions);
614}
615
616fn track_session_state(
617    payload: &ResponsePayload,
618    active_sessions: &mut BTreeSet<SessionScope>,
619    active_connections: &mut BTreeSet<String>,
620) {
621    match payload {
622        ResponsePayload::AuthenticatedResponse(AuthenticatedResponse { connection_id, .. }) => {
623            active_connections.insert(connection_id.clone());
624        }
625        ResponsePayload::SessionOpenedResponse(SessionOpenedResponse {
626            session_id,
627            owner_connection_id,
628        }) => {
629            active_sessions.insert(SessionScope {
630                connection_id: owner_connection_id.clone(),
631                session_id: session_id.clone(),
632            });
633        }
634        _ => {}
635    }
636}
637
638fn read_frame(
639    codec: &WireFrameCodec,
640    reader: &mut impl Read,
641) -> Result<Option<ProtocolFrame>, Box<dyn Error>> {
642    let mut prefix = [0u8; 4];
643    match reader.read_exact(&mut prefix) {
644        Ok(()) => {}
645        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
646            return Ok(None);
647        }
648        Err(error) => return Err(error.into()),
649    }
650
651    let declared_len = u32::from_be_bytes(prefix) as usize;
652    if declared_len > codec.max_frame_bytes() {
653        return Err(ProtocolCodecError::FrameTooLarge {
654            size: declared_len,
655            max: codec.max_frame_bytes(),
656        }
657        .into());
658    }
659    let total_len = prefix.len().saturating_add(declared_len);
660    let mut bytes = Vec::with_capacity(total_len);
661    bytes.extend_from_slice(&prefix);
662    bytes.resize(total_len, 0);
663    reader.read_exact(&mut bytes[prefix.len()..])?;
664
665    Ok(Some(codec.decode(&bytes)?))
666}
667
668fn write_frame(
669    codec: &WireFrameCodec,
670    writer: &mut impl Write,
671    frame: &ProtocolFrame,
672) -> Result<(), Box<dyn Error>> {
673    let bytes = codec.encode(frame)?;
674    writer.write_all(&bytes)?;
675    writer.flush()?;
676    Ok(())
677}
678
679fn frame_kind(frame: &ProtocolFrame) -> &'static str {
680    match frame {
681        ProtocolFrame::RequestFrame(_) => "request",
682        ProtocolFrame::ResponseFrame(_) => "response",
683        ProtocolFrame::EventFrame(_) => "event",
684        ProtocolFrame::SidecarRequestFrame(_) => "sidecar_request",
685        ProtocolFrame::SidecarResponseFrame(_) => "sidecar_response",
686    }
687}
688
689#[derive(Debug, Clone, PartialEq, Eq)]
690enum StdinFrameQueueError {
691    Full(String),
692    Closed,
693}
694
695fn enqueue_stdin_frame(
696    sender: &tokio::sync::mpsc::Sender<Result<Option<ProtocolFrame>, String>>,
697    frame: Result<Option<ProtocolFrame>, String>,
698) -> Result<(), StdinFrameQueueError> {
699    sender.try_send(frame).map_err(|error| match error {
700        tokio::sync::mpsc::error::TrySendError::Full(_) => StdinFrameQueueError::Full(format!(
701            "stdin frame queue exceeded {MAX_STDIN_FRAME_QUEUE} pending frames"
702        )),
703        tokio::sync::mpsc::error::TrySendError::Closed(_) => StdinFrameQueueError::Closed,
704    })
705}
706
707fn flush_sidecar_requests(
708    sidecar: &mut NativeSidecar<LocalBridge>,
709    writer: &TrackedSyncSender<ProtocolFrame>,
710) -> Result<(), Box<dyn Error>> {
711    while let Some(request) = sidecar.pop_wire_sidecar_request()? {
712        send_output_frame(writer, ProtocolFrame::SidecarRequestFrame(request))?;
713    }
714    Ok(())
715}
716
717fn send_output_frame(
718    writer: &TrackedSyncSender<ProtocolFrame>,
719    frame: ProtocolFrame,
720) -> Result<(), io::Error> {
721    // Apply backpressure rather than killing the sidecar when the host reads
722    // stdout slowly. A full queue means the dedicated writer thread is blocked on
723    // the stdout pipe (the host has not drained it yet) — a transient, recoverable
724    // condition. Previously `try_send` turned that backlog into a `BrokenPipe`
725    // error that propagated up and exited the whole sidecar process (code 1),
726    // taking every session with it. A blocking `send` parks the producer until the
727    // writer drains a slot, which transitively backpressures the V8 event bridge
728    // and the guest. It never deadlocks: the writer thread runs independently, and
729    // if it dies (real broken pipe) the receiver is dropped and `send` returns
730    // `Disconnected`, which we still surface as a terminal `BrokenPipe`.
731    writer.send(frame).map_err(|_disconnected| {
732        io::Error::new(io::ErrorKind::BrokenPipe, "stdout writer disconnected")
733    })
734}
735
736fn default_compile_cache_root() -> PathBuf {
737    // Stable across sidecar processes so V8 compile-cache (cachedData) survives a
738    // fresh sidecar/VM and benefits cold starts. Previously keyed by PID, which
739    // gave every process an empty cache — cold module imports never reused
740    // compiled bytecode. Entries are namespaced+validated downstream by
741    // `stable_compile_cache_namespace_hash` + V8's source/version checks, so a
742    // shared root is safe; stale or mismatched entries are simply ignored.
743    std::env::temp_dir().join("secure-exec-sidecar-compile-cache")
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use crate::wire::{AuthenticateRequest, KillProcessRequest};
750    use crate::{ExtensionContext, ExtensionFuture, ExtensionInterruptResponse, ExtensionResponse};
751    use std::io::Cursor;
752
753    const TEST_EXTENSION_NAMESPACE: &str = "dev.rivet.secure-exec.test.blocking";
754
755    #[test]
756    fn read_frame_rejects_oversized_prefix_before_allocating_payload() {
757        let codec = WireFrameCodec::new(16);
758        let mut reader = Cursor::new((32_u32).to_be_bytes().to_vec());
759
760        let error = read_frame(&codec, &mut reader).expect_err("oversized frame should fail");
761        let error = error
762            .downcast::<ProtocolCodecError>()
763            .expect("protocol codec error");
764        assert!(matches!(
765            *error,
766            ProtocolCodecError::FrameTooLarge { size: 32, max: 16 }
767        ));
768    }
769
770    #[test]
771    fn stdio_work_queues_are_bounded() {
772        let (stdin_tx, _stdin_rx) =
773            channel::<Result<Option<ProtocolFrame>, String>>(MAX_STDIN_FRAME_QUEUE);
774        for _ in 0..MAX_STDIN_FRAME_QUEUE {
775            enqueue_stdin_frame(&stdin_tx, Ok(None))
776                .expect("stdin frame queue should accept capacity");
777        }
778        assert!(matches!(
779            enqueue_stdin_frame(&stdin_tx, Ok(None)),
780            Err(StdinFrameQueueError::Full(_))
781        ));
782
783        let (event_ready_tx, _event_ready_rx) = channel::<()>(MAX_EVENT_READY_QUEUE);
784        event_ready_tx
785            .try_send(())
786            .expect("event-ready queue should accept capacity");
787        assert!(matches!(
788            event_ready_tx.try_send(()),
789            Err(tokio::sync::mpsc::error::TrySendError::Full(_))
790        ));
791    }
792
793    // Regression: a full stdout frame queue must apply backpressure (block the
794    // producer until the writer drains a slot), NOT tear the sidecar down. The
795    // old `try_send` turned a slow host reader into a `BrokenPipe` error that
796    // propagated up and exited the whole sidecar process (code 1). Here a slow
797    // drainer forces the queue past capacity; with backpressure every send
798    // succeeds, and overflow only fails when the writer (receiver) is gone.
799    #[test]
800    fn stdout_frame_queue_applies_backpressure_instead_of_crashing() {
801        let queue_frame = |request_id: RequestId| {
802            ProtocolFrame::RequestFrame(request_frame(
803                request_id,
804                connection_ownership("conn-queue"),
805                RequestPayload::AuthenticateRequest(AuthenticateRequest {
806                    client_name: String::from("queue-test"),
807                    auth_token: String::from("token"),
808                    protocol_version: wire::PROTOCOL_VERSION,
809                    bridge_version: secure_exec_bridge::bridge_contract().version,
810                }),
811            ))
812        };
813
814        // Small fixed capacity (independent of the production constant) with a
815        // drainer slow enough that the queue fills and the producer is forced
816        // onto the blocking path. The old try_send path errored on the
817        // (capacity + 1)th frame; backpressure accepts all of them.
818        let queue_cap = 8usize;
819        let total_frames = queue_cap * 3;
820        let (stdout_tx, stdout_rx) =
821            tracked_sync_channel::<ProtocolFrame>(TrackedLimit::SidecarStdoutFrames, queue_cap);
822        let drainer = std::thread::spawn(move || {
823            let mut drained = 0usize;
824            while stdout_rx.recv().is_ok() {
825                drained += 1;
826                std::thread::sleep(std::time::Duration::from_millis(1));
827            }
828            drained
829        });
830
831        for request_id in 0..total_frames {
832            send_output_frame(&stdout_tx, queue_frame(request_id as RequestId))
833                .expect("backpressured stdout queue must accept frames, not crash");
834        }
835        drop(stdout_tx);
836        let drained = drainer.join().expect("drainer thread panicked");
837        assert_eq!(
838            drained, total_frames,
839            "every frame must survive the backpressured queue"
840        );
841
842        // When the writer (receiver) is gone, overflow is genuinely terminal and
843        // still surfaces as a BrokenPipe error rather than blocking forever.
844        let (closed_tx, closed_rx) =
845            tracked_sync_channel::<ProtocolFrame>(TrackedLimit::SidecarStdoutFrames, queue_cap);
846        drop(closed_rx);
847        let error = send_output_frame(&closed_tx, queue_frame(0))
848            .expect_err("send to a dropped writer must error");
849        assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
850    }
851
852    // Regression (M5): the active-session set must shrink when a session is
853    // disposed. `track_session_state` is insert-only, so the transport relies on
854    // `untrack_disposed_sessions` draining the sidecar's disposed-session signal;
855    // without it a long-lived connection's set grows per session forever and the
856    // ~250us event pump iterates every dead entry.
857    #[test]
858    fn disposed_sessions_are_untracked_from_active_sessions() {
859        let mut active_sessions = BTreeSet::<SessionScope>::new();
860        let mut active_connections = BTreeSet::<String>::new();
861        track_session_state(
862            &ResponsePayload::SessionOpenedResponse(SessionOpenedResponse {
863                session_id: String::from("session-1"),
864                owner_connection_id: String::from("conn-1"),
865            }),
866            &mut active_sessions,
867            &mut active_connections,
868        );
869        assert_eq!(
870            active_sessions.len(),
871            1,
872            "opening a session should track it for the event pump"
873        );
874
875        untrack_disposed_sessions(
876            &[(String::from("conn-1"), String::from("session-1"))],
877            &mut active_sessions,
878        );
879        assert!(
880            active_sessions.is_empty(),
881            "a disposed session must be removed from the active-session set"
882        );
883    }
884
885    #[test]
886    fn read_frame_decodes_wire_authenticate_request() {
887        let codec = WireFrameCodec::new(wire::DEFAULT_MAX_FRAME_BYTES);
888        let frame = ProtocolFrame::RequestFrame(request_frame(
889            1,
890            connection_ownership("client-hint"),
891            RequestPayload::AuthenticateRequest(AuthenticateRequest {
892                client_name: "probe".to_string(),
893                auth_token: "probe-token".to_string(),
894                protocol_version: wire::PROTOCOL_VERSION,
895                bridge_version: secure_exec_bridge::bridge_contract().version,
896            }),
897        ));
898        let encoded = codec.encode(&frame).expect("encode wire frame");
899        let mut reader = Cursor::new(encoded);
900
901        let decoded = read_frame(&codec, &mut reader)
902            .expect("decode bare frame")
903            .expect("frame present");
904
905        assert_eq!(decoded, frame);
906    }
907
908    #[test]
909    fn extension_close_interrupts_matching_blocking_request() {
910        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
911        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
912        let close = ProtocolFrame::RequestFrame(test_extension_request_frame(
913            11,
914            ownership,
915            "close:ext-session-1",
916        ));
917
918        let blocking_request = blocking_extension_request(&prompt);
919        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &close)
920            .expect("close should interrupt prompt");
921
922        assert_eq!(interrupt.interrupted_dispatch.response.request_id, 10);
923        let ResponsePayload::ExtEnvelope(envelope) =
924            interrupt.interrupted_dispatch.response.payload
925        else {
926            panic!("expected extension response");
927        };
928        assert_eq!(envelope.namespace, TEST_EXTENSION_NAMESPACE);
929        assert_eq!(envelope.payload, b"prompt-cancelled:ext-session-1");
930    }
931
932    #[test]
933    fn extension_cancel_interrupt_gets_synthetic_response() {
934        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
935        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
936        let cancel = ProtocolFrame::RequestFrame(test_extension_request_frame(
937            11,
938            ownership,
939            "cancel:ext-session-1",
940        ));
941
942        let blocking_request = blocking_extension_request(&prompt);
943        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &cancel)
944            .expect("cancel should interrupt prompt");
945        let response = interrupt
946            .interrupting_response
947            .expect("cancel should get a response");
948
949        assert_eq!(response.request_id, 11);
950        let ResponsePayload::ExtEnvelope(envelope) = response.payload else {
951            panic!("expected extension response");
952        };
953        assert_eq!(envelope.namespace, TEST_EXTENSION_NAMESPACE);
954        assert_eq!(envelope.payload, b"cancelled:ext-session-1");
955    }
956
957    #[test]
958    fn kill_process_interrupts_blocking_extension_request() {
959        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
960        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
961        let kill = ProtocolFrame::RequestFrame(request_frame(
962            11,
963            ownership,
964            RequestPayload::KillProcessRequest(KillProcessRequest {
965                process_id: "adapter-process".to_string(),
966                signal: "SIGTERM".to_string(),
967            }),
968        ));
969
970        let blocking_request = blocking_extension_request(&prompt);
971        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &kill)
972            .expect("kill should interrupt prompt");
973
974        assert_eq!(interrupt.interrupted_dispatch.response.request_id, 10);
975        assert!(interrupt.interrupting_response.is_none());
976    }
977
978    fn test_extension_request_frame(
979        request_id: RequestId,
980        ownership: OwnershipScope,
981        payload: &str,
982    ) -> RequestFrame {
983        request_frame(
984            request_id,
985            ownership,
986            RequestPayload::ExtEnvelope(ExtEnvelope {
987                namespace: TEST_EXTENSION_NAMESPACE.to_string(),
988                payload: payload.as_bytes().to_vec(),
989            }),
990        )
991    }
992
993    fn blocking_extension_request(request: &RequestFrame) -> BlockingExtensionRequest {
994        let RequestPayload::ExtEnvelope(envelope) = &request.payload else {
995            panic!("expected extension request");
996        };
997        BlockingExtensionRequest {
998            namespace: TEST_EXTENSION_NAMESPACE.to_string(),
999            payload: envelope.payload.clone(),
1000            extension: Arc::new(TestBlockingInterruptExtension),
1001        }
1002    }
1003
1004    struct TestBlockingInterruptExtension;
1005
1006    impl Extension for TestBlockingInterruptExtension {
1007        fn namespace(&self) -> &str {
1008            TEST_EXTENSION_NAMESPACE
1009        }
1010
1011        fn handle_request<'a>(
1012            &'a self,
1013            _ctx: ExtensionContext<'a>,
1014            _payload: Vec<u8>,
1015        ) -> ExtensionFuture<'a, ExtensionResponse> {
1016            Box::pin(async { Ok(ExtensionResponse::new(Vec::new())) })
1017        }
1018
1019        fn is_blocking_request(&self, payload: &[u8]) -> bool {
1020            parse_test_payload(payload).is_some_and(|(kind, _session_id)| kind == "prompt")
1021        }
1022
1023        fn interrupt_blocking_request(
1024            &self,
1025            blocking_payload: &[u8],
1026            interrupt: ExtensionInterruptRequest<'_>,
1027        ) -> Option<ExtensionInterruptResponse> {
1028            let (blocking_kind, blocking_session_id) = parse_test_payload(blocking_payload)?;
1029            if blocking_kind != "prompt" {
1030                return None;
1031            }
1032
1033            let interrupted_response_payload =
1034                encode_test_response("prompt-cancelled", blocking_session_id);
1035            match interrupt {
1036                ExtensionInterruptRequest::KillProcess => Some(ExtensionInterruptResponse {
1037                    interrupted_response_payload,
1038                    interrupting_response_payload: None,
1039                }),
1040                ExtensionInterruptRequest::ExtensionPayload(payload) => {
1041                    let (interrupt_kind, interrupt_session_id) = parse_test_payload(payload)?;
1042                    match interrupt_kind {
1043                        "close" if interrupt_session_id == blocking_session_id => {
1044                            Some(ExtensionInterruptResponse {
1045                                interrupted_response_payload,
1046                                interrupting_response_payload: None,
1047                            })
1048                        }
1049                        "cancel" if interrupt_session_id == blocking_session_id => {
1050                            Some(ExtensionInterruptResponse {
1051                                interrupted_response_payload,
1052                                interrupting_response_payload: Some(encode_test_response(
1053                                    "cancelled",
1054                                    interrupt_session_id,
1055                                )),
1056                            })
1057                        }
1058                        "prompt" | "close" | "cancel" => None,
1059                        _ => None,
1060                    }
1061                }
1062            }
1063        }
1064    }
1065
1066    fn parse_test_payload(payload: &[u8]) -> Option<(&str, &str)> {
1067        let payload = std::str::from_utf8(payload).ok()?;
1068        payload.split_once(':')
1069    }
1070
1071    fn encode_test_response(kind: &str, session_id: &str) -> Vec<u8> {
1072        format!("{kind}:{session_id}").into_bytes()
1073    }
1074}
1075
1076#[derive(Debug, Clone)]
1077pub(crate) struct LocalBridge {
1078    started_at: Instant,
1079    next_timer_id: usize,
1080    snapshots: BTreeMap<String, FilesystemSnapshot>,
1081}
1082
1083impl Default for LocalBridge {
1084    fn default() -> Self {
1085        Self {
1086            started_at: Instant::now(),
1087            next_timer_id: 0,
1088            snapshots: BTreeMap::new(),
1089        }
1090    }
1091}
1092
1093impl BridgeTypes for LocalBridge {
1094    type Error = LocalBridgeError;
1095}
1096
1097impl FilesystemBridge for LocalBridge {
1098    fn read_file(&mut self, request: ReadFileRequest) -> Result<Vec<u8>, Self::Error> {
1099        fs::read(Self::host_path(&request.path))
1100            .map_err(|error| LocalBridgeError::io("read", &request.path, error))
1101    }
1102
1103    fn write_file(&mut self, request: WriteFileRequest) -> Result<(), Self::Error> {
1104        let host_path = Self::host_path(&request.path);
1105        if let Some(parent) = host_path.parent() {
1106            fs::create_dir_all(parent)
1107                .map_err(|error| LocalBridgeError::io("mkdir", &request.path, error))?;
1108        }
1109        fs::write(host_path, request.contents)
1110            .map_err(|error| LocalBridgeError::io("write", &request.path, error))
1111    }
1112
1113    fn stat(&mut self, request: PathRequest) -> Result<FileMetadata, Self::Error> {
1114        fs::metadata(Self::host_path(&request.path))
1115            .map(Self::file_metadata)
1116            .map_err(|error| LocalBridgeError::io("stat", &request.path, error))
1117    }
1118
1119    fn lstat(&mut self, request: PathRequest) -> Result<FileMetadata, Self::Error> {
1120        fs::symlink_metadata(Self::host_path(&request.path))
1121            .map(Self::file_metadata)
1122            .map_err(|error| LocalBridgeError::io("lstat", &request.path, error))
1123    }
1124
1125    fn read_dir(&mut self, request: ReadDirRequest) -> Result<Vec<DirectoryEntry>, Self::Error> {
1126        let mut entries = fs::read_dir(Self::host_path(&request.path))
1127            .map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?
1128            .map(|entry| {
1129                let entry =
1130                    entry.map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?;
1131                let kind = entry
1132                    .file_type()
1133                    .map(Self::file_kind)
1134                    .map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?;
1135                Ok(DirectoryEntry {
1136                    name: entry.file_name().to_string_lossy().into_owned(),
1137                    kind,
1138                })
1139            })
1140            .collect::<Result<Vec<_>, LocalBridgeError>>()?;
1141        entries.sort_by(|left, right| left.name.cmp(&right.name));
1142        Ok(entries)
1143    }
1144
1145    fn create_dir(&mut self, request: CreateDirRequest) -> Result<(), Self::Error> {
1146        let host_path = Self::host_path(&request.path);
1147        if request.recursive {
1148            fs::create_dir_all(host_path)
1149        } else {
1150            fs::create_dir(host_path)
1151        }
1152        .map_err(|error| LocalBridgeError::io("mkdir", &request.path, error))
1153    }
1154
1155    fn remove_file(&mut self, request: PathRequest) -> Result<(), Self::Error> {
1156        fs::remove_file(Self::host_path(&request.path))
1157            .map_err(|error| LocalBridgeError::io("unlink", &request.path, error))
1158    }
1159
1160    fn remove_dir(&mut self, request: PathRequest) -> Result<(), Self::Error> {
1161        fs::remove_dir(Self::host_path(&request.path))
1162            .map_err(|error| LocalBridgeError::io("rmdir", &request.path, error))
1163    }
1164
1165    fn rename(&mut self, request: RenameRequest) -> Result<(), Self::Error> {
1166        let from_path = Self::host_path(&request.from_path);
1167        let to_path = Self::host_path(&request.to_path);
1168        if let Some(parent) = to_path.parent() {
1169            fs::create_dir_all(parent)
1170                .map_err(|error| LocalBridgeError::io("mkdir", &request.to_path, error))?;
1171        }
1172        fs::rename(from_path, to_path).map_err(|error| {
1173            LocalBridgeError::unsupported(format!(
1174                "rename {} -> {}: {}",
1175                request.from_path, request.to_path, error
1176            ))
1177        })
1178    }
1179
1180    fn symlink(&mut self, request: SymlinkRequest) -> Result<(), Self::Error> {
1181        let link_path = Self::host_path(&request.link_path);
1182        if let Some(parent) = link_path.parent() {
1183            fs::create_dir_all(parent)
1184                .map_err(|error| LocalBridgeError::io("mkdir", &request.link_path, error))?;
1185        }
1186        create_symlink(&request.target_path, link_path)
1187            .map_err(|error| LocalBridgeError::io("symlink", &request.link_path, error))
1188    }
1189
1190    fn read_link(&mut self, request: PathRequest) -> Result<String, Self::Error> {
1191        fs::read_link(Self::host_path(&request.path))
1192            .map(|target| target.to_string_lossy().into_owned())
1193            .map_err(|error| LocalBridgeError::io("readlink", &request.path, error))
1194    }
1195
1196    fn chmod(&mut self, request: ChmodRequest) -> Result<(), Self::Error> {
1197        let permissions = fs::Permissions::from_mode(request.mode);
1198        fs::set_permissions(Self::host_path(&request.path), permissions)
1199            .map_err(|error| LocalBridgeError::io("chmod", &request.path, error))
1200    }
1201
1202    fn truncate(&mut self, request: TruncateRequest) -> Result<(), Self::Error> {
1203        OpenOptions::new()
1204            .write(true)
1205            .create(false)
1206            .open(Self::host_path(&request.path))
1207            .and_then(|file| file.set_len(request.len))
1208            .map_err(|error| LocalBridgeError::io("truncate", &request.path, error))
1209    }
1210
1211    fn exists(&mut self, request: PathRequest) -> Result<bool, Self::Error> {
1212        Ok(fs::symlink_metadata(Self::host_path(&request.path)).is_ok())
1213    }
1214}
1215
1216impl PermissionBridge for LocalBridge {
1217    fn check_filesystem_access(
1218        &mut self,
1219        request: FilesystemPermissionRequest,
1220    ) -> Result<PermissionDecision, Self::Error> {
1221        Ok(PermissionDecision::deny(format!(
1222            "no static filesystem policy registered for {}:{}",
1223            request.vm_id, request.path
1224        )))
1225    }
1226
1227    fn check_network_access(
1228        &mut self,
1229        request: NetworkPermissionRequest,
1230    ) -> Result<PermissionDecision, Self::Error> {
1231        Ok(PermissionDecision::deny(format!(
1232            "no static network policy registered for {}:{}",
1233            request.vm_id, request.resource
1234        )))
1235    }
1236
1237    fn check_command_execution(
1238        &mut self,
1239        request: CommandPermissionRequest,
1240    ) -> Result<PermissionDecision, Self::Error> {
1241        Ok(PermissionDecision::deny(format!(
1242            "no static child_process policy registered for {}:{}",
1243            request.vm_id, request.command
1244        )))
1245    }
1246
1247    fn check_environment_access(
1248        &mut self,
1249        request: EnvironmentPermissionRequest,
1250    ) -> Result<PermissionDecision, Self::Error> {
1251        Ok(PermissionDecision::deny(format!(
1252            "no static env policy registered for {}:{}",
1253            request.vm_id, request.key
1254        )))
1255    }
1256}
1257
1258impl PersistenceBridge for LocalBridge {
1259    fn load_filesystem_state(
1260        &mut self,
1261        request: LoadFilesystemStateRequest,
1262    ) -> Result<Option<FilesystemSnapshot>, Self::Error> {
1263        Ok(self.snapshots.get(&request.vm_id).cloned())
1264    }
1265
1266    fn flush_filesystem_state(
1267        &mut self,
1268        request: FlushFilesystemStateRequest,
1269    ) -> Result<(), Self::Error> {
1270        self.snapshots.insert(request.vm_id, request.snapshot);
1271        Ok(())
1272    }
1273}
1274
1275impl ClockBridge for LocalBridge {
1276    fn wall_clock(&mut self, _request: ClockRequest) -> Result<SystemTime, Self::Error> {
1277        Ok(SystemTime::now())
1278    }
1279
1280    fn monotonic_clock(&mut self, _request: ClockRequest) -> Result<Duration, Self::Error> {
1281        Ok(self.started_at.elapsed())
1282    }
1283
1284    fn schedule_timer(
1285        &mut self,
1286        request: ScheduleTimerRequest,
1287    ) -> Result<ScheduledTimer, Self::Error> {
1288        self.next_timer_id += 1;
1289        Ok(ScheduledTimer {
1290            timer_id: format!("timer-{}", self.next_timer_id),
1291            delay: request.delay,
1292        })
1293    }
1294}
1295
1296impl RandomBridge for LocalBridge {
1297    fn fill_random_bytes(&mut self, request: RandomBytesRequest) -> Result<Vec<u8>, Self::Error> {
1298        Ok(vec![0u8; request.len])
1299    }
1300}
1301
1302impl EventBridge for LocalBridge {
1303    fn emit_structured_event(&mut self, _event: StructuredEventRecord) -> Result<(), Self::Error> {
1304        Ok(())
1305    }
1306
1307    fn emit_diagnostic(&mut self, _event: DiagnosticRecord) -> Result<(), Self::Error> {
1308        Ok(())
1309    }
1310
1311    fn emit_log(&mut self, _event: LogRecord) -> Result<(), Self::Error> {
1312        Ok(())
1313    }
1314
1315    fn emit_lifecycle(&mut self, _event: LifecycleEventRecord) -> Result<(), Self::Error> {
1316        Ok(())
1317    }
1318}
1319
1320impl ExecutionBridge for LocalBridge {
1321    fn create_javascript_context(
1322        &mut self,
1323        _request: CreateJavascriptContextRequest,
1324    ) -> Result<GuestContextHandle, Self::Error> {
1325        Err(LocalBridgeError::unsupported(
1326            "execution bridge is handled internally by the native sidecar",
1327        ))
1328    }
1329
1330    fn create_wasm_context(
1331        &mut self,
1332        _request: CreateWasmContextRequest,
1333    ) -> Result<GuestContextHandle, Self::Error> {
1334        Err(LocalBridgeError::unsupported(
1335            "execution bridge is handled internally by the native sidecar",
1336        ))
1337    }
1338
1339    fn start_execution(
1340        &mut self,
1341        _request: StartExecutionRequest,
1342    ) -> Result<StartedExecution, Self::Error> {
1343        Err(LocalBridgeError::unsupported(
1344            "execution bridge is handled internally by the native sidecar",
1345        ))
1346    }
1347
1348    fn write_stdin(&mut self, _request: WriteExecutionStdinRequest) -> Result<(), Self::Error> {
1349        Err(LocalBridgeError::unsupported(
1350            "execution bridge is handled internally by the native sidecar",
1351        ))
1352    }
1353
1354    fn close_stdin(&mut self, _request: ExecutionHandleRequest) -> Result<(), Self::Error> {
1355        Err(LocalBridgeError::unsupported(
1356            "execution bridge is handled internally by the native sidecar",
1357        ))
1358    }
1359
1360    fn kill_execution(&mut self, _request: KillExecutionRequest) -> Result<(), Self::Error> {
1361        Err(LocalBridgeError::unsupported(
1362            "execution bridge is handled internally by the native sidecar",
1363        ))
1364    }
1365
1366    fn poll_execution_event(
1367        &mut self,
1368        _request: PollExecutionEventRequest,
1369    ) -> Result<Option<ExecutionEvent>, Self::Error> {
1370        Ok(None)
1371    }
1372}
1373
1374#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1375struct SessionScope {
1376    connection_id: String,
1377    session_id: String,
1378}
1379
1380impl SessionScope {
1381    fn ownership_scope(&self) -> OwnershipScope {
1382        session_ownership(&self.connection_id, &self.session_id)
1383    }
1384
1385    fn compat_ownership_scope(&self) -> crate::protocol::OwnershipScope {
1386        wire::ownership_scope_to_compat(self.ownership_scope())
1387    }
1388}
1389
1390/// Live event sink backed by the outbound stdout channel. Writes each event as a
1391/// `ProtocolFrame::EventFrame` immediately, using the same blocking
1392/// backpressure semantics as the batch event path (`send_output_frame`): a full
1393/// queue parks the producer until the writer thread drains stdout rather than
1394/// tearing down the process.
1395struct FrameEventTransport {
1396    writer: TrackedSyncSender<ProtocolFrame>,
1397}
1398
1399impl FrameEventTransport {
1400    fn new(writer: TrackedSyncSender<ProtocolFrame>) -> Self {
1401        Self { writer }
1402    }
1403}
1404
1405impl EventSinkTransport for FrameEventTransport {
1406    fn emit_event(&self, event: crate::wire::EventFrame) -> Result<(), SidecarError> {
1407        send_output_frame(&self.writer, ProtocolFrame::EventFrame(event))
1408            .map_err(|error| SidecarError::Bridge(error.to_string()))
1409    }
1410}
1411
1412struct FrameSidecarRequestTransport {
1413    writer: TrackedSyncSender<ProtocolFrame>,
1414    pending: Arc<Mutex<BTreeMap<RequestId, mpsc::SyncSender<SidecarResponseFrame>>>>,
1415}
1416
1417impl FrameSidecarRequestTransport {
1418    fn new(writer: TrackedSyncSender<ProtocolFrame>) -> Self {
1419        Self {
1420            writer,
1421            pending: Arc::new(Mutex::new(BTreeMap::new())),
1422        }
1423    }
1424
1425    fn accept_response(&self, response: SidecarResponseFrame) -> bool {
1426        let sender = {
1427            let mut pending = match self.pending.lock() {
1428                Ok(pending) => pending,
1429                Err(_) => return false,
1430            };
1431            pending.remove(&response.request_id)
1432        };
1433        let Some(sender) = sender else {
1434            return false;
1435        };
1436        let _ = sender.send(response);
1437        true
1438    }
1439}
1440
1441impl SidecarRequestTransport for FrameSidecarRequestTransport {
1442    fn send_request(
1443        &self,
1444        request: crate::protocol::SidecarRequestFrame,
1445        timeout: Duration,
1446    ) -> Result<crate::protocol::SidecarResponseFrame, SidecarError> {
1447        let request =
1448            wire::sidecar_request_frame_from_compat(request).map_err(wire_protocol_error)?;
1449        let (sender, receiver) = mpsc::sync_channel(1);
1450        self.pending
1451            .lock()
1452            .map_err(|_| {
1453                SidecarError::Bridge(String::from("sidecar callback waiter map lock poisoned"))
1454            })?
1455            .insert(request.request_id, sender);
1456        // Bound the request-frame WRITE by the caller's deadline. The shared
1457        // `send_output_frame` blocks (correct backpressure for the fire-and-forget
1458        // event/response paths), but this request path has a `timeout` that the
1459        // response wait below already honors — so a stalled host stdout must not
1460        // make the *send* block past it. Poll try_send until a slot frees or the
1461        // deadline passes.
1462        let write_deadline = Instant::now() + timeout;
1463        let mut frame = ProtocolFrame::SidecarRequestFrame(request.clone());
1464        let write_result = loop {
1465            match self.writer.try_send(frame) {
1466                Ok(()) => break Ok(()),
1467                Err(mpsc::TrySendError::Disconnected(_)) => {
1468                    break Err(String::from("stdout writer disconnected"));
1469                }
1470                Err(mpsc::TrySendError::Full(returned)) => {
1471                    if Instant::now() >= write_deadline {
1472                        break Err(format!(
1473                            "timed out writing sidecar request frame after {}s",
1474                            timeout.as_secs()
1475                        ));
1476                    }
1477                    frame = returned;
1478                    thread::sleep(Duration::from_millis(1));
1479                }
1480            }
1481        };
1482        if let Err(message) = write_result {
1483            let _ = self
1484                .pending
1485                .lock()
1486                .map(|mut pending| pending.remove(&request.request_id));
1487            return Err(SidecarError::Io(format!(
1488                "failed to write sidecar request frame: {message}"
1489            )));
1490        }
1491        match receiver.recv_timeout(timeout) {
1492            Ok(response) => {
1493                wire::sidecar_response_frame_to_compat(response).map_err(wire_protocol_error)
1494            }
1495            Err(mpsc::RecvTimeoutError::Timeout) => {
1496                let _ = self
1497                    .pending
1498                    .lock()
1499                    .map(|mut pending| pending.remove(&request.request_id));
1500                Err(SidecarError::Io(format!(
1501                    "timed out waiting for sidecar response after {}s",
1502                    timeout.as_secs()
1503                )))
1504            }
1505            Err(mpsc::RecvTimeoutError::Disconnected) => Err(SidecarError::Io(String::from(
1506                "sidecar response waiter disconnected",
1507            ))),
1508        }
1509    }
1510}
1511
1512#[derive(Debug, Clone, PartialEq, Eq)]
1513pub(crate) struct LocalBridgeError {
1514    message: String,
1515}
1516
1517impl LocalBridgeError {
1518    fn unsupported(message: impl Into<String>) -> Self {
1519        Self {
1520            message: message.into(),
1521        }
1522    }
1523
1524    fn io(operation: &str, path: &str, error: io::Error) -> Self {
1525        Self::unsupported(format!("{operation} {path}: {error}"))
1526    }
1527}
1528
1529impl fmt::Display for LocalBridgeError {
1530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1531        f.write_str(&self.message)
1532    }
1533}
1534
1535impl Error for LocalBridgeError {}
1536
1537impl LocalBridge {
1538    fn host_path(path: &str) -> PathBuf {
1539        let candidate = Path::new(path);
1540        if candidate.is_absolute() {
1541            candidate.to_path_buf()
1542        } else {
1543            std::env::current_dir()
1544                .unwrap_or_else(|_| PathBuf::from("."))
1545                .join(candidate)
1546        }
1547    }
1548
1549    fn file_metadata(metadata: fs::Metadata) -> FileMetadata {
1550        FileMetadata {
1551            mode: metadata.permissions().mode(),
1552            size: metadata.size(),
1553            kind: Self::file_kind(metadata.file_type()),
1554        }
1555    }
1556
1557    fn file_kind(file_type: fs::FileType) -> secure_exec_bridge::FileKind {
1558        if file_type.is_file() {
1559            secure_exec_bridge::FileKind::File
1560        } else if file_type.is_dir() {
1561            secure_exec_bridge::FileKind::Directory
1562        } else if file_type.is_symlink() {
1563            secure_exec_bridge::FileKind::SymbolicLink
1564        } else {
1565            secure_exec_bridge::FileKind::Other
1566        }
1567    }
1568}