use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use serde_json::Value;
use thiserror::Error;
pub use crate::loader::{DebugFileConfig, default_debug_port};
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::dap::DapAdapter;
use crate::debug::session::DebugSession;
use crate::debug::transport::Transport;
pub(crate) mod breakpoints;
pub(crate) mod dap;
pub(crate) mod hook;
pub(crate) mod inspect;
pub(crate) mod session;
pub(crate) mod transport;
pub(crate) mod wiring;
pub mod types;
pub mod source_map;
pub use types::{
Breakpoint, FrameInfo, LineEvent, ResolvedBreakpoint, Scope, SessionCommand, SessionEvent,
SourceRef, StopReason, ThreadId, ThreadInfo, Variable,
};
const LOOPBACK: Ipv4Addr = Ipv4Addr::LOCALHOST;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceMode {
#[default]
Pasta,
Lua,
}
impl SourceMode {
pub fn parse(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"pasta" => SourceMode::Pasta,
"lua" => SourceMode::Lua,
other => {
tracing::warn!(
value = other,
"invalid source presentation mode; falling back to default `pasta`"
);
SourceMode::default()
}
}
}
fn as_u8(self) -> u8 {
match self {
SourceMode::Pasta => 0,
SourceMode::Lua => 1,
}
}
fn from_u8(v: u8) -> Self {
match v {
1 => SourceMode::Lua,
_ => SourceMode::Pasta,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct SharedSourceMode {
inner: Arc<std::sync::atomic::AtomicU8>,
}
impl SharedSourceMode {
pub(crate) fn new(mode: SourceMode) -> Self {
Self {
inner: Arc::new(std::sync::atomic::AtomicU8::new(mode.as_u8())),
}
}
pub(crate) fn get(&self) -> SourceMode {
SourceMode::from_u8(self.inner.load(Ordering::SeqCst))
}
pub(crate) fn set(&self, mode: SourceMode) {
self.inner.store(mode.as_u8(), Ordering::SeqCst);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebugConfig {
pub enabled: bool,
pub listen: Option<SocketAddr>,
pub source_mode: SourceMode,
pub source_map_sidecar: bool,
}
impl Default for DebugConfig {
fn default() -> Self {
Self {
enabled: false,
listen: None,
source_mode: SourceMode::Pasta,
source_map_sidecar: false,
}
}
}
impl DebugConfig {
#[allow(clippy::too_many_arguments)]
pub fn resolve(
file: Option<&DebugFileConfig>,
env_enabled: Option<bool>,
env_port: Option<u16>,
env_source_mode: Option<SourceMode>,
env_sidecar: Option<bool>,
file_source_mode: Option<SourceMode>,
file_sidecar: Option<bool>,
attach_source_mode: Option<SourceMode>,
) -> Self {
let file_enabled = file.map(|f| f.enabled).unwrap_or(false);
let file_port = file.map(|f| f.port).unwrap_or_else(default_debug_port);
let enabled = env_enabled.unwrap_or(file_enabled);
let port = env_port.unwrap_or(file_port);
let listen = if enabled {
Some(SocketAddr::V4(SocketAddrV4::new(LOOPBACK, port)))
} else {
None
};
let source_mode = attach_source_mode
.or(env_source_mode)
.or(file_source_mode)
.unwrap_or_default();
let source_map_sidecar = env_sidecar.or(file_sidecar).unwrap_or(false);
Self {
enabled,
listen,
source_mode,
source_map_sidecar,
}
}
pub fn from_env(file: Option<&DebugFileConfig>) -> Self {
let env_enabled = std::env::var("PASTA_DEBUG")
.ok()
.and_then(|v| parse_env_bool(&v));
let env_port = std::env::var("PASTA_DEBUG_PORT")
.ok()
.and_then(|v| v.trim().parse::<u16>().ok());
let env_source_mode = std::env::var("PASTA_DEBUG_SOURCE_MODE")
.ok()
.map(|v| SourceMode::parse(&v));
let env_sidecar = std::env::var("PASTA_DEBUG_SOURCE_MAP_SIDECAR")
.ok()
.and_then(|v| parse_env_bool(&v));
Self::resolve(
file,
env_enabled,
env_port,
env_source_mode,
env_sidecar,
file_source_mode(file), file_sidecar(file), None, )
}
pub fn from_file(file: Option<&DebugFileConfig>) -> Self {
Self::resolve(
file,
None,
None,
None,
None,
file_source_mode(file),
file_sidecar(file),
None,
)
}
}
fn file_source_mode(file: Option<&DebugFileConfig>) -> Option<SourceMode> {
file.and_then(|f| f.present_as.as_deref())
.map(SourceMode::parse)
}
fn file_sidecar(file: Option<&DebugFileConfig>) -> Option<bool> {
file.map(|f| f.source_map_sidecar)
}
fn parse_env_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" | "" => Some(false),
_ => None,
}
}
#[derive(Error, Debug)]
pub enum DebugError {
#[error("debug transport bind failed: {0}")]
Bind(#[source] std::io::Error),
#[error("debug protocol error: {0}")]
Protocol(String),
#[error("debug VM error: {0}")]
Vm(String),
#[error("debug client disconnected")]
Disconnected,
}
pub struct DebugHandle {
config: DebugConfig,
local_addr: Option<SocketAddr>,
shutdown: Arc<AtomicBool>,
socket_handle: Option<JoinHandle<()>>,
encoder_handle: Option<JoinHandle<()>>,
terminate_tx: mpsc::Sender<SessionEvent>,
}
impl std::fmt::Debug for DebugHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DebugHandle")
.field("config", &self.config)
.field("local_addr", &self.local_addr)
.finish_non_exhaustive()
}
}
impl DebugHandle {
pub fn config(&self) -> &DebugConfig {
&self.config
}
pub fn local_addr(&self) -> Option<SocketAddr> {
self.local_addr
}
}
impl Drop for DebugHandle {
fn drop(&mut self) {
let _ = self.terminate_tx.send(SessionEvent::Terminated);
std::thread::sleep(std::time::Duration::from_millis(30));
self.shutdown.store(true, Ordering::SeqCst);
if let Some(h) = self.socket_handle.take() {
let _ = h.join();
}
let _ = self.encoder_handle.take();
}
}
pub fn enable(
lua: &mlua::Lua,
cfg: &DebugConfig,
source_map: Option<Arc<source_map::SourceMap>>,
) -> Result<Option<DebugHandle>, DebugError> {
if !cfg.enabled {
return Ok(None);
}
let shared_mode = SharedSourceMode::new(cfg.source_mode);
let source_map_wiring = wiring::SourceMapWiring {
source_map: source_map.clone(),
source_mode: shared_mode.clone(),
};
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let terminate_tx = event_tx.clone();
let session = DebugSession::new(breakpoints.clone(), cmd_rx, event_tx)
.with_source_map(source_map.clone(), cfg.source_mode)
.with_shared_mode(Some(shared_mode.clone()));
crate::debug::hook::install(lua, session).map_err(|e| DebugError::Vm(e.to_string()))?;
let transport = Transport::start(cfg.listen).map_err(|e| {
let Some(listen) = cfg.listen else {
unreachable!("enabled => cfg.listen is Some (R5.5)")
};
tracing::warn!(addr = %listen, error = %e, "debug transport bind failed");
e
})?;
let local_addr = transport.local_addr();
if let Some(addr) = local_addr {
tracing::info!(addr = %addr, "debug backend listening");
}
let adapter: wiring::SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let (out_tx, out_rx) = mpsc::channel::<Value>();
let shutdown = Arc::new(AtomicBool::new(false));
let socket_handle = {
let adapter = Arc::clone(&adapter);
let breakpoints = breakpoints.clone();
let shutdown = Arc::clone(&shutdown);
let source_map_wiring = source_map_wiring.clone();
std::thread::spawn(move || {
wiring::run_socket_bridge(
transport,
adapter,
breakpoints,
cmd_tx,
out_rx,
shutdown,
source_map_wiring,
);
})
};
let encoder_handle = {
let adapter = Arc::clone(&adapter);
std::thread::spawn(move || {
wiring::run_event_encoder(adapter, event_rx, out_tx);
})
};
Ok(Some(DebugHandle {
config: cfg.clone(),
local_addr,
shutdown,
socket_handle: Some(socket_handle),
encoder_handle: Some(encoder_handle),
terminate_tx,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_by_default_no_inputs() {
let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
assert!(!cfg.enabled, "default must be disabled");
assert!(cfg.listen.is_none(), "disabled => no listen address (R5.5)");
}
#[test]
fn disabled_when_file_enabled_false() {
let file = DebugFileConfig {
enabled: false,
port: 9276,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
assert!(!cfg.enabled);
assert!(cfg.listen.is_none());
}
#[test]
fn enabled_via_file_default_port() {
let file = DebugFileConfig {
enabled: true,
port: 9276,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
assert!(cfg.enabled);
assert_eq!(
cfg.listen,
Some("127.0.0.1:9276".parse().unwrap()),
"enabled => listen 127.0.0.1:<port> (default 9276)"
);
}
#[test]
fn enabled_via_env_when_no_file() {
let cfg = DebugConfig::resolve(None, Some(true), None, None, None, None, None, None);
assert!(cfg.enabled);
assert_eq!(cfg.listen, Some("127.0.0.1:9276".parse().unwrap()));
}
#[test]
fn file_port_overrides_default() {
let file = DebugFileConfig {
enabled: true,
port: 5000,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
assert_eq!(cfg.listen, Some("127.0.0.1:5000".parse().unwrap()));
}
#[test]
fn env_port_overrides_file_port() {
let file = DebugFileConfig {
enabled: true,
port: 5000,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), None, Some(7000), None, None, None, None, None);
assert_eq!(
cfg.listen,
Some("127.0.0.1:7000".parse().unwrap()),
"PASTA_DEBUG_PORT overrides [debug] port"
);
}
#[test]
fn env_enabled_overrides_file_disabled() {
let file = DebugFileConfig {
enabled: false,
port: 9276,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), Some(true), None, None, None, None, None, None);
assert!(cfg.enabled, "PASTA_DEBUG truthy overrides [debug] enabled=false");
assert_eq!(cfg.listen, Some("127.0.0.1:9276".parse().unwrap()));
}
#[test]
fn env_disabled_overrides_file_enabled() {
let file = DebugFileConfig {
enabled: true,
port: 9276,
..Default::default()
};
let cfg = DebugConfig::resolve(Some(&file), Some(false), None, None, None, None, None, None);
assert!(!cfg.enabled, "explicit PASTA_DEBUG=false overrides [debug] enabled=true");
assert!(cfg.listen.is_none());
}
#[test]
fn env_port_only_without_enable_stays_disabled() {
let cfg = DebugConfig::resolve(None, None, Some(7000), None, None, None, None, None);
assert!(!cfg.enabled);
assert!(cfg.listen.is_none());
}
#[test]
fn parse_truthy_env_values() {
for v in ["1", "true", "TRUE", "yes", "on", " on "] {
assert_eq!(parse_env_bool(v), Some(true), "{v:?} should be truthy");
}
for v in ["0", "false", "no", "off", ""] {
assert_eq!(parse_env_bool(v), Some(false), "{v:?} should be falsy");
}
assert_eq!(parse_env_bool("garbage"), None);
}
#[tracing_test::traced_test]
#[test]
fn enable_disabled_returns_none_and_no_trace() {
let lua = mlua::Lua::new();
let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
let handle = enable(&lua, &cfg, None).expect("enable must not error when disabled");
assert!(handle.is_none(), "disabled enable() returns Ok(None) (R5.2)");
let debug_is_nil: bool = lua
.load("return debug == nil")
.eval()
.expect("eval should succeed");
assert!(debug_is_nil, "disabled gate must not expose std_debug");
assert!(
!logs_contain("debug backend listening"),
"disabled enable() must emit no listening info (3.1)"
);
assert!(
!logs_contain("debug transport bind failed"),
"disabled enable() must emit no bind-failure warn (3.1)"
);
}
#[test]
fn enable_enabled_returns_handle() {
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
let cfg = DebugConfig {
enabled: true,
listen: Some("127.0.0.1:0".parse().unwrap()),
..Default::default()
};
let handle = enable(&lua, &cfg, None).expect("enable must succeed when enabled");
let handle = handle.expect("enabled enable() returns Ok(Some(DebugHandle))");
assert_eq!(handle.config().listen, cfg.listen);
let addr = handle
.local_addr()
.expect("enabled handle must expose a bound addr (R3.1)");
assert_eq!(addr.ip().to_string(), "127.0.0.1");
assert_ne!(addr.port(), 0, "OS must assign a concrete port");
let jit_off: bool = lua
.load("return (jit.status() == false)")
.eval()
.expect("jit.status() must be callable on an ALL_SAFE VM");
assert!(jit_off, "enable must install the hook and apply engine-wide jit.off()");
drop(handle);
lua.remove_global_hook();
}
#[test]
fn unload_synchronously_frees_port_for_plain_rebind() {
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
let cfg = DebugConfig {
enabled: true,
listen: Some("127.0.0.1:0".parse().unwrap()),
..Default::default()
};
let handle = enable(&lua, &cfg, None)
.expect("enable must succeed when enabled")
.expect("enabled enable() returns Ok(Some(DebugHandle))");
let port = handle
.local_addr()
.expect("enabled handle must expose a bound addr (R3.1)")
.port();
assert_ne!(port, 0, "OS must assign a concrete port");
drop(handle);
let rebind = std::net::TcpListener::bind(("127.0.0.1", port));
assert!(
rebind.is_ok(),
"plain rebind of port {port} must succeed after synchronous unload \
(got {:?}); a failure proves the listener was still open (detached \
teardown / AddrInUse 10048)",
rebind.as_ref().err()
);
drop(rebind);
lua.remove_global_hook();
}
#[test]
fn enable_bind_failure_surfaces_debug_error_bind() {
let blocker =
std::net::TcpListener::bind("127.0.0.1:0").expect("test listener must bind");
let taken = blocker.local_addr().expect("bound addr");
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
let cfg = DebugConfig {
enabled: true,
listen: Some(taken),
..Default::default()
};
let err = enable(&lua, &cfg, None).expect_err("bind to an occupied port must fail");
assert!(
matches!(err, DebugError::Bind(_)),
"expected DebugError::Bind, got: {err:?}"
);
assert!(
format!("{err}").to_lowercase().contains("bind"),
"Bind display names the failure: {err}"
);
lua.remove_global_hook();
drop(blocker);
}
#[tracing_test::traced_test]
#[test]
fn enable_enabled_emits_listening_info() {
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
let cfg = DebugConfig {
enabled: true,
listen: Some("127.0.0.1:0".parse().unwrap()),
..Default::default()
};
let handle = enable(&lua, &cfg, None)
.expect("enable must succeed when enabled")
.expect("enabled enable() returns Some(handle)");
assert!(
logs_contain("debug backend listening"),
"enable() must emit the listening info (1.1/1.3)"
);
let port = handle.local_addr().expect("bound addr").port();
assert!(
logs_contain(&format!("addr=127.0.0.1:{port}")),
"listening info must carry the real bound addr (1.4/1.5)"
);
drop(handle);
lua.remove_global_hook();
}
#[tracing_test::traced_test]
#[test]
fn enable_bind_failure_emits_warn_and_no_info() {
let blocker =
std::net::TcpListener::bind("127.0.0.1:0").expect("test listener must bind");
let taken = blocker.local_addr().expect("bound addr");
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
let cfg = DebugConfig {
enabled: true,
listen: Some(taken),
..Default::default()
};
let err = enable(&lua, &cfg, None).expect_err("bind to an occupied port must fail");
assert!(matches!(err, DebugError::Bind(_)), "expected Bind, got {err:?}");
assert!(
logs_contain("debug transport bind failed"),
"bind failure must emit a warn (2.1)"
);
assert!(
!logs_contain("debug backend listening"),
"no listening info must be emitted when the bind fails (2.2)"
);
lua.remove_global_hook();
drop(blocker);
}
#[test]
fn shared_source_mode_get_set_round_trip() {
let cell = SharedSourceMode::new(SourceMode::Pasta);
assert_eq!(cell.get(), SourceMode::Pasta);
let reader = cell.clone();
cell.set(SourceMode::Lua);
assert_eq!(reader.get(), SourceMode::Lua, "clone observes the write");
cell.set(SourceMode::Pasta);
assert_eq!(reader.get(), SourceMode::Pasta);
}
#[test]
fn source_mode_u8_codec_round_trips_and_defends_unknown() {
assert_eq!(SourceMode::from_u8(SourceMode::Pasta.as_u8()), SourceMode::Pasta);
assert_eq!(SourceMode::from_u8(SourceMode::Lua.as_u8()), SourceMode::Lua);
assert_eq!(SourceMode::from_u8(42), SourceMode::Pasta);
assert_eq!(SourceMode::from_u8(u8::MAX), SourceMode::Pasta);
}
#[test]
fn from_file_invalid_present_as_falls_back_to_pasta() {
let file = DebugFileConfig {
present_as: Some("garbage".to_string()),
..Default::default()
};
let cfg = DebugConfig::from_file(Some(&file));
assert_eq!(
cfg.source_mode,
SourceMode::Pasta,
"invalid present_as tolerated → default .pasta"
);
}
#[test]
fn file_config_defaults() {
let parsed: DebugFileConfig = toml::from_str("").unwrap();
assert!(!parsed.enabled, "default enabled=false");
assert_eq!(parsed.port, 9276, "default port=9276");
}
#[test]
fn file_config_parses_section() {
let parsed: DebugFileConfig =
toml::from_str("enabled = true\nport = 1234").unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.port, 1234);
}
#[test]
fn debug_error_variants_display() {
let bind = DebugError::Bind(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
"in use",
));
assert!(format!("{bind}").to_lowercase().contains("bind"));
let proto = DebugError::Protocol("bad frame".into());
assert!(format!("{proto}").contains("bad frame"));
let vm = DebugError::Vm("lua boom".into());
assert!(format!("{vm}").contains("lua boom"));
let disc = DebugError::Disconnected;
assert!(!format!("{disc}").is_empty());
}
#[test]
fn source_mode_default_is_pasta() {
assert_eq!(SourceMode::default(), SourceMode::Pasta);
}
#[test]
fn source_mode_parse_case_insensitive() {
assert_eq!(SourceMode::parse("pasta"), SourceMode::Pasta);
assert_eq!(SourceMode::parse("lua"), SourceMode::Lua);
assert_eq!(SourceMode::parse("PASTA"), SourceMode::Pasta);
assert_eq!(SourceMode::parse("Lua"), SourceMode::Lua);
assert_eq!(SourceMode::parse(" pasta "), SourceMode::Pasta);
}
#[test]
fn source_mode_parse_invalid_falls_back_to_pasta() {
assert_eq!(SourceMode::parse("garbage"), SourceMode::Pasta);
assert_eq!(SourceMode::parse(""), SourceMode::Pasta);
}
#[test]
fn default_source_mode_is_pasta_and_sidecar_false() {
let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
assert_eq!(cfg.source_mode, SourceMode::Pasta, "6.1: default present mode is .pasta");
assert!(!cfg.source_map_sidecar, "3.2: sidecar disabled by default");
let d = DebugConfig::default();
assert_eq!(d.source_mode, SourceMode::Pasta);
assert!(!d.source_map_sidecar);
}
#[test]
fn source_mode_file_overrides_default() {
let cfg = DebugConfig::resolve(
None,
None,
None,
None, None, Some(SourceMode::Lua), None, None, );
assert_eq!(cfg.source_mode, SourceMode::Lua, "file overrides default");
}
#[test]
fn source_mode_env_overrides_file() {
let cfg = DebugConfig::resolve(
None,
None,
None,
Some(SourceMode::Lua), None, Some(SourceMode::Pasta), None, None, );
assert_eq!(cfg.source_mode, SourceMode::Lua, "env overrides file");
}
#[test]
fn source_mode_attach_overrides_env() {
let cfg = DebugConfig::resolve(
None,
None,
None,
Some(SourceMode::Pasta), None, Some(SourceMode::Pasta), None, Some(SourceMode::Lua), );
assert_eq!(cfg.source_mode, SourceMode::Lua, "attach overrides env");
}
#[test]
fn sidecar_file_overrides_default() {
let cfg = DebugConfig::resolve(None, None, None, None, None, None, Some(true), None);
assert!(cfg.source_map_sidecar, "file sidecar=true overrides default false");
}
#[test]
fn sidecar_env_overrides_file() {
let off = DebugConfig::resolve(None, None, None, None, Some(false), None, None, None)
.source_map_sidecar;
let file_on = DebugConfig::resolve(None, None, None, None, None, None, Some(true), None)
.source_map_sidecar;
let env_off_over_file_on =
DebugConfig::resolve(None, None, None, None, Some(false), None, Some(true), None)
.source_map_sidecar;
let env_on_over_file_off =
DebugConfig::resolve(None, None, None, None, Some(true), None, Some(false), None)
.source_map_sidecar;
assert!(!off);
assert!(file_on);
assert!(!env_off_over_file_on, "PASTA_DEBUG_SOURCE_MAP_SIDECAR=false overrides file true");
assert!(env_on_over_file_off, "PASTA_DEBUG_SOURCE_MAP_SIDECAR=true overrides file false");
}
}