#![allow(dead_code)]
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
use mlua::{Debug, Lua, VmState};
use crate::debug::SourceMode;
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::hook::LineHook;
use crate::debug::inspect::{capture_stack, capture_variables};
use crate::debug::source_map::{PastaPos, SourceMap};
use crate::debug::types::{
Scope, SessionCommand, SessionEvent, StopReason, ThreadId, ThreadInfo,
};
const MAIN_THREAD_ID: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StepKind {
Over,
In,
Out,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RunMode {
Running,
Stepping {
kind: StepKind,
thread: crate::debug::types::ThreadId,
base_depth: u32,
start_line: u32,
origin_pasta: Option<PastaPos>,
},
}
pub(crate) struct DebugSession {
breakpoints: BreakpointSet,
cmd_rx: Receiver<SessionCommand>,
event_tx: Sender<SessionEvent>,
mode: std::cell::RefCell<RunMode>,
source_map: Option<Arc<SourceMap>>,
source_mode: SourceMode,
shared_mode: Option<crate::debug::SharedSourceMode>,
}
impl DebugSession {
pub(crate) fn new(
breakpoints: BreakpointSet,
cmd_rx: Receiver<SessionCommand>,
event_tx: Sender<SessionEvent>,
) -> Self {
Self {
breakpoints,
cmd_rx,
event_tx,
mode: std::cell::RefCell::new(RunMode::Running),
source_map: None,
source_mode: SourceMode::default(),
shared_mode: None,
}
}
pub(crate) fn with_source_map(
mut self,
source_map: Option<Arc<SourceMap>>,
source_mode: SourceMode,
) -> Self {
self.source_map = source_map;
self.source_mode = source_mode;
self
}
pub(crate) fn with_shared_mode(
mut self,
shared_mode: Option<crate::debug::SharedSourceMode>,
) -> Self {
self.shared_mode = shared_mode;
self
}
fn effective_mode(&self) -> SourceMode {
match &self.shared_mode {
Some(shared) => shared.get(),
None => self.source_mode,
}
}
pub(crate) fn mode(&self) -> RunMode {
self.mode.borrow().clone()
}
pub(crate) fn source_map(&self) -> Option<&Arc<SourceMap>> {
self.source_map.as_ref()
}
pub(crate) fn source_mode(&self) -> SourceMode {
self.effective_mode()
}
fn source_and_line(debug: &Debug) -> (String, u32) {
let src = debug.source();
let source = src
.source
.as_ref()
.map(|c| c.as_ref().to_string())
.or_else(|| src.short_src.as_ref().map(|c| c.as_ref().to_string()))
.unwrap_or_default();
let line = debug.current_line().unwrap_or(0) as u32;
(source, line)
}
fn current_thread_and_depth(lua: &Lua) -> (ThreadId, u32) {
let thread = lua.current_thread();
let tid = ThreadId::from_state(thread.state());
let depth = capture_stack(lua, &thread).len() as u32;
(tid, depth)
}
fn resolve_current_pasta(&self, source: &str, line: u32) -> Option<PastaPos> {
if self.effective_mode() != SourceMode::Pasta {
return None;
}
let map = self.source_map.as_ref()?;
map.resolve_lua_to_pasta(source, line).cloned()
}
fn step_should_stop(
kind: StepKind,
thread: ThreadId,
base_depth: u32,
start_line: u32,
cur_thread: ThreadId,
depth: u32,
line: u32,
) -> bool {
if cur_thread != thread {
return false;
}
match kind {
StepKind::Over => {
depth < base_depth || (depth == base_depth && line != start_line)
}
StepKind::In => depth > base_depth || line != start_line,
StepKind::Out => depth < base_depth,
}
}
fn pasta_step_should_stop(
thread: ThreadId,
base_depth: u32,
origin_pasta: Option<&PastaPos>,
cur_thread: ThreadId,
depth: u32,
cur_pasta: Option<&PastaPos>,
) -> bool {
let Some(cur) = cur_pasta else {
return false;
};
let same_frame = cur_thread == thread && depth == base_depth;
if same_frame && origin_pasta == Some(cur) {
return false;
}
true
}
fn stop_loop(
&self,
lua: &Lua,
debug: &Debug,
reason: StopReason,
thread_id: u32,
) -> mlua::Result<VmState> {
let _ = self.event_tx.send(SessionEvent::Stopped { reason, thread_id });
loop {
match self.cmd_rx.recv() {
Ok(SessionCommand::Continue) => {
*self.mode.borrow_mut() = RunMode::Running;
return Ok(VmState::Continue);
}
Ok(cmd @ (SessionCommand::Next | SessionCommand::StepIn | SessionCommand::StepOut)) => {
let kind = match cmd {
SessionCommand::Next => StepKind::Over,
SessionCommand::StepIn => StepKind::In,
_ => StepKind::Out,
};
let (thread, base_depth) = Self::current_thread_and_depth(lua);
let (source, start_line) = Self::source_and_line(debug);
let origin_pasta = self.resolve_current_pasta(&source, start_line);
*self.mode.borrow_mut() = RunMode::Stepping {
kind,
thread,
base_depth,
start_line,
origin_pasta,
};
return Ok(VmState::Continue);
}
Ok(SessionCommand::Disconnect) => {
let _ = self.event_tx.send(SessionEvent::Terminated);
return Ok(VmState::Continue);
}
Ok(SessionCommand::SetBreakpoints { source, lines }) => {
let _ = self.breakpoints.set_breakpoints(&source, &lines);
continue;
}
Ok(SessionCommand::StackTrace) => {
let frames = capture_stack(lua, &lua.current_thread());
let _ = self.event_tx.send(SessionEvent::Stack(frames));
continue;
}
Ok(SessionCommand::Variables { var_ref }) => {
let frame_level = var_ref.saturating_sub(1);
let vars = capture_variables(lua, &lua.current_thread(), frame_level);
let _ = self.event_tx.send(SessionEvent::Variables(vars));
continue;
}
Ok(SessionCommand::Scopes { frame_id }) => {
let scopes = vec![Scope {
name: "Locals".to_string(),
variables_reference: frame_id + 1,
}];
let _ = self.event_tx.send(SessionEvent::Scopes(scopes));
continue;
}
Ok(SessionCommand::Threads) => {
let threads = vec![ThreadInfo {
id: MAIN_THREAD_ID,
name: "main".to_string(),
}];
let _ = self.event_tx.send(SessionEvent::Threads(threads));
continue;
}
Err(_) => return Ok(VmState::Continue),
}
}
}
fn on_line_impl(&self, lua: &Lua, debug: &Debug) -> mlua::Result<VmState> {
let (source, line) = Self::source_and_line(debug);
if self.breakpoints.should_pause(&source, line) {
return self.stop_loop(lua, debug, StopReason::Breakpoint, MAIN_THREAD_ID);
}
let current_mode = self.mode.borrow().clone();
if let RunMode::Stepping {
kind,
thread,
base_depth,
start_line,
origin_pasta,
} = current_mode
{
let (cur_thread, depth) = Self::current_thread_and_depth(lua);
if Self::step_should_stop(
kind, thread, base_depth, start_line, cur_thread, depth, line,
) {
let take_stop = if self.effective_mode() == SourceMode::Pasta
&& self.source_map.is_some()
{
let cur_pasta = self.resolve_current_pasta(&source, line);
Self::pasta_step_should_stop(
thread,
base_depth,
origin_pasta.as_ref(),
cur_thread,
depth,
cur_pasta.as_ref(),
)
} else {
true
};
if take_stop {
return self.stop_loop(lua, debug, StopReason::Step, MAIN_THREAD_ID);
}
}
}
Ok(VmState::Continue)
}
fn report_error(&self, err: &mlua::Error) {
let _ = self.event_tx.send(SessionEvent::Error(err.to_string()));
}
}
impl LineHook for DebugSession {
fn on_line(&self, lua: &Lua, debug: &Debug) -> mlua::Result<VmState> {
self.on_line_impl(lua, debug)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use mlua::{Lua, LuaOptions, StdLib};
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::types::{SourceRef, StopReason};
#[test]
fn with_source_map_pasta_threads_map_into_session() {
let (_cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, _event_rx) = mpsc::channel::<SessionEvent>();
let map = Arc::new(SourceMap::new());
let session = DebugSession::new(BreakpointSet::new(), cmd_rx, event_tx)
.with_source_map(Some(Arc::clone(&map)), SourceMode::Pasta);
assert!(
session.source_map().is_some(),
"Some(map) in Pasta mode must REACH the session (design 548)"
);
assert_eq!(session.source_mode(), SourceMode::Pasta);
assert!(Arc::ptr_eq(session.source_map().unwrap(), &map));
}
#[test]
fn none_map_leaves_session_default_lua_behavior() {
let (_cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, _event_rx) = mpsc::channel::<SessionEvent>();
let default_session =
DebugSession::new(BreakpointSet::new(), cmd_rx, event_tx);
assert!(
default_session.source_map().is_none(),
"default session must hold NO map (default `.lua` behavior, 7.2)"
);
let (_c2, cmd_rx2) = mpsc::channel::<SessionCommand>();
let (ev2, _e2) = mpsc::channel::<SessionEvent>();
let lua_session = DebugSession::new(BreakpointSet::new(), cmd_rx2, ev2)
.with_source_map(None, SourceMode::Lua);
assert!(
lua_session.source_map().is_none(),
"None in `.lua` mode must leave the session map-less (6.2 / 7.2)"
);
assert_eq!(lua_session.source_mode(), SourceMode::Lua);
}
const WATCHDOG: Duration = Duration::from_secs(10);
const SCENARIO_SOURCE: &str = "@session_scenario";
const BREAKPOINT_LINE: u32 = 3;
fn build_all_safe_vm() -> Lua {
unsafe { Lua::unsafe_new_with(StdLib::ALL_SAFE, LuaOptions::default()) }
}
struct HostThread {
cmd_tx: Sender<SessionCommand>,
event_rx: Receiver<SessionEvent>,
progress: Arc<AtomicUsize>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl HostThread {
fn progress(&self) -> usize {
self.progress.load(Ordering::SeqCst)
}
}
fn start_session(breakpoints: BreakpointSet) -> HostThread {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let progress = Arc::new(AtomicUsize::new(0));
let hook_progress = Arc::clone(&progress);
let handle = std::thread::spawn(move || -> Result<(), String> {
run_host_thread(breakpoints, cmd_rx, event_tx, hook_progress)
.map_err(|e| e.to_string())
});
HostThread {
cmd_tx,
event_rx,
progress,
handle: Some(handle),
}
}
fn run_host_thread(
breakpoints: BreakpointSet,
cmd_rx: Receiver<SessionCommand>,
event_tx: Sender<SessionEvent>,
hook_progress: Arc<AtomicUsize>,
) -> mlua::Result<()> {
let lua = build_all_safe_vm();
let session = DebugSession::new(breakpoints, cmd_rx, event_tx);
let handler = move |lua: &Lua, debug: &Debug| {
hook_progress.fetch_add(1, Ordering::SeqCst);
session.on_line(lua, debug)
};
crate::debug::hook::install(&lua, handler)?;
let chunk = "\
local a = 1
local b = a + 1
local c = b + 1
local d = c + 1
local e = d + 1
return e
";
lua.load(chunk).set_name(SCENARIO_SOURCE).exec()?;
lua.remove_global_hook();
Ok(())
}
fn join_with_watchdog(
host: &mut HostThread,
timeout: Duration,
) -> Option<std::thread::Result<Result<(), String>>> {
let handle = host.handle.take().expect("join at most once");
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(handle.join());
});
done_rx.recv_timeout(timeout).ok()
}
#[test]
fn session_stops_at_breakpoint_and_resumes() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(SCENARIO_SOURCE), &[BREAKPOINT_LINE]);
let mut host = start_session(breakpoints);
let stopped = host
.event_rx
.recv_timeout(WATCHDOG)
.expect("must receive a Stopped event before the watchdog (R3.4)");
assert_eq!(
stopped,
SessionEvent::Stopped {
reason: StopReason::Breakpoint,
thread_id: MAIN_THREAD_ID,
},
"Stopped must report Breakpoint on the main thread id"
);
let at_stop = host.progress();
std::thread::sleep(Duration::from_millis(150));
let still = host.progress();
assert_eq!(
still, at_stop,
"progress must NOT advance while blocked at the breakpoint (R1.2): \
{at_stop} -> {still}"
);
host.cmd_tx
.send(SessionCommand::Continue)
.expect("sending Continue must succeed");
let joined =
join_with_watchdog(&mut host, WATCHDOG).expect("host thread must finish after Continue (R1.6)");
joined
.expect("host thread must not panic")
.expect("scenario must run to completion after Continue");
let after = host.progress();
assert!(
after > at_stop,
"progress must advance past the stop value after Continue (R1.6): \
at_stop={at_stop}, after={after}"
);
}
#[test]
fn progress_advances_when_no_breakpoint() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(SCENARIO_SOURCE), &[999]);
let mut host = start_session(breakpoints);
let joined = join_with_watchdog(&mut host, WATCHDOG)
.expect("host thread must finish without a breakpoint");
joined
.expect("host thread must not panic")
.expect("scenario must run to completion with no breakpoint");
assert!(
host.progress() >= BREAKPOINT_LINE as usize,
"progress must advance across executed lines when no breakpoint hits: got {}",
host.progress()
);
}
#[test]
fn disconnect_while_stopped_terminates_and_releases_vm() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(SCENARIO_SOURCE), &[BREAKPOINT_LINE]);
let mut host = start_session(breakpoints);
let stopped = host
.event_rx
.recv_timeout(WATCHDOG)
.expect("must reach the breakpoint and emit Stopped");
assert_eq!(
stopped,
SessionEvent::Stopped {
reason: StopReason::Breakpoint,
thread_id: MAIN_THREAD_ID,
}
);
host.cmd_tx
.send(SessionCommand::Disconnect)
.expect("sending Disconnect must succeed");
let terminated = host
.event_rx
.recv_timeout(WATCHDOG)
.expect("Disconnect must emit a Terminated event");
assert_eq!(
terminated,
SessionEvent::Terminated,
"Disconnect while stopped must emit Terminated"
);
let joined = join_with_watchdog(&mut host, WATCHDOG)
.expect("host thread must finish after Disconnect (VM released, no hang)");
joined
.expect("host thread must not panic")
.expect("scenario must run to completion after Disconnect");
}
#[test]
fn non_resuming_command_keeps_blocking_until_continue() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(SCENARIO_SOURCE), &[BREAKPOINT_LINE]);
let mut host = start_session(breakpoints);
host.event_rx
.recv_timeout(WATCHDOG)
.expect("must reach the breakpoint");
let at_stop = host.progress();
host.cmd_tx
.send(SessionCommand::StackTrace)
.expect("sending StackTrace must succeed");
std::thread::sleep(Duration::from_millis(150));
assert_eq!(
host.progress(),
at_stop,
"a non-resuming command (StackTrace) must NOT release the stop loop"
);
host.cmd_tx
.send(SessionCommand::Continue)
.expect("sending Continue must succeed");
let joined = join_with_watchdog(&mut host, WATCHDOG)
.expect("host thread must finish after Continue");
joined
.expect("host thread must not panic")
.expect("scenario must complete after Continue");
assert!(
host.progress() > at_stop,
"progress must advance after Continue following a non-resuming command"
);
}
#[test]
fn stop_core_is_unbounded_without_continue() {
const DEADLOCK_WATCHDOG: Duration = Duration::from_millis(500);
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(SCENARIO_SOURCE), &[BREAKPOINT_LINE]);
let mut host = start_session(breakpoints);
host.event_rx
.recv_timeout(WATCHDOG)
.expect("must reach the breakpoint and emit Stopped");
let handle = host.handle.take().expect("handle present");
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(handle.join());
});
let completed = done_rx.recv_timeout(DEADLOCK_WATCHDOG);
assert!(
matches!(completed, Err(RecvTimeoutError::Timeout)),
"a session with no resume command must NOT complete within the watchdog \
(stop core stays unbounded); got {completed:?}"
);
assert!(host.handle.is_none());
}
#[test]
fn report_error_stringifies_mlua_error() {
let (_cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let session = DebugSession::new(BreakpointSet::new(), cmd_rx, event_tx);
let err = mlua::Error::RuntimeError("boom".to_string());
session.report_error(&err);
match event_rx.recv().expect("an Error event must be sent") {
SessionEvent::Error(msg) => assert!(
msg.contains("boom"),
"the stringified error must carry the message: {msg:?}"
),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn channel_payloads_are_send() {
fn assert_send<T: Send>() {}
assert_send::<SessionCommand>();
assert_send::<SessionEvent>();
assert_send::<Arc<Mutex<Option<String>>>>();
}
struct StepHost {
cmd_tx: Sender<SessionCommand>,
event_rx: Receiver<SessionEvent>,
last_line: Arc<std::sync::atomic::AtomicU32>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl StepHost {
fn recv_stop(&self) -> (StopReason, u32) {
loop {
match self
.event_rx
.recv_timeout(WATCHDOG)
.expect("must receive a session event before the watchdog")
{
SessionEvent::Stopped { reason, .. } => {
return (reason, self.last_line.load(Ordering::SeqCst));
}
other => panic!("unexpected event while awaiting a stop: {other:?}"),
}
}
}
fn cont(&self, cmd: SessionCommand) {
self.cmd_tx.send(cmd).expect("command send must succeed");
}
fn join(&mut self) {
let handle = self.handle.take().expect("join at most once");
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(handle.join());
});
done_rx
.recv_timeout(WATCHDOG)
.expect("host thread must finish after Continue/Disconnect")
.expect("host thread must not panic")
.expect("scenario must run to completion");
}
}
fn start_step_host(
breakpoints: BreakpointSet,
chunk: &'static str,
source: &'static str,
) -> StepHost {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let last_line = Arc::new(std::sync::atomic::AtomicU32::new(0));
let hook_last_line = Arc::clone(&last_line);
let handle = std::thread::spawn(move || -> Result<(), String> {
let lua = build_all_safe_vm();
let session = DebugSession::new(breakpoints, cmd_rx, event_tx);
let handler = move |lua: &Lua, debug: &Debug| {
let line = debug.current_line().unwrap_or(0) as u32;
hook_last_line.store(line, Ordering::SeqCst);
session.on_line(lua, debug)
};
crate::debug::hook::install(&lua, handler).map_err(|e| e.to_string())?;
lua.load(chunk)
.set_name(source)
.exec()
.map_err(|e| e.to_string())?;
lua.remove_global_hook();
Ok(())
});
StepHost {
cmd_tx,
event_rx,
last_line,
handle: Some(handle),
}
}
const CALL_SOURCE: &str = "@step_call_scenario";
const CALL_CHUNK: &str = "\
local function helper(x)
local y = x + 1
return y
end
local a = 1
local b = helper(a)
local c = b + 1
return c
";
const CALL_BP_LINE: u32 = 6;
#[test]
fn step_over_skips_called_function() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(CALL_SOURCE), &[CALL_BP_LINE]);
let mut host = start_step_host(breakpoints, CALL_CHUNK, CALL_SOURCE);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Breakpoint);
assert_eq!(line, CALL_BP_LINE, "must stop at the breakpoint line first");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step over must stop with reason Step");
assert_eq!(
line, 7,
"step over must stop at the next line in the SAME frame (7), NOT inside helper"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn step_in_enters_called_function() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(CALL_SOURCE), &[CALL_BP_LINE]);
let mut host = start_step_host(breakpoints, CALL_CHUNK, CALL_SOURCE);
let (_reason, line) = host.recv_stop();
assert_eq!(line, CALL_BP_LINE);
host.cont(SessionCommand::StepIn);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step in must stop with reason Step");
assert_eq!(
line, 2,
"step in must stop at the callee's first body line (2), entering helper"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn step_out_returns_to_caller() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(CALL_SOURCE), &[CALL_BP_LINE]);
let mut host = start_step_host(breakpoints, CALL_CHUNK, CALL_SOURCE);
let (_reason, line) = host.recv_stop();
assert_eq!(line, CALL_BP_LINE);
host.cont(SessionCommand::StepIn);
let (_reason, line) = host.recv_stop();
assert_eq!(line, 2, "precondition: stepped into helper at line 2");
host.cont(SessionCommand::StepOut);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step out must stop with reason Step");
assert_eq!(
line, 7,
"step out must stop back in the caller after helper returns (line 7)"
);
host.cont(SessionCommand::Continue);
host.join();
}
const CO_CHUNK: &str = "\
local body = function()
local a = 1
coroutine.yield()
local b = a + 1
return b
end
local co = coroutine.create(body)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
";
const CO_SOURCE: &str = "@step_co_scenario";
const CO_BODY_BP_LINE: u32 = 2;
#[test]
fn step_over_survives_coroutine_yield_and_skips_other_threads() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(CO_SOURCE), &[CO_BODY_BP_LINE]);
let mut host = start_step_host(breakpoints, CO_CHUNK, CO_SOURCE);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Breakpoint);
assert_eq!(line, CO_BODY_BP_LINE, "must stop inside the coroutine body");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 3,
"first step over must reach the yield line (3) in the same frame"
);
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "stepping must stop with reason Step");
assert_eq!(
line, 4,
"a step over `coroutine.yield()` must complete at the post-yield body \
line (4) AFTER the resume — NOT on a driver-loop line (採択B survival)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn breakpoint_still_stops_while_stepping() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(CALL_SOURCE), &[CALL_BP_LINE, 2]);
let mut host = start_step_host(breakpoints, CALL_CHUNK, CALL_SOURCE);
let (_reason, line) = host.recv_stop();
assert_eq!(line, CALL_BP_LINE);
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(
line, 2,
"the breakpoint inside helper (line 2) must fire even while stepping over"
);
assert_eq!(
reason,
StopReason::Breakpoint,
"a breakpoint hit while Stepping must report reason Breakpoint, not Step"
);
host.cont(SessionCommand::Continue);
host.join();
}
use crate::debug::source_map::ChunkSourceMap;
use std::collections::BTreeMap;
fn ppos(line: u32) -> PastaPos {
PastaPos {
file: "scene.pasta".to_string(),
line,
}
}
#[test]
fn pasta_decision_same_pasta_line_same_frame_continues() {
let t = ThreadId(0xAB);
let origin = ppos(10);
let cur = ppos(10);
assert!(
!DebugSession::pasta_step_should_stop(
t, 3, Some(&origin), t, 3, Some(&cur)
),
"same `.pasta` line in the origin frame must be consumed (continue, 9.1)"
);
}
#[test]
fn pasta_decision_different_pasta_line_same_frame_stops() {
let t = ThreadId(0xAB);
let origin = ppos(10);
let cur = ppos(11);
assert!(
DebugSession::pasta_step_should_stop(
t, 3, Some(&origin), t, 3, Some(&cur)
),
"a DIFFERENT `.pasta` line in the same frame must STOP (9.1)"
);
}
#[test]
fn pasta_decision_unmapped_line_passes_through() {
let t = ThreadId(0xAB);
let origin = ppos(10);
assert!(
!DebugSession::pasta_step_should_stop(t, 3, Some(&origin), t, 3, None),
"an unmapped `.lua` line must be passed through (continue, 9.4)"
);
assert!(
!DebugSession::pasta_step_should_stop(t, 3, Some(&origin), t, 4, None),
"an unmapped line in a deeper frame must also be passed through (9.2/9.4)"
);
}
#[test]
fn pasta_decision_deeper_frame_mapped_line_stops_discarding_origin() {
let t = ThreadId(0xAB);
let origin = ppos(10);
let cur_diff = ppos(20);
assert!(
DebugSession::pasta_step_should_stop(
t, 3, Some(&origin), t, 4, Some(&cur_diff)
),
"a mapped line in the callee frame must STOP (step into, 9.2)"
);
let cur_same = ppos(10);
assert!(
DebugSession::pasta_step_should_stop(
t, 3, Some(&origin), t, 4, Some(&cur_same)
),
"a callee line equal to the origin `.pasta` line still STOPS (origin \
discarded across frames, 9.2)"
);
}
#[test]
fn pasta_decision_shallower_frame_mapped_line_stops() {
let t = ThreadId(0xAB);
let origin = ppos(20); let cur = ppos(12); assert!(
DebugSession::pasta_step_should_stop(
t, 4, Some(&origin), t, 3, Some(&cur)
),
"a mapped line in the caller frame after return must STOP (step out, 9.3)"
);
}
#[test]
fn pasta_decision_origin_none_mapped_line_stops() {
let t = ThreadId(0xAB);
let cur = ppos(11);
assert!(
DebugSession::pasta_step_should_stop(t, 3, None, t, 3, Some(&cur)),
"with no origin `.pasta` (unmapped start), the first mapped line STOPS"
);
}
const PASTA_SOURCE: &str = "@pasta_step_scenario";
const PASTA_CHUNK: &str = "\
local function helper(x)
local y = x + 1
local z = y + 1
return z
end
local a = 1
local b = a + 1
local c = b + 1
local d = helper(c)
return d
";
const PASTA_BP_LINE: u32 = 6;
fn pasta_scenario_map() -> Arc<SourceMap> {
let mut forward: BTreeMap<u32, PastaPos> = BTreeMap::new();
forward.insert(6, ppos(10));
forward.insert(7, ppos(10)); forward.insert(9, ppos(11));
forward.insert(10, ppos(12));
forward.insert(3, ppos(20));
forward.insert(4, ppos(21));
let mut sm = SourceMap::new();
sm.insert_chunk(
PASTA_SOURCE.to_string(),
"scene.pasta".to_string(),
ChunkSourceMap::from_forward(forward),
);
Arc::new(sm)
}
fn start_step_host_with_map(
breakpoints: BreakpointSet,
chunk: &'static str,
source: &'static str,
source_map: Option<Arc<SourceMap>>,
source_mode: SourceMode,
) -> StepHost {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let last_line = Arc::new(std::sync::atomic::AtomicU32::new(0));
let hook_last_line = Arc::clone(&last_line);
let handle = std::thread::spawn(move || -> Result<(), String> {
let lua = build_all_safe_vm();
let session = DebugSession::new(breakpoints, cmd_rx, event_tx)
.with_source_map(source_map, source_mode);
let handler = move |lua: &Lua, debug: &Debug| {
let line = debug.current_line().unwrap_or(0) as u32;
hook_last_line.store(line, Ordering::SeqCst);
session.on_line(lua, debug)
};
crate::debug::hook::install(&lua, handler).map_err(|e| e.to_string())?;
lua.load(chunk)
.set_name(source)
.exec()
.map_err(|e| e.to_string())?;
lua.remove_global_hook();
Ok(())
});
StepHost {
cmd_tx,
event_rx,
last_line,
handle: Some(handle),
}
}
fn start_step_host_with_shared_mode(
breakpoints: BreakpointSet,
chunk: &'static str,
source: &'static str,
source_map: Option<Arc<SourceMap>>,
initial_mode: SourceMode,
) -> (StepHost, crate::debug::SharedSourceMode) {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
let shared_mode = crate::debug::SharedSourceMode::new(initial_mode);
let session_shared = shared_mode.clone();
let last_line = Arc::new(std::sync::atomic::AtomicU32::new(0));
let hook_last_line = Arc::clone(&last_line);
let handle = std::thread::spawn(move || -> Result<(), String> {
let lua = build_all_safe_vm();
let session = DebugSession::new(breakpoints, cmd_rx, event_tx)
.with_source_map(source_map, initial_mode)
.with_shared_mode(Some(session_shared));
let handler = move |lua: &Lua, debug: &Debug| {
let line = debug.current_line().unwrap_or(0) as u32;
hook_last_line.store(line, Ordering::SeqCst);
session.on_line(lua, debug)
};
crate::debug::hook::install(&lua, handler).map_err(|e| e.to_string())?;
lua.load(chunk)
.set_name(source)
.exec()
.map_err(|e| e.to_string())?;
lua.remove_global_hook();
Ok(())
});
(
StepHost {
cmd_tx,
event_rx,
last_line,
handle: Some(handle),
},
shared_mode,
)
}
#[test]
fn attach_pasta_switches_session_to_pasta_step_granularity() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[PASTA_BP_LINE]);
let (mut host, shared_mode) = start_step_host_with_shared_mode(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Lua,
);
shared_mode.set(SourceMode::Pasta);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Breakpoint);
assert_eq!(line, PASTA_BP_LINE, "must stop at the breakpoint line (6)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 9,
"attach `pasta` must switch this session to `.pasta` step granularity: \
consume line 7 (same `.pasta` 10), pass line 8 (unmapped), stop at \
line 9 (`.pasta` 11) — NOT line 7 (5.5/6.3)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn attach_lua_switches_session_to_lua_step_granularity() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[PASTA_BP_LINE]);
let (mut host, shared_mode) = start_step_host_with_shared_mode(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
shared_mode.set(SourceMode::Lua);
let (_reason, line) = host.recv_stop();
assert_eq!(line, PASTA_BP_LINE, "must stop at the breakpoint line (6)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 7,
"attach `lua` must force `.lua` step granularity (stop at line 7), NOT \
consume to the next `.pasta` line at 9 (5.5/6.3 — attach > env/file)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn no_attach_keeps_resolved_pasta_step_granularity() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[PASTA_BP_LINE]);
let (mut host, _shared_mode) = start_step_host_with_shared_mode(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
let (_reason, line) = host.recv_stop();
assert_eq!(line, PASTA_BP_LINE, "must stop at the breakpoint line (6)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 9,
"no attach override → resolved Pasta `.pasta` granularity (stop at 9)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn pasta_step_over_consumes_same_pasta_line_and_passes_unmapped() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[PASTA_BP_LINE]);
let mut host = start_step_host_with_map(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Breakpoint);
assert_eq!(line, PASTA_BP_LINE, "must stop at the breakpoint line (6)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step over must stop with reason Step");
assert_eq!(
line, 9,
"step over from `.pasta` 10 must consume line 7 (same `.pasta` 10) and \
pass line 8 (unmapped), stopping at line 9 (`.pasta` 11) — the next \
DIFFERENT `.pasta` line (9.1/9.4)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn pasta_step_into_stops_at_first_mapped_callee_line() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[9]);
let mut host = start_step_host_with_map(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
let (_reason, line) = host.recv_stop();
assert_eq!(line, 9, "must stop at the call line (9)");
host.cont(SessionCommand::StepIn);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step into must stop with reason Step");
assert_eq!(
line, 3,
"step into must PASS the unmapped callee line 2 and stop at line 3 \
(`.pasta` 20) — the callee's first MAPPED `.pasta` line (9.2/9.4)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn pasta_step_out_stops_at_first_mapped_caller_line() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[9]);
let mut host = start_step_host_with_map(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
let (_reason, line) = host.recv_stop();
assert_eq!(line, 9, "must stop at the call line (9)");
host.cont(SessionCommand::StepIn);
let (_reason, line) = host.recv_stop();
assert_eq!(line, 3, "precondition: stepped into helper at line 3");
host.cont(SessionCommand::StepOut);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step, "step out must stop with reason Step");
assert_eq!(
line, 10,
"step out must return to the caller and stop at line 10 (`.pasta` 12) — \
the first MAPPED `.pasta` line after `helper` returns (9.3)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn pasta_step_over_does_not_enter_sub_call() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[9]);
let mut host = start_step_host_with_map(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Pasta,
);
let (_reason, line) = host.recv_stop();
assert_eq!(line, 9, "must stop at the call line (9)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 10,
"step over the call line (9) must stop at line 10 in the SAME frame \
(`.pasta` 12), NOT inside helper (E2 — sub-call not entered)"
);
host.cont(SessionCommand::Continue);
host.join();
}
#[test]
fn lua_mode_keeps_lua_granularity_even_with_map() {
let breakpoints = BreakpointSet::new();
breakpoints.set_breakpoints(&SourceRef::new(PASTA_SOURCE), &[PASTA_BP_LINE]);
let mut host = start_step_host_with_map(
breakpoints,
PASTA_CHUNK,
PASTA_SOURCE,
Some(pasta_scenario_map()),
SourceMode::Lua,
);
let (_reason, line) = host.recv_stop();
assert_eq!(line, PASTA_BP_LINE, "must stop at the breakpoint line (6)");
host.cont(SessionCommand::Next);
let (reason, line) = host.recv_stop();
assert_eq!(reason, StopReason::Step);
assert_eq!(
line, 7,
"`.lua` mode must step at `.lua` granularity (stop at line 7), NOT \
consume to the next `.pasta` line (9.5)"
);
host.cont(SessionCommand::Continue);
host.join();
}
}