1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

egui_kittest: connect inspector over local socket instead of child stdio

The harness inspector now speaks the wire protocol over the same
interprocess local socket as the live egui_inspection plugin, in two
modes:
- connect: EGUI_INSPECTION_SOCKET set -> dial the listening socket
  (e.g. the kittest MCP bridge).
- spawn: KITTEST_INSPECTOR truthy, no socket -> bind a socket, spawn
  the kittest_inspector binary pointed at it, accept.

env_enabled() now also auto-enables when the socket var is set. Pulls
egui_inspection/transport into the inspector_api feature.
This commit is contained in:
lucasmerlin
2026-05-26 16:28:33 +02:00
parent 501eaba386
commit e3bdddf306
2 changed files with 91 additions and 52 deletions

View File

@@ -29,7 +29,7 @@ snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
## Expose the [`inspector_api`] wire protocol used to talk to the external ## Expose the [`inspector_api`] wire protocol used to talk to the external
## `kittest_inspector` binary. Pull this in if you're building a tool that consumes the ## `kittest_inspector` binary. Pull this in if you're building a tool that consumes the
## same stream — the binary itself enables this transitively. ## same stream — the binary itself enables this transitively.
inspector_api = ["dep:egui_inspection", "egui/serde"] inspector_api = ["dep:egui_inspection", "egui_inspection/transport", "egui/serde"]
## Stream frames + accesskit tree to a `kittest_inspector` window for live debugging. ## Stream frames + accesskit tree to a `kittest_inspector` window for live debugging.
## Auto-launches when the `KITTEST_INSPECTOR` env var is truthy. ## Auto-launches when the `KITTEST_INSPECTOR` env var is truthy.

View File

@@ -1,19 +1,25 @@
//! [`InspectorPlugin`] — connect a [`crate::Harness`] to a `kittest_inspector` process for //! [`InspectorPlugin`] — connect a [`crate::Harness`] to an inspector for live debugging.
//! live debugging.
//! //!
//! The plugin spawns the inspector as a child process and communicates over stdin/stdout //! The plugin speaks the [`crate::inspector_api`] wire protocol over a local socket — the
//! using the [`crate::inspector_api`] wire protocol. A background reader thread receives //! same transport the live [`egui_inspection::InspectionPlugin`] uses. Two topologies:
//! [`InspectorCommand`]s from the inspector and pushes them into an mpsc channel, so the
//! plugin can check for commands non-blockingly during `Play` mode and block for them in
//! `Paused` mode.
//! //!
//! Auto-registered on harness creation when the [`INSPECTOR_ENV_VAR`] env var is truthy. //! - **connect** ([`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] set): the harness dials an
//! already-listening socket (e.g. the kittest MCP bridge).
//! - **spawn** ([`INSPECTOR_ENV_VAR`] truthy, no socket var): the harness binds a socket,
//! spawns the `kittest_inspector` binary pointed at it, and accepts — standalone "pop up
//! an inspector" debugging.
//!
//! A background reader thread receives [`InspectorCommand`]s from the inspector and pushes
//! them into an mpsc channel, so the plugin can check for commands non-blockingly during
//! `Play` mode and block for them in `Paused` mode.
//!
//! Auto-registered on harness creation when either env var requests it (see [`env_enabled`]).
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{BufReader, BufWriter}; use std::io::{BufReader, BufWriter};
use std::panic::Location; use std::panic::Location;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::{LazyLock, OnceLock}; use std::sync::{LazyLock, OnceLock};
use std::thread; use std::thread;
@@ -25,6 +31,7 @@ use egui_inspection::protocol::{
Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION, Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION,
PeerHello, PeerKind, SourceView, read_message, write_message, PeerHello, PeerKind, SourceView, read_message, write_message,
}; };
use egui_inspection::transport::{self, RecvHalf, SendHalf, SocketTarget};
use crate::{Harness, Plugin, TestResult}; use crate::{Harness, Plugin, TestResult};
/// Environment variable: when set to a truthy value, every harness auto-launches an inspector. /// Environment variable: when set to a truthy value, every harness auto-launches an inspector.
@@ -36,18 +43,21 @@ pub const INSPECTOR_PATH_ENV_VAR: &str = "KITTEST_INSPECTOR_PATH";
/// Errors that can occur attaching or talking to the inspector. /// Errors that can occur attaching or talking to the inspector.
#[derive(Debug)] #[derive(Debug)]
pub enum InspectorError { pub enum InspectorError {
/// Failed to launch the `kittest_inspector` binary. /// Failed to set up the connection: dial the socket (connect mode), or bind + spawn the
Launch(std::io::Error), /// `kittest_inspector` binary (spawn mode).
/// Failed to set up the child's stdio pipes. Connect(std::io::Error),
/// Failed to set up the reader/writer or send the initial handshake.
Pipe(String), Pipe(String),
} }
impl std::fmt::Display for InspectorError { impl std::fmt::Display for InspectorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Launch(err) => write!( Self::Connect(err) => write!(
f, f,
"failed to launch kittest_inspector (set {INSPECTOR_PATH_ENV_VAR} or put it on PATH): {err}" "failed to connect to inspector \
(set {} to dial a socket, or {INSPECTOR_PATH_ENV_VAR} / PATH to spawn one): {err}",
egui_inspection::INSPECTION_SOCKET_ENV_VAR,
), ),
Self::Pipe(msg) => write!(f, "inspector pipe setup failed: {msg}"), Self::Pipe(msg) => write!(f, "inspector pipe setup failed: {msg}"),
} }
@@ -90,10 +100,13 @@ pub struct InspectorPlugin {
} }
impl InspectorPlugin { impl InspectorPlugin {
/// Launch a `kittest_inspector` child process and attach this plugin to it. /// Connect this plugin to an inspector: dial the socket in
/// [`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] (connect mode), or bind a socket and
/// spawn a `kittest_inspector` child (spawn mode).
/// ///
/// # Errors /// # Errors
/// If the inspector binary cannot be launched or its stdio pipes fail to set up. /// If the socket can't be dialed/bound, the inspector binary can't be spawned, or the
/// handshake fails.
pub fn launch(label: Option<String>) -> Result<Self, InspectorError> { pub fn launch(label: Option<String>) -> Result<Self, InspectorError> {
Ok(Self { Ok(Self {
conn: Connection::launch(label)?, conn: Connection::launch(label)?,
@@ -316,50 +329,66 @@ impl InspectorPlugin {
} }
} }
/// The inspector's child-process connection + step counter. Private — [`InspectorPlugin`] is /// The inspector connection (local socket) + step counter. Private — [`InspectorPlugin`] is
/// the public wrapper. /// the public wrapper.
struct Connection { struct Connection {
writer: BufWriter<ChildStdin>, writer: BufWriter<SendHalf>,
command_rx: mpsc::Receiver<InspectorCommand>, command_rx: mpsc::Receiver<InspectorCommand>,
_reader_thread: thread::JoinHandle<()>, _reader_thread: thread::JoinHandle<()>,
_child: Child, /// Spawn mode only: the `kittest_inspector` child we started. Kept alive so the inspector
/// window outlives the connection. `None` in connect mode (we don't own the peer).
_child: Option<Child>,
/// Spawn mode only: owns the socket file (on unix). Kept alive for the socket's lifetime.
/// `None` in connect mode.
_socket_target: Option<SocketTarget>,
step: u64, step: u64,
broken: bool, broken: bool,
} }
impl Connection { impl Connection {
fn launch(label: Option<String>) -> Result<Self, InspectorError> { fn launch(label: Option<String>) -> Result<Self, InspectorError> {
let bin = std::env::var(INSPECTOR_PATH_ENV_VAR) // Two topologies, both ending in a split local-socket stream. Connect mode wins when
.map(PathBuf::from) // the socket var is set (the inspector/bridge already bound it).
.unwrap_or_else(|_| PathBuf::from("kittest_inspector")); let (reader, writer, child, socket_target) =
if let Ok(socket) = std::env::var(egui_inspection::INSPECTION_SOCKET_ENV_VAR) {
// Connect mode: dial the already-listening socket.
let (r, w) = transport::connect(&socket).map_err(InspectorError::Connect)?;
(r, w, None, None)
} else {
// Spawn mode: bind a socket, spawn the inspector pointed at it, accept.
let target =
transport::generate_socket_target().map_err(InspectorError::Connect)?;
let listener =
transport::Listener::bind(&target.name).map_err(InspectorError::Connect)?;
// Important: do NOT inherit stderr. The cargo-test / nextest stderr capture pipe can let bin = std::env::var(INSPECTOR_PATH_ENV_VAR)
// close between tests while the inspector is still alive; a later `eprintln!` in the .map(PathBuf::from)
// inspector would then panic ("failed printing to stderr: Broken pipe") and take the .unwrap_or_else(|_| PathBuf::from("kittest_inspector"));
// window down. The inspector keeps its own log file for diagnostics.
let mut child = Command::new(&bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(InspectorError::Launch)?;
let stdin = child // Important: do NOT inherit stderr. The cargo-test / nextest stderr capture
.stdin // pipe can close between tests while the inspector is still alive; a later
.take() // `eprintln!` in the inspector would then panic ("failed printing to stderr:
.ok_or_else(|| InspectorError::Pipe("missing child stdin".into()))?; // Broken pipe") and take the window down. The inspector keeps its own log
let stdout = child // file for diagnostics.
.stdout let child = Command::new(&bin)
.take() .env(egui_inspection::INSPECTION_SOCKET_ENV_VAR, &target.name)
.ok_or_else(|| InspectorError::Pipe("missing child stdout".into()))?; .stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(InspectorError::Connect)?;
let (r, w) = listener.accept().map_err(InspectorError::Connect)?;
(r, w, Some(child), Some(target))
};
let (command_tx, command_rx) = mpsc::channel::<InspectorCommand>(); let (command_tx, command_rx) = mpsc::channel::<InspectorCommand>();
let reader_thread = thread::Builder::new() let reader_thread = thread::Builder::new()
.name("kittest_inspector_reader".into()) .name("kittest_inspector_reader".into())
.spawn(move || run_reader(BufReader::new(stdout), &command_tx)) .spawn(move || run_reader(BufReader::new(reader), &command_tx))
.map_err(|err| InspectorError::Pipe(format!("spawn reader thread: {err}")))?; .map_err(|err| InspectorError::Pipe(format!("spawn reader thread: {err}")))?;
let mut writer = BufWriter::new(stdin); let mut writer = BufWriter::new(writer);
// Hello must be the first message on the wire — the inspector reads it before any // Hello must be the first message on the wire — the inspector reads it before any
// Frame to decide which controls to render. // Frame to decide which controls to render.
@@ -380,6 +409,7 @@ impl Connection {
command_rx, command_rx,
_reader_thread: reader_thread, _reader_thread: reader_thread,
_child: child, _child: child,
_socket_target: socket_target,
step: 0, step: 0,
broken: false, broken: false,
}) })
@@ -445,7 +475,7 @@ impl Connection {
/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the mpsc /// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the mpsc
/// channel until EOF or the receiver is dropped. /// channel until EOF or the receiver is dropped.
fn run_reader(mut reader: BufReader<ChildStdout>, tx: &mpsc::Sender<InspectorCommand>) { fn run_reader(mut reader: BufReader<RecvHalf>, tx: &mpsc::Sender<InspectorCommand>) {
loop { loop {
match read_message::<_, InspectorCommand>(&mut reader) { match read_message::<_, InspectorCommand>(&mut reader) {
Ok(cmd) => { Ok(cmd) => {
@@ -524,15 +554,24 @@ fn resolve_and_read(path: &str) -> Option<String> {
None None
} }
/// Read [`INSPECTOR_ENV_VAR`] once and cache. Exposed to [`crate::Harness::from_builder`] /// Whether to auto-register an [`InspectorPlugin`], read once and cached. Exposed to
/// so it can auto-register an [`InspectorPlugin`]. /// [`crate::Harness::from_builder`].
///
/// Enabled when either: [`egui_inspection::INSPECTION_SOCKET_ENV_VAR`] is set (connect mode —
/// an inspector/bridge already bound a socket for us), or [`INSPECTOR_ENV_VAR`] is truthy
/// (spawn mode — pop up an inspector ourselves).
pub(crate) fn env_enabled() -> bool { pub(crate) fn env_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new(); static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| match std::env::var(INSPECTOR_ENV_VAR) { *ENABLED.get_or_init(|| {
Ok(value) => matches!( if std::env::var_os(egui_inspection::INSPECTION_SOCKET_ENV_VAR).is_some() {
value.trim().to_ascii_lowercase().as_str(), return true;
"1" | "true" | "yes" | "on" }
), match std::env::var(INSPECTOR_ENV_VAR) {
Err(_) => false, Ok(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
}
}) })
} }