use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::Mutex;
use std::time::Duration;
use serde_json::Value;
use crate::debug::{SharedSourceMode, SourceMode};
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::dap::{DapAdapter, pasta_source_resolver};
use crate::debug::source_map::SourceMap;
use crate::debug::transport::Transport;
use crate::debug::types::{
Breakpoint, ResolvedBreakpoint, SessionCommand, SessionEvent, SourceRef,
};
#[derive(Clone)]
pub(crate) struct SourceMapWiring {
pub(crate) source_map: Option<Arc<SourceMap>>,
pub(crate) source_mode: SharedSourceMode,
}
impl SourceMapWiring {
#[cfg(test)]
pub(crate) fn disabled() -> Self {
Self {
source_map: None,
source_mode: SharedSourceMode::new(SourceMode::default()),
}
}
pub(crate) fn pasta_active(&self) -> bool {
self.source_map.is_some() && self.source_mode.get() == SourceMode::Pasta
}
}
const POLL_INTERVAL: Duration = Duration::from_millis(5);
pub(crate) type SharedAdapter = Arc<Mutex<DapAdapter>>;
fn attach_pasta_resolver(adapter: &SharedAdapter, source_map: &SourceMapWiring) {
let resolver = if source_map.pasta_active() {
match &source_map.source_map {
Some(map) => pasta_source_resolver(Arc::clone(map)), None => return,
}
} else {
crate::debug::dap::default_source_resolver()
};
if let Ok(mut dap) = adapter.lock() {
dap.set_source_resolver(resolver);
}
}
pub(crate) fn run_socket_bridge(
transport: Transport,
adapter: SharedAdapter,
breakpoints: BreakpointSet,
cmd_tx: Sender<SessionCommand>,
out_rx: Receiver<Value>,
shutdown: Arc<AtomicBool>,
source_map: SourceMapWiring,
) {
attach_pasta_resolver(&adapter, &source_map);
loop {
if shutdown.load(Ordering::SeqCst) {
return;
}
match transport.inbound().recv_timeout(POLL_INTERVAL) {
Ok(req) => {
if !handle_inbound(&transport, &adapter, &breakpoints, &cmd_tx, &req, &source_map) {
return; }
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
drain_outbound(&transport, &out_rx);
return;
}
}
if !drain_outbound(&transport, &out_rx) {
return; }
}
}
fn handle_inbound(
transport: &Transport,
adapter: &SharedAdapter,
breakpoints: &BreakpointSet,
cmd_tx: &Sender<SessionCommand>,
req: &Value,
source_map: &SourceMapWiring,
) -> bool {
let command = req.get("command").and_then(Value::as_str).unwrap_or("");
let decoded = {
let mut dap = match adapter.lock() {
Ok(g) => g,
Err(_) => return false, };
dap.decode_request(req)
};
if command == "pasta/sourcePresentation" {
let request_seq = req.get("seq").and_then(Value::as_u64).unwrap_or(0);
if let Some(mode) = decoded.requested_source_mode {
source_map.source_mode.set(mode);
attach_pasta_resolver(adapter, source_map);
}
let current = source_map.source_mode.get();
let (response, event) = {
let mut dap = match adapter.lock() {
Ok(g) => g,
Err(_) => return false,
};
(
dap.source_presentation_response(request_seq, current),
dap.source_presentation_event(current),
)
};
if transport.send(response).is_err() {
return false;
}
if transport.send(event).is_err() {
return false;
}
if cmd_tx.send(SessionCommand::RefreshPresentation).is_err() {
return false;
}
return true;
}
if let Some(mode) = decoded.attach_source_mode {
source_map.source_mode.set(mode);
attach_pasta_resolver(adapter, source_map);
}
if let Some(response) = decoded.response
&& transport.send(response).is_err()
{
return false;
}
for ev in decoded.events {
if transport.send(ev).is_err() {
return false;
}
}
if command == "attach" {
let current = source_map.source_mode.get();
let event = {
let mut dap = match adapter.lock() {
Ok(g) => g,
Err(_) => return false,
};
dap.source_presentation_event(current)
};
if transport.send(event).is_err() {
return false;
}
}
match decoded.command {
Some(SessionCommand::SetBreakpoints { source, lines }) => {
let resolved = if source_map.pasta_active() && is_pasta_source(&source.path) {
translate_pasta_breakpoints(breakpoints, source_map, &source, &lines)
} else {
breakpoints.set_breakpoints(&source, &lines)
};
let frames = {
let mut dap = match adapter.lock() {
Ok(g) => g,
Err(_) => return false,
};
dap.encode_event(SessionEvent::Breakpoints(resolved))
};
for frame in frames {
if transport.send(frame).is_err() {
return false;
}
}
}
Some(cmd) => {
if cmd_tx.send(cmd).is_err() {
return false;
}
}
None => {}
}
true
}
fn is_pasta_source(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pasta"))
}
fn translate_pasta_breakpoints(
breakpoints: &BreakpointSet,
source_map: &SourceMapWiring,
source: &SourceRef,
lines: &[u32],
) -> Vec<ResolvedBreakpoint> {
let map = match &source_map.source_map {
Some(map) => map,
None => return breakpoints.set_breakpoints(source, lines),
};
let pasta_path = source.path.as_str();
let mut entries: Vec<Breakpoint> = Vec::new();
let resolved: Vec<ResolvedBreakpoint> = lines
.iter()
.map(|&line| {
let direct = map.resolve_pasta_to_lua(pasta_path, line);
if !direct.is_empty() {
for (chunk, lua_line) in direct {
entries.push(Breakpoint::new(pasta_path, chunk, lua_line));
}
return ResolvedBreakpoint {
source: source.clone(),
line,
verified: true,
};
}
if let Some(adjusted) = map.nearest_pasta_line_with_mapping(pasta_path, line) {
for (chunk, lua_line) in map.resolve_pasta_to_lua(pasta_path, adjusted) {
entries.push(Breakpoint::new(pasta_path, chunk, lua_line));
}
return ResolvedBreakpoint {
source: source.clone(),
line: adjusted,
verified: true,
};
}
ResolvedBreakpoint {
source: source.clone(),
line,
verified: false,
}
})
.collect();
breakpoints.register(pasta_path, entries);
resolved
}
fn drain_outbound(transport: &Transport, out_rx: &Receiver<Value>) -> bool {
loop {
match out_rx.try_recv() {
Ok(frame) => {
if transport.send(frame).is_err() {
return false;
}
}
Err(_) => return true,
}
}
}
pub(crate) fn run_event_encoder(
adapter: SharedAdapter,
event_rx: Receiver<SessionEvent>,
out_tx: Sender<Value>,
) {
while let Ok(event) = event_rx.recv() {
let frames = {
let mut dap = match adapter.lock() {
Ok(g) => g,
Err(_) => return,
};
dap.encode_event(event)
};
for frame in frames {
if out_tx.send(frame).is_err() {
return; }
}
}
}
#[cfg(test)]
mod tests {
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::transport::{read_frame, write_frame};
use crate::debug::{DebugConfig, enable};
const WATCHDOG: Duration = Duration::from_secs(15);
const SCENARIO_SOURCE: &str = "@e2e_scenario";
const SCENARIO_CHUNK: &str = "\
local function helper(x)
local y = x + 1
return y
end
local body = function()
local co_local = 7
local marker = co_local
local doubled = helper(marker)
coroutine.yield()
return doubled
end
local co = coroutine.create(body)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
";
const BREAKPOINT_LINE: u32 = 7;
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
#[test]
fn full_dap_session_over_tcp_attach_bp_stack_vars_step_continue_terminated() {
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
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)
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before running the VM".to_string())?;
lua.load(SCENARIO_CHUNK)
.set_name(SCENARIO_SOURCE)
.exec()
.map_err(|e| format!("scenario exec failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let init_resp = client.recv_until(|m| is_response(m, "initialize"));
assert_eq!(init_resp["success"], true, "initialize must succeed");
assert_eq!(init_resp["request_seq"], 1);
assert_eq!(
init_resp["body"]["supportsConfigurationDoneRequest"], true,
"initialize must advertise supportsConfigurationDoneRequest"
);
let _initialized = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": SCENARIO_SOURCE },
"breakpoints": [{ "line": BREAKPOINT_LINE }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
assert_eq!(bp_resp["request_seq"], 2, "setBreakpoints response correlates");
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 1);
assert_eq!(bps[0]["verified"], true);
assert_eq!(bps[0]["line"], BREAKPOINT_LINE);
client.send_request(3, "configurationDone", json!({}));
let cfg_resp = client.recv_until(|m| is_response(m, "configurationDone"));
assert_eq!(cfg_resp["success"], true);
assert_eq!(cfg_resp["request_seq"], 3);
go_tx.send(()).expect("send go signal");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"must stop with reason breakpoint at the coroutine-body BP"
);
let thread_id = stopped["body"]["threadId"].as_u64().expect("threadId");
client.send_request(9, "threads", json!({}));
let threads = client.recv_until(|m| is_response(m, "threads"));
assert_eq!(threads["request_seq"], 9);
let thread_arr = threads["body"]["threads"].as_array().expect("threads array");
assert!(!thread_arr.is_empty(), "threads must report at least one thread");
client.send_request(10, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
assert_eq!(stack["request_seq"], 10);
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have at least the stopped frame");
assert_eq!(
frames[0]["source"]["path"], SCENARIO_SOURCE,
"top frame source must be the scenario `.lua`"
);
assert_eq!(
frames[0]["line"], BREAKPOINT_LINE,
"top frame line must be the breakpoint line"
);
let frame_id = frames[0]["id"].as_u64().expect("frame id");
client.send_request(11, "scopes", json!({ "frameId": frame_id }));
let scopes = client.recv_until(|m| is_response(m, "scopes"));
assert_eq!(scopes["request_seq"], 11);
let scope_arr = scopes["body"]["scopes"].as_array().expect("scopes array");
assert_eq!(scope_arr.len(), 1, "exactly one scopes response (no double-answer)");
assert_eq!(scope_arr[0]["name"], "Locals");
let var_ref = scope_arr[0]["variablesReference"]
.as_u64()
.expect("variablesReference");
assert!(var_ref != 0, "variablesReference must be non-zero");
client.send_request(12, "variables", json!({ "variablesReference": var_ref }));
let vars = client.recv_until(|m| is_response(m, "variables"));
assert_eq!(vars["request_seq"], 12);
let var_arr = vars["body"]["variables"].as_array().expect("variables array");
let co_local = var_arr
.iter()
.find(|v| v["name"] == "co_local")
.unwrap_or_else(|| panic!("coroutine-body local `co_local` must be present: {var_arr:?}"));
assert_eq!(co_local["type"], "number");
assert_eq!(co_local["value"], "7", "co_local must read its live value 7");
client.send_request(20, "next", json!({ "threadId": thread_id }));
let next_ack = client.recv_until(|m| is_response(m, "next"));
assert_eq!(next_ack["request_seq"], 20);
let step_stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
step_stopped["body"]["reason"], "step",
"after `next` the VM must re-stop with reason step"
);
client.send_request(30, "continue", json!({ "threadId": thread_id }));
let cont_ack = client.recv_until(|m| is_response(m, "continue"));
assert_eq!(cont_ack["request_seq"], 30);
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion after continue");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
const FULL_SOURCE: &str = "@e2e_full_scenario";
const FULL_CHUNK: &str = "\
local function helper(x)
local hv = x + 1
return hv
end
local body = function()
local num = 7
local str = 'hi'
local flag = true
local tbl = { 1, 2, 3 }
local fn = helper
local nilv = nil
local marker = num
local doubled = helper(marker)
coroutine.yield()
return doubled
end
local co = coroutine.create(body)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
";
const FULL_BP_LINE: u32 = 12; const FULL_STEP_OVER_LINE: u32 = 13; const FULL_STEP_IN_LINE: u32 = 2; const FULL_STEP_OUT_LINE: u32 = 14;
#[test]
fn full_lua_debug_session_all_steps_all_var_types_coroutine_body() {
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
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)
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before running the VM".to_string())?;
lua.load(FULL_CHUNK)
.set_name(FULL_SOURCE)
.exec()
.map_err(|e| format!("scenario exec failed: {e}"))?;
let sane: i64 = lua
.load("return 1 + 2")
.eval()
.map_err(|e| format!("post-session VM eval failed: {e}"))?;
if sane != 3 {
return Err(format!("VM stack corrupted after session: 1+2 = {sane}"));
}
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let init_resp = client.recv_until(|m| is_response(m, "initialize"));
assert_eq!(init_resp["success"], true, "initialize must succeed");
assert_eq!(init_resp["request_seq"], 1);
assert_eq!(
init_resp["body"]["supportsConfigurationDoneRequest"], true,
"initialize must advertise supportsConfigurationDoneRequest"
);
let _initialized = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": FULL_SOURCE },
"breakpoints": [{ "line": FULL_BP_LINE }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
assert_eq!(bp_resp["request_seq"], 2);
let bps = bp_resp["body"]["breakpoints"].as_array().expect("bp array");
assert_eq!(bps.len(), 1);
assert_eq!(bps[0]["verified"], true, "the `.lua` BP must be verified (R1.1)");
assert_eq!(bps[0]["line"], FULL_BP_LINE);
client.send_request(3, "configurationDone", json!({}));
let cfg_resp = client.recv_until(|m| is_response(m, "configurationDone"));
assert_eq!(cfg_resp["success"], true);
assert_eq!(cfg_resp["request_seq"], 3);
go_tx.send(()).expect("send go signal");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"must stop with reason breakpoint at the coroutine-body BP (R1.2/R3.4)"
);
let thread_id = stopped["body"]["threadId"].as_u64().expect("threadId");
client.send_request(10, "threads", json!({}));
let threads = client.recv_until(|m| is_response(m, "threads"));
assert_eq!(threads["request_seq"], 10);
let thread_arr = threads["body"]["threads"].as_array().expect("threads array");
assert!(!thread_arr.is_empty(), "threads must report at least one thread");
client.send_request(11, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
assert_eq!(stack["request_seq"], 11);
let frames = stack["body"]["stackFrames"].as_array().expect("frames array");
assert!(!frames.is_empty(), "stack must have the stopped frame (R2.1)");
assert_eq!(
frames[0]["source"]["path"], FULL_SOURCE,
"top frame source must be the scenario `.lua` (R2.1)"
);
assert_eq!(
frames[0]["line"], FULL_BP_LINE,
"top frame line must be the breakpoint line (R2.1)"
);
let frame_id = frames[0]["id"].as_u64().expect("frame id");
client.send_request(12, "scopes", json!({ "frameId": frame_id }));
let scopes = client.recv_until(|m| is_response(m, "scopes"));
assert_eq!(scopes["request_seq"], 12);
let scope_arr = scopes["body"]["scopes"].as_array().expect("scopes array");
assert_eq!(scope_arr.len(), 1, "exactly one scopes response (no double-answer)");
assert_eq!(scope_arr[0]["name"], "Locals");
let var_ref = scope_arr[0]["variablesReference"]
.as_u64()
.expect("variablesReference");
assert_ne!(var_ref, 0, "variablesReference must be non-zero");
client.send_request(13, "variables", json!({ "variablesReference": var_ref }));
let vars = client.recv_until(|m| is_response(m, "variables"));
assert_eq!(vars["request_seq"], 13);
assert_eq!(vars["success"], true, "variables must not error (R2.5)");
let var_arr = vars["body"]["variables"].as_array().expect("variables array");
let find = |name: &str| -> Value {
var_arr
.iter()
.find(|v| v["name"] == name)
.unwrap_or_else(|| panic!("coroutine-body local `{name}` must be present: {var_arr:?}"))
.clone()
};
let num = find("num");
assert_eq!(num["type"], "number", "num must be discriminated as number (R2.3)");
assert_eq!(num["value"], "7", "num must read its live value 7 (R2.4)");
let s = find("str");
assert_eq!(s["type"], "string", "str must be discriminated as string (R2.3)");
assert_eq!(s["value"], "hi", "str must read its live value 'hi'");
let flag = find("flag");
assert_eq!(flag["type"], "boolean", "flag must be a boolean (R2.3)");
assert_eq!(flag["value"], "true", "flag must read its live value true");
let tbl = find("tbl");
assert_eq!(tbl["type"], "table", "tbl must be a table (R2.3)");
assert!(
tbl["value"].as_str().unwrap().starts_with("table:"),
"table value must be a readable placeholder: {:?}",
tbl["value"]
);
let fnval = find("fn");
assert_eq!(fnval["type"], "function", "unsupported kind type surfaced (R2.5)");
assert!(
fnval["value"].as_str().unwrap().starts_with("<unsupported"),
"an unsupported kind must carry an out-of-scope repr (R2.5): {:?}",
fnval["value"]
);
let nilv = find("nilv");
assert_eq!(nilv["type"], "nil", "nil kind surfaced gracefully (R2.5)");
assert!(
nilv["value"].as_str().unwrap().starts_with("<unsupported"),
"nil must carry an out-of-scope repr (R2.5): {:?}",
nilv["value"]
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let next_ack = client.recv_until(|m| is_response(m, "next"));
assert_eq!(next_ack["request_seq"], 20);
let over_stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
over_stopped["body"]["reason"], "step",
"step over must re-stop with reason step (R1.3)"
);
assert_eq!(
top_frame_line(&mut client, thread_id, 21),
FULL_STEP_OVER_LINE,
"step over must stop at the next line in the SAME frame (R1.3), not inside helper"
);
client.send_request(30, "stepIn", json!({ "threadId": thread_id }));
let in_ack = client.recv_until(|m| is_response(m, "stepIn"));
assert_eq!(in_ack["request_seq"], 30);
let in_stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(in_stopped["body"]["reason"], "step", "step in reason step (R1.4)");
assert_eq!(
top_frame_line(&mut client, thread_id, 31),
FULL_STEP_IN_LINE,
"step in must stop at the callee's first body line (R1.4)"
);
client.send_request(40, "stepOut", json!({ "threadId": thread_id }));
let out_ack = client.recv_until(|m| is_response(m, "stepOut"));
assert_eq!(out_ack["request_seq"], 40);
let out_stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(out_stopped["body"]["reason"], "step", "step out reason step (R1.5)");
assert_eq!(
top_frame_line(&mut client, thread_id, 41),
FULL_STEP_OUT_LINE,
"step out must stop back in the caller body past the call (R1.5)"
);
client.send_request(50, "continue", json!({ "threadId": thread_id }));
let cont_ack = client.recv_until(|m| is_response(m, "continue"));
assert_eq!(cont_ack["request_seq"], 50);
assert_eq!(cont_ack["body"]["allThreadsContinued"], true);
let terminated = client.recv_until(|m| is_event(m, "terminated"));
assert_eq!(terminated["event"], "terminated", "natural end emits terminated (R3.5)");
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion after continue (R1.6)");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
fn top_frame_line(client: &mut DapClient, thread_id: u64, seq: u64) -> u32 {
client.send_request(seq, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have the stopped frame");
frames[0]["line"].as_u64().expect("top frame line") as u32
}
}
#[cfg(test)]
mod source_map_wiring_tests {
use std::sync::Arc;
use crate::debug::{SharedSourceMode, SourceMode};
use crate::debug::source_map::SourceMap;
use super::SourceMapWiring;
#[test]
fn pasta_active_when_map_present_and_mode_pasta() {
let wiring = SourceMapWiring {
source_map: Some(Arc::new(SourceMap::new())),
source_mode: SharedSourceMode::new(SourceMode::Pasta),
};
assert!(
wiring.pasta_active(),
"Some(map) + Pasta must activate the `.pasta` consumers (6.1)"
);
}
#[test]
fn not_active_in_lua_mode_even_with_map() {
let wiring = SourceMapWiring {
source_map: Some(Arc::new(SourceMap::new())),
source_mode: SharedSourceMode::new(SourceMode::Lua),
};
assert!(
!wiring.pasta_active(),
"`.lua` mode must NOT activate `.pasta` consumers (6.2)"
);
}
#[test]
fn not_active_without_map() {
assert!(!SourceMapWiring::disabled().pasta_active());
let pasta_no_map = SourceMapWiring {
source_map: None,
source_mode: SharedSourceMode::new(SourceMode::Pasta),
};
assert!(
!pasta_no_map.pasta_active(),
"no map → default `.lua` behavior even in Pasta mode (7.2)"
);
}
}
#[cfg(test)]
mod resolver_attach_tests {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::debug::{SharedSourceMode, SourceMode};
use crate::debug::dap::DapAdapter;
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::types::{FrameInfo, SessionEvent};
use super::{SourceMapWiring, attach_pasta_resolver};
fn map_with(chunk: &str, lua_line: u32, file: &str, pasta_line: u32) -> SourceMap {
let mut forward = BTreeMap::new();
forward.insert(
lua_line,
PastaPos {
file: file.to_string(),
line: pasta_line,
},
);
let mut sm = SourceMap::new();
sm.insert_chunk(
chunk.to_string(),
file.to_string(),
ChunkSourceMap::from_forward(forward),
);
sm
}
fn top_frame(adapter: &Arc<Mutex<DapAdapter>>, source: &str, line: u32) -> (serde_json::Value, u32) {
let mut dap = adapter.lock().unwrap();
dap.decode_request(&json!({
"seq": 1, "type": "request", "command": "stackTrace",
"arguments": { "threadId": 1 },
}));
let out = dap.encode_event(SessionEvent::Stack(vec![FrameInfo {
source: source.to_string(),
line,
func_name: Some("f".to_string()),
}]));
let frame = &out[0]["body"]["stackFrames"][0];
(frame["source"].clone(), frame["line"].as_u64().unwrap() as u32)
}
#[test]
fn attaches_pasta_resolver_when_active() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = SourceMapWiring {
source_map: Some(Arc::new(map_with(
"C:/proj/cache/scene.lua",
7,
"C:/proj/scene.pasta",
3,
))),
source_mode: SharedSourceMode::new(SourceMode::Pasta),
};
attach_pasta_resolver(&adapter, &wiring);
let (source, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
source,
json!({ "path": "C:/proj/scene.pasta" }),
"active → 対応ありフレームは `.pasta` 提示 (R5.1/R5.2)"
);
assert_eq!(line, 3);
}
#[test]
fn attached_resolver_falls_back_to_lua_for_unmapped() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = SourceMapWiring {
source_map: Some(Arc::new(map_with(
"C:/proj/cache/scene.lua",
7,
"C:/proj/scene.pasta",
3,
))),
source_mode: SharedSourceMode::new(SourceMode::Pasta),
};
attach_pasta_resolver(&adapter, &wiring);
let (source, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 2);
assert_eq!(source, json!({ "path": r"@C:\proj\cache\scene.lua" }));
assert_eq!(line, 2);
}
#[test]
fn does_not_attach_in_lua_mode() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = SourceMapWiring {
source_map: Some(Arc::new(map_with(
"C:/proj/cache/scene.lua",
7,
"C:/proj/scene.pasta",
3,
))),
source_mode: SharedSourceMode::new(SourceMode::Lua),
};
attach_pasta_resolver(&adapter, &wiring);
let (source, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
source,
json!({ "path": r"@C:\proj\cache\scene.lua" }),
"R6.2: Lua モードは既定 `.lua` resolver のまま(非装着)"
);
assert_eq!(line, 7);
}
#[test]
fn does_not_attach_without_map() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
attach_pasta_resolver(&adapter, &SourceMapWiring::disabled());
let (source, line) = top_frame(&adapter, "@scene.lua", 7);
assert_eq!(source, json!({ "path": "@scene.lua" }), "no map → 既定 `.lua`");
assert_eq!(line, 7);
}
}
#[cfg(test)]
mod attach_source_presentation_tests {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::debug::dap::DapAdapter;
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::types::{FrameInfo, SessionEvent};
use crate::debug::{SharedSourceMode, SourceMode};
use super::{SourceMapWiring, attach_pasta_resolver};
fn map_with(chunk: &str, lua_line: u32, file: &str, pasta_line: u32) -> SourceMap {
let mut forward = BTreeMap::new();
forward.insert(
lua_line,
PastaPos {
file: file.to_string(),
line: pasta_line,
},
);
let mut sm = SourceMap::new();
sm.insert_chunk(
chunk.to_string(),
file.to_string(),
ChunkSourceMap::from_forward(forward),
);
sm
}
fn top_frame(adapter: &Arc<Mutex<DapAdapter>>, source: &str, line: u32) -> (serde_json::Value, u32) {
let mut dap = adapter.lock().unwrap();
dap.decode_request(&json!({
"seq": 1, "type": "request", "command": "stackTrace",
"arguments": { "threadId": 1 },
}));
let out = dap.encode_event(SessionEvent::Stack(vec![FrameInfo {
source: source.to_string(),
line,
func_name: Some("f".to_string()),
}]));
let frame = &out[0]["body"]["stackFrames"][0];
(frame["source"].clone(), frame["line"].as_u64().unwrap() as u32)
}
fn wiring_with(map: SourceMap, start: SourceMode) -> SourceMapWiring {
SourceMapWiring {
source_map: Some(Arc::new(map)),
source_mode: SharedSourceMode::new(start),
}
}
fn apply_attach(adapter: &Arc<Mutex<DapAdapter>>, wiring: &SourceMapWiring, mode: SourceMode) {
wiring.source_mode.set(mode);
attach_pasta_resolver(adapter, wiring);
}
#[test]
fn attach_lua_forces_lua_resolver_over_pasta_default() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
attach_pasta_resolver(&adapter, &wiring);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(src, json!({ "path": "C:/proj/scene.pasta" }));
assert_eq!(line, 3);
apply_attach(&adapter, &wiring, SourceMode::Lua);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": r"@C:\proj\cache\scene.lua" }),
"attach `lua` must force `.lua` presentation over the Pasta default (R6.3)"
);
assert_eq!(line, 7);
assert_eq!(wiring.source_mode.get(), SourceMode::Lua);
assert!(!wiring.pasta_active(), "Lua effective mode → consumers inactive");
}
#[test]
fn attach_pasta_forces_pasta_resolver_over_lua_default() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Lua,
);
attach_pasta_resolver(&adapter, &wiring);
let (src, _line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(src, json!({ "path": r"@C:\proj\cache\scene.lua" }));
apply_attach(&adapter, &wiring, SourceMode::Pasta);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": "C:/proj/scene.pasta" }),
"attach `pasta` must force `.pasta` presentation over the Lua default (R6.3)"
);
assert_eq!(line, 3);
assert!(wiring.pasta_active(), "Pasta effective mode + map → consumers active");
}
#[test]
fn no_attach_arg_keeps_resolved_mode() {
let adapter = Arc::new(Mutex::new(DapAdapter::new()));
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
attach_pasta_resolver(&adapter, &wiring);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": "C:/proj/scene.pasta" }),
"absent attach arg keeps the resolved Pasta mode (design 581)"
);
assert_eq!(line, 3);
assert_eq!(wiring.source_mode.get(), SourceMode::Pasta);
}
}
#[cfg(test)]
mod bp_translator_tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::debug::{SharedSourceMode, SourceMode};
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::types::SourceRef;
use super::{SourceMapWiring, is_pasta_source, translate_pasta_breakpoints};
#[test]
fn is_pasta_source_detects_pasta_extension() {
assert!(is_pasta_source("C:/proj/scene.pasta"));
assert!(is_pasta_source(r"C:\proj\scene.PASTA"), "拡張子は大小無視");
assert!(is_pasta_source("scene.pasta"));
assert!(!is_pasta_source("@e2e_scenario"));
assert!(!is_pasta_source("C:/proj/cache/scene.lua"));
assert!(!is_pasta_source(r"@C:\proj\cache\scene.lua"));
}
fn map_from(file: &str, entries: &[(&str, u32, u32)]) -> SourceMap {
let mut per_chunk: BTreeMap<String, BTreeMap<u32, PastaPos>> = BTreeMap::new();
for &(chunk, lua_line, pasta_line) in entries {
per_chunk.entry(chunk.to_string()).or_default().insert(
lua_line,
PastaPos {
file: file.to_string(),
line: pasta_line,
},
);
}
let mut sm = SourceMap::new();
for (chunk, forward) in per_chunk {
sm.insert_chunk(chunk, file.to_string(), ChunkSourceMap::from_forward(forward));
}
sm
}
fn pasta_wiring(map: SourceMap) -> SourceMapWiring {
SourceMapWiring {
source_map: Some(Arc::new(map)),
source_mode: SharedSourceMode::new(SourceMode::Pasta),
}
}
#[test]
fn pasta_line_registers_all_lua_lines_and_fires_should_pause() {
let file = "C:/proj/scene.pasta";
let map = map_from(
file,
&[
("C:/proj/cache/scene.lua", 12, 7),
("C:/proj/cache/scene.lua", 13, 7),
],
);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved =
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[7]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].verified, "対応ありは verified (4.1)");
assert_eq!(resolved[0].line, 7, "verified 行は元の `.pasta` 行");
assert_eq!(resolved[0].source, SourceRef::new(file));
assert!(
set.should_pause(r"@C:\proj\cache\scene.lua", 12),
"`.pasta` 行 7 の `.lua` 行 12 が生フック座標で発火する (4.2/8.2)"
);
assert!(
set.should_pause(r"@C:\proj\cache\scene.lua", 13),
"`.pasta` 行 7 の `.lua` 行 13 が生フック座標で発火する (4.2/8.2)"
);
assert!(!set.should_pause(r"@C:\proj\cache\scene.lua", 11));
}
#[test]
fn unmapped_pasta_line_adjusts_to_nearest_subsequent() {
let file = "C:/proj/scene.pasta";
let map = map_from(
file,
&[
("C:/proj/cache/scene.lua", 10, 3),
("C:/proj/cache/scene.lua", 20, 7),
],
);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved =
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[4]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].verified, "調整後は verified (4.3)");
assert_eq!(
resolved[0].line, 7,
"対応なし行 4 は後続最近接の対応行 7 へ調整される (4.3)"
);
assert!(
set.should_pause(r"@C:\proj\cache\scene.lua", 20),
"調整後の `.pasta` 行 7 の `.lua` 座標で停止する (4.3)"
);
assert!(!set.should_pause(r"@C:\proj\cache\scene.lua", 10));
}
#[test]
fn no_subsequent_mapping_returns_unverified() {
let file = "C:/proj/scene.pasta";
let map = map_from(file, &[("C:/proj/cache/scene.lua", 10, 3)]);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved =
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[5]);
assert_eq!(resolved.len(), 1);
assert!(
!resolved[0].verified,
"後続最近接が無い場合は unverified(誤マッピング禁止・4.3)"
);
assert_eq!(resolved[0].line, 5, "unverified は元の行を保持");
assert!(!set.should_pause(r"@C:\proj\cache\scene.lua", 10));
}
#[test]
fn multiple_pasta_lines_all_register_without_mutual_eviction() {
let file = "C:/proj/scene.pasta";
let map = map_from(
file,
&[
("C:/proj/cache/scene.lua", 10, 3),
("C:/proj/cache/scene.lua", 20, 7),
("C:/proj/cache/scene.lua", 21, 7),
],
);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved =
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[3, 7]);
assert_eq!(resolved.len(), 2);
assert!(resolved.iter().all(|r| r.verified));
assert_eq!(resolved[0].line, 3);
assert_eq!(resolved[1].line, 7);
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 10), "行 3 の座標");
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 20), "行 7 の座標 1");
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 21), "行 7 の座標 2");
}
#[test]
fn pasta_line_spanning_multiple_chunks_registers_all() {
let file = "C:/proj/scene.pasta";
let map = map_from(
file,
&[
("C:/proj/cache/a.lua", 12, 7),
("C:/proj/cache/b.lua", 5, 7),
],
);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved =
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[7]);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].verified);
assert!(set.should_pause(r"@C:\proj\cache\a.lua", 12), "chunk a の座標");
assert!(set.should_pause(r"@C:\proj\cache\b.lua", 5), "chunk b の座標");
}
#[test]
fn re_setting_pasta_source_replaces_only_its_own_coords() {
let file = "C:/proj/scene.pasta";
let map = map_from(
file,
&[
("C:/proj/cache/scene.lua", 10, 3),
("C:/proj/cache/scene.lua", 20, 7),
],
);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[3]);
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 10));
translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[7]);
assert!(
!set.should_pause(r"@C:\proj\cache\scene.lua", 10),
"同一 present source の旧座標は権威的に置換される"
);
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 20), "新座標が登録される");
}
#[test]
fn re_setting_pasta_source_with_empty_lines_clears_its_coords() {
let file = "C:/proj/scene.pasta";
let map = map_from(file, &[("C:/proj/cache/scene.lua", 10, 3)]);
let wiring = pasta_wiring(map);
let set = BreakpointSet::new();
let resolved = translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[3]);
assert_eq!(resolved.len(), 1);
assert!(set.should_pause(r"@C:\proj\cache\scene.lua", 10));
let cleared = translate_pasta_breakpoints(&set, &wiring, &SourceRef::new(file), &[]);
assert!(cleared.is_empty(), "空要求の resolved は空集合");
assert!(
!set.should_pause(r"@C:\proj\cache\scene.lua", 10),
"空再設定は当該 present source の全座標を権威的に除去する"
);
}
#[test]
fn translate_without_map_degrades_to_direct_lua_registration() {
let wiring = SourceMapWiring {
source_map: None,
source_mode: SharedSourceMode::new(SourceMode::Pasta),
};
let set = BreakpointSet::new();
let src = SourceRef::new("C:/proj/scene.pasta");
let resolved = translate_pasta_breakpoints(&set, &wiring, &src, &[4, 9]);
assert_eq!(resolved.len(), 2);
assert!(resolved.iter().all(|bp| bp.verified), "直接経路は全行 verified");
assert_eq!(resolved[0].line, 4);
assert_eq!(resolved[1].line, 9);
assert!(set.should_pause("C:/proj/scene.pasta", 4));
assert!(set.should_pause("C:/proj/scene.pasta", 9));
}
}
#[cfg(test)]
mod pasta_bp_e2e {
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::source_map::{MapBuilderSink, SourceMap};
use crate::debug::transport::{read_frame, write_frame};
use crate::debug::{DebugConfig, SourceMode, enable};
use crate::loader::CacheManager;
use crate::transpiler::LuaTranspiler;
const WATCHDOG: Duration = Duration::from_secs(15);
const PASTA_FIXTURE: &str = "\
#グローバル単語
@あいさつ:こんにちは、やあ
@べつ:A、B、C
@みっつ:x、y
";
const PASTA_SHIM: &str = "\
local word = {}
word.__index = word
function word:entry(...) self.entries = { ... } return self end
local PASTA = {}
function PASTA.create_word(name) return setmetatable({ name = name }, word) end
package.loaded['pasta'] = PASTA
package.loaded['pasta.global'] = {}
";
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
struct Fixture {
map: Arc<SourceMap>,
lua_source: String,
chunk_name: String,
pasta_path: String,
mapped_pasta_line: u32,
mapped_lua_line: u32,
unmapped_pasta_line: u32,
nearest_adjusted_line: u32,
}
fn build_fixture() -> Fixture {
let temp = tempfile::TempDir::new().expect("temp dir");
let base_dir = temp.path().to_path_buf();
let pasta_file = base_dir.join("dic/baseware/words.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).expect("mkdir dic");
std::fs::write(&pasta_file, PASTA_FIXTURE).expect("write .pasta");
let cache_manager = CacheManager::new(base_dir.clone(), "profile/pasta/cache/lua");
let map = crate::loader::PastaLoader::build_source_map(
std::slice::from_ref(&pasta_file),
&cache_manager,
false,
);
let chunk_name = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_path = pasta_file.to_string_lossy().to_string();
let content = std::fs::read_to_string(&pasta_file).expect("read .pasta");
let parsed = pasta_dsl::parse_str(&content, &pasta_path).expect("parse .pasta");
let transpiler = LuaTranspiler::default();
let mut sink = MapBuilderSink::new(pasta_path.clone(), chunk_name.clone());
let mut out = Vec::new();
transpiler
.transpile_with_source_map(&parsed, &mut out, Some(&mut sink))
.expect("transpile .pasta");
let lua_source = String::from_utf8(out).expect("utf8 .lua");
let mut mapped: Vec<(u32, u32)> = Vec::new(); for lua_line in 1u32..=200 {
if let Some(pos) = map.resolve_lua_to_pasta(&chunk_name, lua_line) {
mapped.push((pos.line, lua_line));
}
}
mapped.sort();
assert!(
mapped.len() >= 2,
"フィクスチャは少なくとも 2 つのマップ済み `.pasta` 行を持つこと: {mapped:?}"
);
let (mapped_pasta_line, mapped_lua_line) = mapped[1];
let min_mapped = mapped[0].0;
assert!(
min_mapped >= 2,
"最小マップ済み `.pasta` 行の手前にマップ無し行が必要(4.3 入力): min={min_mapped}"
);
let unmapped_pasta_line = min_mapped - 1; let nearest_adjusted_line = map
.nearest_pasta_line_with_mapping(&pasta_path, unmapped_pasta_line)
.expect("マップ無し行には後続最近接のマップ済み行が存在すること");
assert_eq!(
nearest_adjusted_line, min_mapped,
"未マップ行の後続最近接は最小マップ済み行(4.3)"
);
assert!(
map.resolve_pasta_to_lua(&pasta_path, unmapped_pasta_line)
.is_empty(),
"選んだ未マップ `.pasta` 行 {unmapped_pasta_line} は対応 `.lua` を持たないこと"
);
drop(temp);
Fixture {
map,
lua_source,
chunk_name,
pasta_path,
mapped_pasta_line,
mapped_lua_line,
unmapped_pasta_line,
nearest_adjusted_line,
}
}
#[test]
fn pasta_breakpoint_hits_presents_pasta_inspects_and_nearest_adjusts_over_tcp() {
let fx = build_fixture();
let lua_source = fx.lua_source.clone();
let chunk_name = fx.chunk_name.clone();
let map = Arc::clone(&fx.map);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
let lua = unsafe {
mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
};
lua.load(PASTA_SHIM)
.set_name("@pasta_shim")
.exec()
.map_err(|e| format!("shim exec failed: {e}"))?;
let cfg = DebugConfig {
enabled: true,
listen: Some("127.0.0.1:0".parse().unwrap()),
source_mode: SourceMode::Pasta, ..Default::default()
};
let handle = enable(&lua, &cfg, Some(map))
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before running the VM".to_string())?;
lua.load(&lua_source)
.set_name(format!("@{chunk_name}"))
.exec()
.map_err(|e| format!("scenario exec failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let init_resp = client.recv_until(|m| is_response(m, "initialize"));
assert_eq!(init_resp["success"], true, "initialize must succeed");
let _initialized = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": fx.pasta_path },
"breakpoints": [{ "line": fx.unmapped_pasta_line }],
}),
);
let adj_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let adj_bps = adj_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(adj_bps.len(), 1, "1 つの BP 応答");
assert_eq!(
adj_bps[0]["verified"], true,
"4.3: マップ無し行 BP は後続最近接へ調整され verified になる"
);
assert_eq!(
adj_bps[0]["line"].as_u64().expect("adjusted line") as u32,
fx.nearest_adjusted_line,
"4.3: 調整後の有効位置(後続最近接のマップ済み `.pasta` 行)が提示される"
);
client.send_request(
3,
"setBreakpoints",
json!({
"source": { "path": fx.pasta_path },
"breakpoints": [{ "line": fx.mapped_pasta_line }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 1);
assert_eq!(
bps[0]["verified"], true,
"4.1: マップ済み `.pasta` 行 BP は verified で登録される"
);
assert_eq!(
bps[0]["line"].as_u64().expect("bp line") as u32,
fx.mapped_pasta_line,
"4.1: マップ済み行は調整されず元の `.pasta` 行のまま"
);
client.send_request(4, "configurationDone", json!({}));
let cfg_resp = client.recv_until(|m| is_response(m, "configurationDone"));
assert_eq!(cfg_resp["success"], true);
go_tx.send(()).expect("send go signal");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"4.2: `.pasta` 行 BP に対応する `.lua` 行で停止する"
);
let thread_id = stopped["body"]["threadId"].as_u64().expect("threadId");
client.send_request(10, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "停止フレームが存在する(5.2)");
let top_src = frames[0]["source"]["path"]
.as_str()
.expect("top frame source path");
assert!(
top_src.ends_with(".pasta"),
"5.1/5.2: 停止フレームは `.pasta` を提示すること(`.lua` ではない)。actual={top_src:?}"
);
assert_eq!(
crate::debug::source_map::canonicalize_chunk_name(top_src),
crate::debug::source_map::canonicalize_chunk_name(&fx.pasta_path),
"5.1: 提示 `.pasta` パスは元 `.pasta` ファイルと一致する"
);
assert_eq!(
frames[0]["line"].as_u64().expect("top frame line") as u32,
fx.mapped_pasta_line,
"5.1: 提示行は **`.pasta` 行**({})。`.lua` 行({})であってはならない",
fx.mapped_pasta_line,
fx.mapped_lua_line
);
assert_ne!(
fx.mapped_pasta_line, fx.mapped_lua_line,
"フィクスチャは `.pasta` 行 ≠ `.lua` 行(提示差が観測可能)"
);
let frame_id = frames[0]["id"].as_u64().expect("frame id");
client.send_request(11, "scopes", json!({ "frameId": frame_id }));
let scopes = client.recv_until(|m| is_response(m, "scopes"));
assert_eq!(scopes["success"], true, "5.4: `.pasta` 提示中も scopes が成功する");
let scope_arr = scopes["body"]["scopes"]
.as_array()
.expect("scopes array");
assert!(
!scope_arr.is_empty(),
"5.4: 停止フレームの scope が利用可能(提示モードは inspect に影響しない)"
);
let var_ref = scope_arr[0]["variablesReference"]
.as_u64()
.expect("variablesReference");
client.send_request(12, "variables", json!({ "variablesReference": var_ref }));
let vars = client.recv_until(|m| is_response(m, "variables"));
assert_eq!(
vars["success"], true,
"5.4: `.pasta` 提示中でも variables 要求が成功する(inspect 継続)"
);
assert!(
vars["body"]["variables"].is_array(),
"5.4: variables 応答は配列を返す(inspect 経路が機能している)"
);
let mut terminated = false;
for continue_seq in 30u64..60u64 {
client.send_request(continue_seq, "continue", json!({ "threadId": thread_id }));
let next = client.recv_until(|m| {
is_event(m, "stopped") || is_event(m, "terminated")
});
if is_event(&next, "terminated") {
terminated = true;
break;
}
assert_eq!(
next["body"]["reason"], "breakpoint",
"再停止は同一 `.pasta` 行 BP のはず(多重呼び出し行の再入)"
);
}
assert!(
terminated,
"chunk 完走 → `terminated` が来るまで continue で流し切れること"
);
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion after continue");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
}
#[cfg(test)]
mod pasta_step_e2e {
use std::collections::BTreeMap;
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::transport::{read_frame, write_frame};
use crate::debug::{DebugConfig, SourceMode, enable};
const WATCHDOG: Duration = Duration::from_secs(15);
const STEP_SOURCE: &str = "@pasta_step_e2e_scenario";
const STEP_PASTA_FILE: &str = "scene_step.pasta";
const STEP_CHUNK: &str = "\
local function helper(x)
local hy = x + 1
local hz = hy + 1
return hz
end
local function recur(n)
if n > 0 then
return recur(n - 1)
end
return 0
end
local body = function()
local p = 1
coroutine.yield()
local q = p + 1
return q
end
local a = 1
local b = a + 1
local c = b + 1
local g = c + 1
local d = helper(c)
local e = recur(2)
local f = e + 1
local co = coroutine.create(body)
while coroutine.status(co) ~= 'dead' do
coroutine.resume(co)
end
return f
";
fn step_scenario_map() -> Arc<SourceMap> {
let pp = |line: u32| PastaPos {
file: STEP_PASTA_FILE.to_string(),
line,
};
let mut forward: BTreeMap<u32, PastaPos> = BTreeMap::new();
forward.insert(3, pp(30)); forward.insert(4, pp(31));
forward.insert(7, pp(40));
forward.insert(8, pp(41));
forward.insert(10, pp(42));
forward.insert(13, pp(50));
forward.insert(14, pp(51));
forward.insert(15, pp(52));
forward.insert(16, pp(53));
forward.insert(18, pp(10)); forward.insert(19, pp(11)); forward.insert(20, pp(11)); forward.insert(22, pp(12)); forward.insert(23, pp(13)); forward.insert(24, pp(14)); forward.insert(29, pp(15));
let mut sm = SourceMap::new();
sm.insert_chunk(
STEP_SOURCE.to_string(),
STEP_PASTA_FILE.to_string(),
ChunkSourceMap::from_forward(forward),
);
Arc::new(sm)
}
struct Expected {
origin_pasta: u32,
multi_pasta: u32,
multi_lua_first: u32,
multi_lua_second: u32,
unmapped_lua: u32,
call_helper_lua: u32,
call_helper_pasta: u32,
callee_unmapped_lua: u32,
callee_first_pasta: u32,
next_caller_pasta: u32,
step_out_pasta: u32,
recur_call_pasta: u32,
after_recur_pasta: u32,
co_origin_pasta: u32,
co_yield_pasta: u32,
co_post_yield_pasta: u32,
}
impl Expected {
fn derive(map: &SourceMap) -> Self {
let lp = |lua: u32| map.resolve_lua_to_pasta(STEP_SOURCE, lua).map(|p| p.line);
let origin_lua = 18;
let origin_pasta = lp(origin_lua).expect("起点 `.lua` 18 は対応 `.pasta` を持つ");
let multi_lua_first = 19;
let multi_lua_second = 20;
let multi_pasta = lp(multi_lua_first).expect("行19 は対応 `.pasta` を持つ");
assert_eq!(
lp(multi_lua_second),
Some(multi_pasta),
"E1: 行19/20 は同一 `.pasta` 行(複数 `.lua` 行展開・消化対象)"
);
assert_ne!(
multi_pasta, origin_pasta,
"E1: 複数 `.lua` の `.pasta` 行は起点の `.pasta` 行と異なる(1 回目 step over の停止先)"
);
let unmapped_lua = 21;
assert_eq!(lp(unmapped_lua), None, "E6: 行21 は未対応(通過する)");
let call_helper_lua = 22;
let call_helper_pasta = lp(call_helper_lua).expect("行22 は helper 呼び出しの対応 `.pasta`");
assert_ne!(
call_helper_pasta, multi_pasta,
"E1: 2 回目 step over 停止先は `.pasta` 11 と異なる次の `.pasta` 行"
);
let callee_unmapped_lua = 2;
assert_eq!(
lp(callee_unmapped_lua),
None,
"E3: helper 内 行2 は未対応(step into で通過)"
);
let callee_first_lua = 3;
let callee_first_pasta =
lp(callee_first_lua).expect("helper 内 行3 は最初の対応 `.pasta`(step into 停止先)");
let recur_call_lua = 23;
let recur_call_pasta = lp(recur_call_lua).expect("行23 は再帰呼び出しの対応 `.pasta`");
assert_ne!(
recur_call_pasta, call_helper_pasta,
"E2/E5: 再帰呼び出し行は helper 呼び出し行と異なる `.pasta` 行"
);
let after_recur_lua = 24;
let after_recur_pasta = lp(after_recur_lua).expect("行24 は recur 呼び出し直後の対応 `.pasta`");
assert_ne!(
after_recur_pasta, recur_call_pasta,
"E5: 再帰 step over 停止先は recur 呼び出し行と異なる次の `.pasta` 行"
);
assert!(
lp(7).is_some() && lp(8).is_some(),
"E5: recur 本体(行7/8)は対応 `.pasta` 行を持つ(深いフレームの誤停止候補)"
);
let next_caller_pasta = recur_call_pasta;
let step_out_pasta = recur_call_pasta;
assert_ne!(
step_out_pasta, call_helper_pasta,
"E4: step out 停止先は呼出行の `.pasta` 行と異なる(次の対応行)"
);
let co_origin_pasta = lp(13).expect("コルーチン本体 行13 の対応 `.pasta`");
let co_yield_pasta = lp(14).expect("yield 行14 の対応 `.pasta`");
let co_post_yield_pasta = lp(15).expect("resume 後 行15 の対応 `.pasta`");
assert!(
co_origin_pasta != co_yield_pasta && co_yield_pasta != co_post_yield_pasta,
"E7: コルーチンの 3 停止位置は相異なる `.pasta` 行"
);
Expected {
origin_pasta,
multi_pasta,
multi_lua_first,
multi_lua_second,
unmapped_lua,
call_helper_lua,
call_helper_pasta,
callee_unmapped_lua,
callee_first_pasta,
next_caller_pasta,
step_out_pasta,
recur_call_pasta,
after_recur_pasta,
co_origin_pasta,
co_yield_pasta,
co_post_yield_pasta,
}
}
}
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
fn top_frame_line(client: &mut DapClient, thread_id: u64, seq: u64) -> u32 {
client.send_request(seq, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have the stopped frame");
frames[0]["line"].as_u64().expect("top frame line") as u32
}
fn top_pasta_line(client: &mut DapClient, thread_id: u64, seq: u64) -> u32 {
client.send_request(seq, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have the stopped frame");
let top_src = frames[0]["source"]["path"]
.as_str()
.expect("top frame source path");
assert!(
top_src.ends_with(".pasta"),
"`.pasta` 提示中は top フレームが `.pasta` を提示すること(`.lua` ではない): {top_src:?}"
);
frames[0]["line"].as_u64().expect("top frame line") as u32
}
fn join_host(host: std::thread::JoinHandle<Result<(), String>>) {
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
#[allow(clippy::too_many_arguments)]
fn start_session(
map: Arc<SourceMap>,
mode: SourceMode,
bp_source_path: &str,
bp_line: u32,
) -> (std::thread::JoinHandle<Result<(), String>>, DapClient, u64) {
let map_for_host = Arc::clone(&map);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
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()),
source_mode: mode,
..Default::default()
};
let handle = enable(&lua, &cfg, Some(map_for_host))
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before running the VM".to_string())?;
lua.load(STEP_CHUNK)
.set_name(STEP_SOURCE)
.exec()
.map_err(|e| format!("scenario exec failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": bp_source_path },
"breakpoints": [{ "line": bp_line }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 1);
assert_eq!(bps[0]["verified"], true, "BP は verified で登録される");
client.send_request(3, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("send go signal");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"最初の停止は BP(step 起点)"
);
let thread_id = stopped["body"]["threadId"].as_u64().expect("threadId");
(host, client, thread_id)
}
fn continue_to_end(
host: std::thread::JoinHandle<Result<(), String>>,
client: &mut DapClient,
thread_id: u64,
seq: u64,
) {
client.send_request(seq, "continue", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "continue"));
join_host(host);
}
#[test]
fn e1_e6_e2_step_over_consumes_pasta_line_passes_unmapped_and_skips_sub_call() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let (host, mut client, thread_id) =
start_session(Arc::clone(&map), SourceMode::Pasta, STEP_PASTA_FILE, exp.origin_pasta);
assert_eq!(
top_pasta_line(&mut client, thread_id, 10),
exp.origin_pasta,
"BP 停止は `.pasta` 起点行({})を提示する",
exp.origin_pasta
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "1 回目 step over は reason step");
assert_eq!(
top_pasta_line(&mut client, thread_id, 21),
exp.multi_pasta,
"1 回目 step over は次の異なる `.pasta` 行 {}(複数 `.lua` 行展開・行{})で停止する",
exp.multi_pasta,
exp.multi_lua_first
);
client.send_request(22, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E1: step over は reason step");
assert_eq!(
top_pasta_line(&mut client, thread_id, 23),
exp.call_helper_pasta,
"E1/E6/9.1/9.4: step over は同一 `.pasta` 行の 2 本目(.lua {})を消化し、未対応行 \
(.lua {})を通過、次の異なる `.pasta` 行 {} で停止する(`.lua` 行ではない)",
exp.multi_lua_second,
exp.unmapped_lua,
exp.call_helper_pasta
);
client.send_request(24, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let _ = client.recv_until(|m| is_event(m, "stopped"));
let after_sub = top_pasta_line(&mut client, thread_id, 25);
assert_eq!(
after_sub, exp.next_caller_pasta,
"E2/9.1: サブ呼び出しを含む行から step over は呼び出し先 helper(`.pasta` \
30/31)に入らず、次の `.pasta` 行 {}(呼出元フレーム)で停止する",
exp.next_caller_pasta
);
assert_ne!(
after_sub, exp.callee_first_pasta,
"E2: helper 内の `.pasta` 行({})で停止してはならない",
exp.callee_first_pasta
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn e3_e4_step_into_first_callee_pasta_line_and_step_out_next_caller_pasta_line() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta,
STEP_PASTA_FILE,
exp.call_helper_pasta,
);
assert_eq!(
top_pasta_line(&mut client, thread_id, 10),
exp.call_helper_pasta,
"BP 停止は呼び出し行の `.pasta`({})",
exp.call_helper_pasta
);
client.send_request(20, "stepIn", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "stepIn"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E3: step into は reason step");
assert_eq!(
top_pasta_line(&mut client, thread_id, 21),
exp.callee_first_pasta,
"E3/9.2/9.4: step into は未対応の callee 行(.lua {})を通過し、helper の最初の \
対応 `.pasta` 行 {} で停止する",
exp.callee_unmapped_lua,
exp.callee_first_pasta
);
client.send_request(30, "stepOut", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "stepOut"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E4: step out は reason step");
let out_line = top_pasta_line(&mut client, thread_id, 31);
assert_eq!(
out_line, exp.step_out_pasta,
"E4/9.3: step out は呼出元へ戻り、次の対応 `.pasta` 行 {} で停止する",
exp.step_out_pasta
);
assert_ne!(
out_line, exp.call_helper_pasta,
"E4: 呼出行の `.pasta` 行({})で再停止してはならない(次の対応行へ)",
exp.call_helper_pasta
);
continue_to_end(host, &mut client, thread_id, 40);
}
#[test]
fn e5_recursion_does_not_mis_stop_at_same_pasta_line_in_other_frames() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta,
STEP_PASTA_FILE,
exp.recur_call_pasta,
);
assert_eq!(
top_pasta_line(&mut client, thread_id, 10),
exp.recur_call_pasta,
"BP 停止は再帰呼び出し行の `.pasta`({})",
exp.recur_call_pasta
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E5: step over は reason step");
assert_eq!(
top_pasta_line(&mut client, thread_id, 21),
exp.after_recur_pasta,
"E5: 再帰の別フレームで同一 `.pasta` 行(40/41)を踏んでも誤停止せず、呼出元 \
フレームの次の `.pasta` 行 {} で停止する(depth による frame identity)",
exp.after_recur_pasta
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn e7_pasta_step_over_crosses_coroutine_yield_resume() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta,
STEP_PASTA_FILE,
exp.co_origin_pasta,
);
assert_eq!(
top_pasta_line(&mut client, thread_id, 10),
exp.co_origin_pasta,
"BP 停止はコルーチン本体の `.pasta` 起点行({})",
exp.co_origin_pasta
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let _ = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
top_pasta_line(&mut client, thread_id, 21),
exp.co_yield_pasta,
"E7: 1 回目 step over は yield 行の `.pasta`({})に停止する",
exp.co_yield_pasta
);
client.send_request(30, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E7: step over は reason step");
assert_eq!(
top_pasta_line(&mut client, thread_id, 31),
exp.co_post_yield_pasta,
"E7: yield をまたぐ step over は駆動ループ(別スレッド)を skip し、resume 後の \
`.pasta` 行 {} で停止する(step 鍵が yield/resume をまたいで生存)",
exp.co_post_yield_pasta
);
continue_to_end(host, &mut client, thread_id, 40);
}
#[test]
fn e8_lua_mode_steps_at_lua_granularity_regression() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let lua_source_path = STEP_SOURCE; let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Lua,
lua_source_path,
exp.multi_lua_first,
);
assert_eq!(
top_frame_line(&mut client, thread_id, 10),
exp.multi_lua_first,
"E8/9.5: `.lua` モードの停止は `.lua` 行({})を提示する",
exp.multi_lua_first
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "E8: step over は reason step");
let lua_step_line = top_frame_line(&mut client, thread_id, 21);
assert_eq!(
lua_step_line, exp.multi_lua_second,
"E8/9.5: `.lua` モードは `.lua` 行単位(次行 {} で停止)。`.pasta` 粒度のように \
同一 `.pasta` 行({})を消化して次の `.pasta` 行まで進んではならない",
exp.multi_lua_second, exp.multi_pasta
);
assert_ne!(
lua_step_line, exp.call_helper_lua,
"E8: `.pasta` 粒度の停止先(.lua {})に進んではならない(`.lua` 粒度回帰)",
exp.call_helper_lua
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn teeth_lua_mode_stops_at_lua_line_not_pasta() {
let map = step_scenario_map();
let exp = Expected::derive(&map);
let (host, mut client, thread_id) =
start_session(Arc::clone(&map), SourceMode::Lua, STEP_SOURCE, exp.multi_lua_first);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let _ = client.recv_until(|m| is_event(m, "stopped"));
let lua_step_line = top_frame_line(&mut client, thread_id, 21);
assert_eq!(
lua_step_line, exp.multi_lua_second,
"teeth: `.pasta` 粒度を無効化(Lua モード)すると step over は `.lua` 行 {} で \
停止する(E1 の `.pasta` 消化後の停止位置 .lua {} ではない)",
exp.multi_lua_second, exp.call_helper_lua
);
assert_ne!(
lua_step_line, exp.call_helper_lua,
"teeth: `.pasta` 粒度が OFF なら E1 の停止位置(.lua {})には到達しない \
→ E1 のアサートは `.pasta` 粒度に真に依存(恒真ではない)",
exp.call_helper_lua
);
continue_to_end(host, &mut client, thread_id, 30);
}
}
#[cfg(test)]
mod pasta_mode_edge_e2e {
use std::collections::BTreeMap;
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::transport::{read_frame, write_frame};
use crate::debug::{DebugConfig, SourceMode, enable};
const WATCHDOG: Duration = Duration::from_secs(15);
const EDGE_SOURCE: &str = "@pasta_mode_edge_e2e_scenario";
const EDGE_PASTA_FILE: &str = "scene_edge.pasta";
const EDGE_CHUNK: &str = "\
local a = 1
local b = a + 1
local c = b + 1
local d = c + 1
local e = d + 1
return e
";
fn edge_scenario_map() -> Arc<SourceMap> {
let pp = |line: u32| PastaPos {
file: EDGE_PASTA_FILE.to_string(),
line,
};
let mut forward: BTreeMap<u32, PastaPos> = BTreeMap::new();
forward.insert(1, pp(20)); forward.insert(2, pp(30)); forward.insert(3, pp(30)); forward.insert(4, pp(30)); forward.insert(5, pp(40));
let mut sm = SourceMap::new();
sm.insert_chunk(
EDGE_SOURCE.to_string(),
EDGE_PASTA_FILE.to_string(),
ChunkSourceMap::from_forward(forward),
);
Arc::new(sm)
}
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
fn top_frame(client: &mut DapClient, thread_id: u64, seq: u64) -> (String, u32) {
client.send_request(seq, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have the stopped frame");
let path = frames[0]["source"]["path"]
.as_str()
.expect("top frame source path")
.to_string();
let line = frames[0]["line"].as_u64().expect("top frame line") as u32;
(path, line)
}
fn join_host(host: std::thread::JoinHandle<Result<(), String>>) {
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
fn start_session(
map: Arc<SourceMap>,
server_mode: SourceMode,
attach_mode: Option<SourceMode>,
bp_source_path: &str,
bp_line: u32,
) -> (std::thread::JoinHandle<Result<(), String>>, DapClient, u64) {
let map_for_host = Arc::clone(&map);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
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()),
source_mode: server_mode,
..Default::default()
};
let handle = enable(&lua, &cfg, Some(map_for_host))
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before running the VM".to_string())?;
lua.load(EDGE_CHUNK)
.set_name(EDGE_SOURCE)
.exec()
.map_err(|e| format!("scenario exec failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
if let Some(m) = attach_mode {
let presentation = match m {
SourceMode::Pasta => "pasta",
SourceMode::Lua => "lua",
};
client.send_request(
4,
"attach",
json!({ "sourcePresentation": presentation }),
);
let _ = client.recv_until(|m| is_response(m, "attach"));
}
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": bp_source_path },
"breakpoints": [{ "line": bp_line }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 1);
assert_eq!(bps[0]["verified"], true, "BP は verified で登録される");
client.send_request(3, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("send go signal");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"最初の停止は BP(観測起点)"
);
let thread_id = stopped["body"]["threadId"].as_u64().expect("threadId");
(host, client, thread_id)
}
fn continue_to_end(
host: std::thread::JoinHandle<Result<(), String>>,
client: &mut DapClient,
thread_id: u64,
mut seq: u64,
) {
for _ in 0..30u64 {
client.send_request(seq, "continue", json!({ "threadId": thread_id }));
seq += 1;
let next =
client.recv_until(|m| is_event(m, "stopped") || is_event(m, "terminated"));
if is_event(&next, "terminated") {
break;
}
}
join_host(host);
}
#[test]
fn mode_switch_lua_presents_lua_coords_and_lua_step_granularity_over_tcp() {
let map = edge_scenario_map();
let lua_bp_line = 2u32;
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta, Some(SourceMode::Lua), EDGE_SOURCE, lua_bp_line,
);
let (lua_src, lua_line) = top_frame(&mut client, thread_id, 10);
assert!(
!lua_src.ends_with(".pasta"),
"6.2: `.lua` モードのコールスタックは `.lua` 座標(チャンク名)を提示すること \
(`.pasta` ではない)。actual={lua_src:?}"
);
assert_eq!(
crate::debug::source_map::canonicalize_chunk_name(&lua_src),
crate::debug::source_map::canonicalize_chunk_name(EDGE_SOURCE),
"6.2: `.lua` モードの提示 source は生成 `.lua` チャンク({EDGE_SOURCE})"
);
assert_eq!(
lua_line, lua_bp_line,
"6.2: `.lua` モードの停止行は `.lua` 行({lua_bp_line})。`.pasta` 行(30)ではない"
);
client.send_request(20, "next", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "next"));
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "9.5: step over は reason step");
let (step_src, step_line) = top_frame(&mut client, thread_id, 21);
assert!(
!step_src.ends_with(".pasta"),
"9.5: `.lua` モードのステップ後も `.lua` 座標提示。actual={step_src:?}"
);
assert_eq!(
step_line, 3,
"9.5: `.lua` モードの step over は次の `.lua` 行(3 = 同一 `.pasta` 30 の 2 本目)で \
停止する。`.pasta` 粒度のように同一 `.pasta` 行を消化して `.pasta` 40(.lua 5)まで \
進んではならない"
);
assert_ne!(
step_line, 5,
"9.5: `.pasta` 粒度の停止先(.lua 5 = `.pasta` 40)に進んではならない(`.lua` 粒度回帰)"
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn teeth_same_lua_line_in_pasta_mode_presents_pasta_not_lua() {
let map = edge_scenario_map();
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta,
None,
EDGE_PASTA_FILE,
30,
);
let (pasta_src, pasta_line) = top_frame(&mut client, thread_id, 10);
assert!(
pasta_src.ends_with(".pasta"),
"歯: `.pasta` モードは `.pasta` を提示する(`.lua` モードのテストの差分が観測可能)。\
actual={pasta_src:?}"
);
assert_eq!(
pasta_line, 30,
"歯: `.pasta` モードの提示行は `.pasta` 30(`.lua` 行2 ではない)。`.lua` モードの \
テストの `.lua` 座標アサートは恒真でない"
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn edge_8_1_aggregated_lua_line_presents_deterministic_single_pasta() {
let map = edge_scenario_map();
let first = map
.resolve_lua_to_pasta(EDGE_SOURCE, 1)
.expect("集約 `.lua` 行1 は対応 `.pasta` を持つ")
.clone();
assert_eq!(
first.line, 20,
"8.1: 集約 `.lua` 行1 の `.pasta` 位置は確定的単一(20・last-write-wins)"
);
for _ in 0..8 {
let again = map
.resolve_lua_to_pasta(EDGE_SOURCE, 1)
.expect("集約行は反復しても対応 `.pasta` を持つ");
assert_eq!(
(&again.file, again.line),
(&first.file, first.line),
"8.1: 集約行の `.pasta` 位置は反復しても確定的に同一の単一位置"
);
}
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta,
None,
EDGE_PASTA_FILE,
20,
);
let (src, line) = top_frame(&mut client, thread_id, 10);
assert!(
src.ends_with(".pasta"),
"8.1: 集約行の停止は `.pasta` を提示する。actual={src:?}"
);
assert_eq!(
line, 20,
"8.1: 集約 `.lua` 行1 の停止は確定的単一の `.pasta` 20 を提示する"
);
continue_to_end(host, &mut client, thread_id, 30);
}
#[test]
fn edge_8_2_expanded_pasta_line_same_pasta_at_every_lua_line() {
let map = edge_scenario_map();
for lua_line in [2u32, 3, 4] {
let pos = map
.resolve_lua_to_pasta(EDGE_SOURCE, lua_line)
.unwrap_or_else(|| panic!("展開 `.lua` 行{lua_line} は対応 `.pasta` を持つ"));
assert_eq!(
pos.line, 30,
"8.2: 展開 `.lua` 行{lua_line} は単一 `.pasta` 30 へ写像する"
);
}
for lua_line in [2u32, 3, 4] {
let (host, mut client, thread_id) = start_session(
Arc::clone(&map),
SourceMode::Pasta, None,
EDGE_SOURCE, lua_line,
);
let (src, presented) = top_frame(&mut client, thread_id, 10);
assert!(
src.ends_with(".pasta"),
"8.2: 展開 `.lua` 行{lua_line} の停止は `.pasta` を提示する。actual={src:?}"
);
assert_eq!(
presented, 30,
"8.2: 展開 `.lua` 行{lua_line} のいずれで停止しても同一 `.pasta` 30 を提示する"
);
continue_to_end(host, &mut client, thread_id, 30);
}
}
#[test]
fn edge_8_3_presentation_order_is_stable_deterministic() {
let map = edge_scenario_map();
let baseline: Vec<u32> = map
.resolve_pasta_to_lua(EDGE_PASTA_FILE, 30)
.into_iter()
.map(|(_chunk, lua_line)| lua_line)
.collect();
assert_eq!(
baseline,
vec![2, 3, 4],
"8.3: 展開 `.pasta` 30 の逆引きは `.lua` 行昇順 [2, 3, 4](決定的)"
);
let baseline_full = map.resolve_pasta_to_lua(EDGE_PASTA_FILE, 30);
for _ in 0..16 {
let again = map.resolve_pasta_to_lua(EDGE_PASTA_FILE, 30);
assert_eq!(
again, baseline_full,
"8.3: 同一 `.pasta` 位置の提示順は反復しても安定(決定的)"
);
}
for _ in 0..4 {
let rebuilt = edge_scenario_map();
let order: Vec<u32> = rebuilt
.resolve_pasta_to_lua(EDGE_PASTA_FILE, 30)
.into_iter()
.map(|(_chunk, lua_line)| lua_line)
.collect();
assert_eq!(
order, baseline,
"8.3: 複数回構築しても提示順は安定(決定的・BTreeMap 由来)"
);
}
}
}
#[cfg(test)]
mod pasta_break_coalesce_e2e {
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::source_map::{MapBuilderSink, SourceMap};
use crate::debug::transport::{read_frame, write_frame};
use crate::debug::{DebugConfig, SourceMode, enable};
use crate::loader::CacheManager;
use crate::transpiler::LuaTranspiler;
const WATCHDOG: Duration = Duration::from_secs(15);
const FIXTURE: &str = include_str!("../../tests/fixtures/debug_break_coalesce.pasta");
const MULTI_TO_ONE_MARKER: &str = "合計は@加算ループ()";
const LOOP_BODY_MARKER: &str = "total = total + i";
const LOOP_VISITS: usize = 3;
const PASTA_SHIM: &str = "\
local PASTA = {}
function PASTA.create_scene(name)
local s = { name = name }
PASTA.__last_scene = s -- 生成 scene を捕捉(chunk の do...end ローカルを橋渡し)
return s
end
package.loaded['pasta'] = PASTA
package.loaded['pasta.global'] = {}
local actor = {}
actor.__index = actor
function actor:talk(_s) return self end
function actor:expr_fn(_name) return 0 end
ACT = setmetatable({}, {
__index = function(_t, _k)
return setmetatable({}, actor)
end,
})
function ACT.init_scene(_self, _scene) return {}, {} end
";
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
fn unique_pasta_line(needle: &str) -> u32 {
let hits: Vec<u32> = FIXTURE
.lines()
.enumerate()
.filter(|(_, l)| {
let t = l.trim_start();
!t.starts_with('#') && !t.starts_with('#') && l.contains(needle)
})
.map(|(i, _)| i as u32 + 1)
.collect();
assert_eq!(
hits.len(),
1,
"fixture invariant: marker {needle:?} must appear on exactly one line, got {hits:?}"
);
hits[0]
}
struct Fixture {
map: Arc<SourceMap>,
lua_source: String,
chunk_name: String,
pasta_path: String,
multi_pasta_line: u32,
multi_lua_lines: Vec<u32>,
next_pasta_line: u32,
}
fn build_fixture() -> Fixture {
let temp = tempfile::TempDir::new().expect("temp dir");
let base_dir = temp.path().to_path_buf();
let pasta_file = base_dir.join("dic/test/debug_break_coalesce.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).expect("mkdir dic");
std::fs::write(&pasta_file, FIXTURE).expect("write .pasta");
let cache_manager = CacheManager::new(base_dir.clone(), "profile/pasta/cache/lua");
let map = crate::loader::PastaLoader::build_source_map(
std::slice::from_ref(&pasta_file),
&cache_manager,
false,
);
let chunk_name = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_path = pasta_file.to_string_lossy().to_string();
let content = std::fs::read_to_string(&pasta_file).expect("read .pasta");
let parsed = pasta_dsl::parse_str(&content, &pasta_path).expect("parse .pasta");
let transpiler = LuaTranspiler::default();
let mut sink = MapBuilderSink::new(pasta_path.clone(), chunk_name.clone());
let mut out = Vec::new();
transpiler
.transpile_with_source_map(&parsed, &mut out, Some(&mut sink))
.expect("transpile .pasta");
let lua_source = String::from_utf8(out).expect("utf8 .lua");
let multi_pasta_line = unique_pasta_line(MULTI_TO_ONE_MARKER);
let mut multi_lua_lines: Vec<u32> = map
.resolve_pasta_to_lua(&pasta_path, multi_pasta_line)
.into_iter()
.map(|(_chunk, lua_line)| lua_line)
.collect();
multi_lua_lines.sort_unstable();
assert!(
multi_lua_lines.len() >= 2,
"6.2(a): 多対1 行 {multi_pasta_line} は ≥2 の `.lua` 行へ展開されること(前提): \
{multi_lua_lines:?}"
);
let last_multi_lua = *multi_lua_lines.last().unwrap();
let next_pasta_line = (last_multi_lua + 1..=last_multi_lua + 200)
.find_map(|lua_line| {
map.resolve_lua_to_pasta(&chunk_name, lua_line)
.filter(|pos| pos.line != multi_pasta_line)
.map(|pos| pos.line)
})
.expect("多対1 行の後に別の `.pasta` 行が存在すること(next stop point)");
assert_ne!(
next_pasta_line, multi_pasta_line,
"次の停止点は多対1 行とは異なる `.pasta` 行であること(歯の有効性)"
);
drop(temp);
Fixture {
map,
lua_source,
chunk_name,
pasta_path,
multi_pasta_line,
multi_lua_lines,
next_pasta_line,
}
}
#[test]
fn one_continue_escapes_multi_to_one_pasta_line_over_tcp() {
let fx = build_fixture();
assert!(
fx.multi_lua_lines.len() >= 2,
"多対1 前提: `.pasta` 行 {} → `.lua` 行 {:?}",
fx.multi_pasta_line,
fx.multi_lua_lines
);
let lua_source = fx.lua_source.clone();
let chunk_name = fx.chunk_name.clone();
let map = Arc::clone(&fx.map);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (loaded_tx, loaded_rx) = mpsc::channel::<()>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let host = std::thread::spawn(move || -> Result<(), String> {
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()),
source_mode: SourceMode::Pasta, ..Default::default()
};
let handle = enable(&lua, &cfg, Some(map))
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
lua.load(PASTA_SHIM)
.set_name("@pasta_shim")
.exec()
.map_err(|e| format!("shim exec failed: {e}"))?;
lua.load(&lua_source)
.set_name(format!("@{chunk_name}"))
.exec()
.map_err(|e| format!("chunk (definitions) exec failed: {e}"))?;
loaded_tx
.send(())
.map_err(|_| "loaded signal send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before calling __start__".to_string())?;
lua.load("local s = package.loaded['pasta'].__last_scene; return s.__start__(ACT)")
.set_name("@invoke_start")
.exec()
.map_err(|e| format!("__start__ call failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
loaded_rx
.recv_timeout(WATCHDOG)
.expect("host must finish definitions before the watchdog");
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": fx.pasta_path },
"breakpoints": [
{ "line": fx.multi_pasta_line },
{ "line": fx.next_pasta_line },
],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 2, "2 つの BP 応答(多対1 行+次行)");
assert!(
bps.iter().all(|b| b["verified"] == true),
"両 `.pasta` 行 BP は verified で登録される: {bps:?}"
);
client.send_request(3, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("send go signal");
let stopped1 = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped1["body"]["reason"], "breakpoint",
"1.2/3.2: 多対1 `.pasta` 行 BP に対応する `.lua` 行で停止する"
);
let thread_id = stopped1["body"]["threadId"].as_u64().expect("threadId");
let stop1_line = top_pasta_line(&mut client, thread_id, 10);
assert_eq!(
stop1_line, fx.multi_pasta_line,
"最初の停止は多対1 `.pasta` 行 {} であること",
fx.multi_pasta_line
);
client.send_request(20, "continue", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "continue"));
let next = client.recv_until(|m| is_event(m, "stopped") || is_event(m, "terminated"));
if is_event(&next, "stopped") {
let next_tid = next["body"]["threadId"].as_u64().unwrap_or(thread_id);
let stop2_line = top_pasta_line(&mut client, next_tid, 21);
assert_ne!(
stop2_line, fx.multi_pasta_line,
"1.1/3.2: 1 回の continue が同一 `.pasta` 行 {} で **再停止してはならない** \
(coalescing)。actual={stop2_line}",
fx.multi_pasta_line
);
assert_eq!(
stop2_line, fx.next_pasta_line,
"1.2: 1 回の continue は **次の** `.pasta` 行 {} へ進むこと。actual={stop2_line}",
fx.next_pasta_line
);
let mut done = false;
for seq in 30u64..60u64 {
client.send_request(seq, "continue", json!({ "threadId": next_tid }));
let m =
client.recv_until(|m| is_event(m, "stopped") || is_event(m, "terminated"));
if is_event(&m, "terminated") {
done = true;
break;
}
}
assert!(done, "残りを continue で流し切れること");
} else {
panic!(
"1.2: 1 回の continue は次の `.pasta` 行 {} で停止すべきだが、stopped 無しで \
terminated した(次の停止点へ到達できていない)",
fx.next_pasta_line
);
}
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("scenario must run to completion after continue");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
#[test]
fn loop_revisit_yields_one_stop_per_iteration_over_tcp() {
let temp = tempfile::TempDir::new().expect("temp dir");
let base_dir = temp.path().to_path_buf();
let pasta_file = base_dir.join("dic/test/debug_break_coalesce.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).expect("mkdir dic");
std::fs::write(&pasta_file, FIXTURE).expect("write .pasta");
let cache_manager = CacheManager::new(base_dir.clone(), "profile/pasta/cache/lua");
let map = crate::loader::PastaLoader::build_source_map(
std::slice::from_ref(&pasta_file),
&cache_manager,
false,
);
let chunk_name = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_path = pasta_file.to_string_lossy().to_string();
let content = std::fs::read_to_string(&pasta_file).expect("read .pasta");
let parsed = pasta_dsl::parse_str(&content, &pasta_path).expect("parse .pasta");
let transpiler = LuaTranspiler::default();
let mut sink = MapBuilderSink::new(pasta_path.clone(), chunk_name.clone());
let mut out = Vec::new();
transpiler
.transpile_with_source_map(&parsed, &mut out, Some(&mut sink))
.expect("transpile .pasta");
let lua_source = String::from_utf8(out).expect("utf8 .lua");
let loop_pasta_line = unique_pasta_line(LOOP_BODY_MARKER);
let loop_lua_lines: Vec<u32> = map
.resolve_pasta_to_lua(&pasta_path, loop_pasta_line)
.into_iter()
.map(|(_chunk, lua_line)| lua_line)
.collect();
assert_eq!(
loop_lua_lines.len(),
1,
"6.2(b) 前提: ループ本体 `.pasta` 行 {loop_pasta_line} は単一 `.lua` 座標を持つ: \
{loop_lua_lines:?}"
);
drop(temp);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (loaded_tx, loaded_rx) = mpsc::channel::<()>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let map_host = Arc::clone(&map);
let host = std::thread::spawn(move || -> Result<(), String> {
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()),
source_mode: SourceMode::Pasta,
..Default::default()
};
let handle = enable(&lua, &cfg, Some(map_host))
.map_err(|e| format!("enable failed: {e}"))?
.ok_or_else(|| "enable returned None for an enabled config".to_string())?;
let addr = handle
.local_addr()
.ok_or_else(|| "enabled handle must expose a bound addr".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
lua.load(PASTA_SHIM)
.set_name("@pasta_shim")
.exec()
.map_err(|e| format!("shim exec failed: {e}"))?;
lua.load(&lua_source)
.set_name(format!("@{chunk_name}"))
.exec()
.map_err(|e| format!("chunk (definitions) exec failed: {e}"))?;
loaded_tx
.send(())
.map_err(|_| "loaded signal send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "did not receive go signal before calling 加算ループ".to_string())?;
lua.load("local s = package.loaded['pasta'].__last_scene; return s['加算ループ'](ACT)")
.set_name("@invoke_loop")
.exec()
.map_err(|e| format!("加算ループ call failed: {e}"))?;
lua.remove_global_hook();
drop(handle);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
loaded_rx
.recv_timeout(WATCHDOG)
.expect("host must finish definitions before the watchdog");
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": pasta_path },
"breakpoints": [ { "line": loop_pasta_line } ],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"]
.as_array()
.expect("breakpoints array");
assert_eq!(bps.len(), 1, "1 つの BP 応答(ループ本体行)");
assert!(
bps.iter().all(|b| b["verified"] == true),
"ループ本体 `.pasta` 行 BP は verified で登録される: {bps:?}"
);
client.send_request(3, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("send go signal");
let mut stop_count = 0usize;
let mut seq = 10u64;
loop {
let ev = client.recv_until(|m| is_event(m, "stopped") || is_event(m, "terminated"));
if is_event(&ev, "terminated") {
break;
}
assert_eq!(
ev["body"]["reason"], "breakpoint",
"2.2: ループ本体行 BP に対応する `.lua` 行で停止する"
);
let tid = ev["body"]["threadId"].as_u64().expect("threadId");
let stop_line = top_pasta_line(&mut client, tid, seq);
seq += 1;
assert_eq!(
stop_line, loop_pasta_line,
"2.2/6.2(b): 各停止はループ本体 `.pasta` 行 {loop_pasta_line} であること。actual={stop_line}"
);
stop_count += 1;
assert!(
stop_count <= LOOP_VISITS,
"2.2: 停止数がループ反復回数 {LOOP_VISITS} を超えた(coalescing 不全 / 同一行で過剰停止): \
既に {stop_count} 回停止"
);
client.send_request(seq, "continue", json!({ "threadId": tid }));
let _ = client.recv_until(|m| is_response(m, "continue"));
seq += 1;
}
assert_eq!(
stop_count, LOOP_VISITS,
"2.2/6.2(b): ループ本体 `.pasta` 行はループ反復回数 {LOOP_VISITS} と同数だけ停止すること \
(N 訪問 → N 停止)。actual={stop_count}"
);
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host VM thread must not panic")
.expect("loop scenario must run to completion");
}
Err(RecvTimeoutError::Timeout) => {
panic!("host VM thread did not finish within the watchdog (hang?)");
}
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
fn top_pasta_line(client: &mut DapClient, thread_id: u64, seq: u64) -> u32 {
client.send_request(seq, "stackTrace", json!({ "threadId": thread_id }));
let stack = client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"]
.as_array()
.expect("stackFrames array");
assert!(!frames.is_empty(), "stack must have the stopped frame");
let top_src = frames[0]["source"]["path"]
.as_str()
.expect("top frame source path");
assert!(
top_src.ends_with(".pasta"),
"`.pasta` 提示中は top フレームが `.pasta` を提示すること: {top_src:?}"
);
frames[0]["line"].as_u64().expect("top frame line") as u32
}
}
#[cfg(test)]
mod source_presentation_toggle_tests {
use std::collections::BTreeMap;
use std::io::BufReader;
use std::net::{SocketAddr, TcpStream};
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::dap::DapAdapter;
use crate::debug::source_map::{ChunkSourceMap, PastaPos, SourceMap};
use crate::debug::transport::{Transport, read_frame};
use crate::debug::types::{FrameInfo, SessionCommand, SessionEvent};
use crate::debug::{SharedSourceMode, SourceMode};
use super::{SharedAdapter, SourceMapWiring, handle_inbound};
const WATCHDOG: Duration = Duration::from_secs(10);
fn map_with(chunk: &str, lua_line: u32, file: &str, pasta_line: u32) -> SourceMap {
let mut forward = BTreeMap::new();
forward.insert(
lua_line,
PastaPos {
file: file.to_string(),
line: pasta_line,
},
);
let mut sm = SourceMap::new();
sm.insert_chunk(
chunk.to_string(),
file.to_string(),
ChunkSourceMap::from_forward(forward),
);
sm
}
fn wiring_with(map: SourceMap, start: SourceMode) -> SourceMapWiring {
SourceMapWiring {
source_map: Some(Arc::new(map)),
source_mode: SharedSourceMode::new(start),
}
}
struct Harness {
transport: Transport,
client: BufReader<TcpStream>,
}
impl Harness {
fn new() -> Self {
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let transport = Transport::start(Some(addr)).expect("bind loopback transport");
let bound = transport.local_addr().expect("bound addr");
let stream = TcpStream::connect(bound).expect("client connects to bridge");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
Self {
transport,
client: BufReader::new(stream),
}
}
fn recv(&mut self) -> Value {
read_frame(&mut self.client)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present")
}
}
fn toggle_req(seq: u64, mode: &str) -> Value {
json!({
"seq": seq,
"type": "request",
"command": "pasta/sourcePresentation",
"arguments": { "mode": mode },
})
}
fn top_frame(adapter: &SharedAdapter, source: &str, line: u32) -> (Value, u32) {
let mut dap = adapter.lock().unwrap();
dap.decode_request(&json!({
"seq": 9, "type": "request", "command": "stackTrace",
"arguments": { "threadId": 1 },
}));
let out = dap.encode_event(SessionEvent::Stack(vec![FrameInfo {
source: source.to_string(),
line,
func_name: Some("f".to_string()),
}]));
let frame = &out[0]["body"]["stackFrames"][0];
(frame["source"].clone(), frame["line"].as_u64().unwrap() as u32)
}
#[test]
fn valid_toggle_to_lua_applies_acks_events_and_forwards_refresh() {
let mut h = Harness::new();
let adapter: SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx): (_, Receiver<SessionCommand>) = mpsc::channel();
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
super::attach_pasta_resolver(&adapter, &wiring);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(src, json!({ "path": "C:/proj/scene.pasta" }));
assert_eq!(line, 3);
let ok = handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&toggle_req(70, "lua"),
&wiring,
);
assert!(ok, "handle_inbound must not report the peer gone");
assert_eq!(wiring.source_mode.get(), SourceMode::Lua, "cell set to lua (1.1)");
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": r"@C:\proj\cache\scene.lua" }),
"resolver swapped → generated `.lua` presentation (3.1/3.4)"
);
assert_eq!(line, 7);
let resp = h.recv();
assert_eq!(resp["type"], "response");
assert_eq!(resp["command"], "pasta/sourcePresentation");
assert_eq!(resp["request_seq"], 70, "ack correlates to the request seq (1.3)");
assert_eq!(resp["success"], true);
assert_eq!(resp["body"]["mode"], "lua", "ack echoes the resolved mode (1.3)");
let ev = h.recv();
assert_eq!(ev["type"], "event");
assert_eq!(ev["event"], "pasta/sourcePresentation");
assert_eq!(ev["body"]["mode"], "lua", "event carries the new mode (2.6)");
let cmd = cmd_rx
.recv_timeout(WATCHDOG)
.expect("RefreshPresentation must be forwarded to the session");
assert_eq!(cmd, SessionCommand::RefreshPresentation);
assert!(cmd_rx.try_recv().is_err(), "exactly one command forwarded");
}
#[test]
fn valid_toggle_to_pasta_swaps_in_pasta_resolver() {
let mut h = Harness::new();
let adapter: SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx): (_, Receiver<SessionCommand>) = mpsc::channel();
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Lua,
);
super::attach_pasta_resolver(&adapter, &wiring);
let (src, _line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(src, json!({ "path": r"@C:\proj\cache\scene.lua" }));
let ok = handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&toggle_req(71, "pasta"),
&wiring,
);
assert!(ok);
assert_eq!(wiring.source_mode.get(), SourceMode::Pasta, "cell set to pasta (1.2)");
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": "C:/proj/scene.pasta" }),
"resolver swapped → `.pasta` presentation (3.2/3.5)"
);
assert_eq!(line, 3);
let resp = h.recv();
assert_eq!(resp["request_seq"], 71);
assert_eq!(resp["body"]["mode"], "pasta");
let ev = h.recv();
assert_eq!(ev["event"], "pasta/sourcePresentation");
assert_eq!(ev["body"]["mode"], "pasta");
assert_eq!(
cmd_rx.recv_timeout(WATCHDOG).unwrap(),
SessionCommand::RefreshPresentation
);
}
#[test]
fn invalid_mode_leaves_cell_unchanged_but_acks_current() {
let mut h = Harness::new();
let adapter: SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx): (_, Receiver<SessionCommand>) = mpsc::channel();
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
super::attach_pasta_resolver(&adapter, &wiring);
let ok = handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&toggle_req(72, "bogus"),
&wiring,
);
assert!(ok);
assert_eq!(
wiring.source_mode.get(),
SourceMode::Pasta,
"1.4: unrecognized mode must NOT change the cell"
);
let (src, line) = top_frame(&adapter, r"@C:\proj\cache\scene.lua", 7);
assert_eq!(
src,
json!({ "path": "C:/proj/scene.pasta" }),
"1.4: resolver unchanged (still `.pasta`)"
);
assert_eq!(line, 3);
let resp = h.recv();
assert_eq!(resp["request_seq"], 72);
assert_eq!(resp["body"]["mode"], "pasta", "1.4: echo the current (unchanged) mode");
let ev = h.recv();
assert_eq!(ev["body"]["mode"], "pasta");
assert_eq!(
cmd_rx.recv_timeout(WATCHDOG).unwrap(),
SessionCommand::RefreshPresentation
);
}
#[test]
fn attach_with_explicit_mode_emits_initial_event() {
let mut h = Harness::new();
let adapter: SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let breakpoints = BreakpointSet::new();
let (cmd_tx, _cmd_rx): (_, Receiver<SessionCommand>) = mpsc::channel();
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
super::attach_pasta_resolver(&adapter, &wiring);
let attach = json!({
"seq": 5, "type": "request", "command": "attach",
"arguments": { "sourcePresentation": "lua" },
});
let ok = handle_inbound(&h.transport, &adapter, &breakpoints, &cmd_tx, &attach, &wiring);
assert!(ok);
assert_eq!(wiring.source_mode.get(), SourceMode::Lua, "explicit attach mode applied");
let ack = h.recv();
assert_eq!(ack["type"], "response");
assert_eq!(ack["command"], "attach");
let ev = h.recv();
assert_eq!(ev["type"], "event");
assert_eq!(ev["event"], "pasta/sourcePresentation");
assert_eq!(ev["body"]["mode"], "lua", "2.5: event carries the resolved initial mode");
}
#[test]
fn attach_without_mode_emits_resolved_initial_event() {
let mut h = Harness::new();
let adapter: SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
let breakpoints = BreakpointSet::new();
let (cmd_tx, _cmd_rx): (_, Receiver<SessionCommand>) = mpsc::channel();
let wiring = wiring_with(
map_with("C:/proj/cache/scene.lua", 7, "C:/proj/scene.pasta", 3),
SourceMode::Pasta,
);
super::attach_pasta_resolver(&adapter, &wiring);
let attach = json!({
"seq": 6, "type": "request", "command": "attach",
"arguments": {},
});
let ok = handle_inbound(&h.transport, &adapter, &breakpoints, &cmd_tx, &attach, &wiring);
assert!(ok);
assert_eq!(wiring.source_mode.get(), SourceMode::Pasta, "no-arg attach keeps resolved mode");
let ack = h.recv();
assert_eq!(ack["command"], "attach");
let ev = h.recv();
assert_eq!(ev["event"], "pasta/sourcePresentation");
assert_eq!(
ev["body"]["mode"], "pasta",
"2.5: no-arg attach still publishes the resolved initial mode"
);
}
}
#[cfg(test)]
mod bridge_lifecycle_tests {
use std::io::BufReader;
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{Value, json};
use crate::debug::SourceMode;
use crate::debug::breakpoints::BreakpointSet;
use crate::debug::dap::DapAdapter;
use crate::debug::source_map::SourceMap;
use crate::debug::transport::{Transport, read_frame, write_frame};
use crate::debug::types::{SessionCommand, SessionEvent, StopReason};
use super::{
SharedAdapter, SourceMapWiring, attach_pasta_resolver, drain_outbound, handle_inbound,
run_event_encoder, run_socket_bridge,
};
const WATCHDOG: Duration = Duration::from_secs(10);
fn shared_adapter() -> SharedAdapter {
Arc::new(Mutex::new(DapAdapter::new()))
}
fn poison(adapter: &SharedAdapter) {
let poisoner = Arc::clone(adapter);
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("TEST-ONLY: poison the shared adapter lock");
})
.join();
assert!(adapter.lock().is_err(), "the adapter lock must now be poisoned");
}
struct Harness {
transport: Transport,
client: BufReader<TcpStream>,
writer: TcpStream,
}
impl Harness {
fn new() -> Self {
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let transport = Transport::start(Some(addr)).expect("bind loopback transport");
let bound = transport.local_addr().expect("bound addr");
let stream = TcpStream::connect(bound).expect("client connects to bridge");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone client socket for writing");
Self {
transport,
client: BufReader::new(stream),
writer,
}
}
fn recv(&mut self) -> Value {
read_frame(&mut self.client)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present")
}
}
#[test]
fn event_encoder_forwards_frames_and_ends_when_event_channel_closes() {
let adapter = shared_adapter();
let (event_tx, event_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel();
let encoder = std::thread::spawn(move || run_event_encoder(adapter, event_rx, out_tx));
event_tx
.send(SessionEvent::Stopped {
reason: StopReason::Breakpoint,
thread_id: 1,
})
.expect("encoder is alive");
let frame = out_rx
.recv_timeout(WATCHDOG)
.expect("the encoded frame must arrive on out_tx");
assert_eq!(frame["type"], "event");
assert_eq!(frame["event"], "stopped");
assert_eq!(frame["body"]["reason"], "breakpoint");
assert_eq!(frame["body"]["threadId"], 1);
drop(event_tx);
encoder
.join()
.expect("closing the event channel must end the encoder cleanly");
}
#[test]
fn event_encoder_ends_when_frame_receiver_is_gone() {
let adapter = shared_adapter();
let (event_tx, event_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel::<Value>();
drop(out_rx); let encoder = std::thread::spawn(move || run_event_encoder(adapter, event_rx, out_tx));
event_tx
.send(SessionEvent::Terminated)
.expect("queued before the encoder observes the closed frame channel");
encoder
.join()
.expect("a gone frame receiver must end the encoder, not panic it");
drop(event_tx);
}
#[test]
fn event_encoder_ends_on_poisoned_adapter_without_panic() {
let adapter = shared_adapter();
poison(&adapter);
let (event_tx, event_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel::<Value>();
let encoder = std::thread::spawn(move || run_event_encoder(adapter, event_rx, out_tx));
event_tx
.send(SessionEvent::Terminated)
.expect("encoder is blocked on recv, channel is open");
encoder
.join()
.expect("a poisoned adapter must end the encoder cleanly, not panic it");
assert!(
out_rx.try_recv().is_err(),
"no frame may be emitted under a poisoned adapter"
);
}
#[test]
fn drain_outbound_flushes_pending_and_treats_closed_channel_as_ok() {
let mut h = Harness::new();
let (out_tx, out_rx) = mpsc::channel();
out_tx.send(json!({ "type": "event", "event": "first" })).unwrap();
out_tx.send(json!({ "type": "event", "event": "second" })).unwrap();
drop(out_tx);
assert!(
drain_outbound(&h.transport, &out_rx),
"a closed out_rx must NOT stop the bridge (inbound may still arrive)"
);
assert_eq!(h.recv()["event"], "first", "queued frames are flushed in order");
assert_eq!(h.recv()["event"], "second");
}
#[test]
fn socket_bridge_exits_on_preset_shutdown_without_serving() {
let Harness {
transport,
mut client,
mut writer,
} = Harness::new();
write_frame(
&mut writer,
&json!({ "seq": 1, "type": "request", "command": "initialize", "arguments": {} }),
)
.expect("client queues a request before the bridge starts");
let (cmd_tx, _cmd_rx) = mpsc::channel();
let (_out_tx, out_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(true)); let adapter = shared_adapter();
let bridge = std::thread::spawn(move || {
run_socket_bridge(
transport,
adapter,
BreakpointSet::new(),
cmd_tx,
out_rx,
shutdown,
SourceMapWiring::disabled(),
)
});
bridge
.join()
.expect("a preset shutdown flag must end the bridge immediately");
if let Ok(Some(frame)) = read_frame(&mut client) {
panic!("bridge must exit before serving the queued request; got {frame}");
}
}
#[test]
fn socket_bridge_flushes_pending_outbound_on_client_disconnect() {
let Harness {
transport,
mut client,
writer,
} = Harness::new();
writer
.shutdown(Shutdown::Write)
.expect("half-close the client write side");
match transport.inbound().recv_timeout(WATCHDOG) {
Err(RecvTimeoutError::Disconnected) => {}
other => panic!("inbound must close on client write-shutdown, got {other:?}"),
}
let (out_tx, out_rx) = mpsc::channel();
out_tx.send(json!({ "type": "event", "event": "pending-1" })).unwrap();
out_tx.send(json!({ "type": "event", "event": "pending-2" })).unwrap();
drop(out_tx);
let (cmd_tx, _cmd_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
let adapter = shared_adapter();
let bridge = std::thread::spawn(move || {
run_socket_bridge(
transport,
adapter,
BreakpointSet::new(),
cmd_tx,
out_rx,
shutdown,
SourceMapWiring::disabled(),
)
});
bridge
.join()
.expect("the bridge must end on inbound disconnect, not hang");
let first = read_frame(&mut client)
.expect("read flushed frame")
.expect("pending-1 must be flushed on disconnect");
assert_eq!(first["event"], "pending-1");
let second = read_frame(&mut client)
.expect("read flushed frame")
.expect("pending-2 must be flushed on disconnect");
assert_eq!(second["event"], "pending-2");
if let Ok(Some(frame)) = read_frame(&mut client) {
panic!("no further frames expected; got {frame}");
}
}
#[test]
fn toggle_missing_seq_acks_with_request_seq_zero() {
let mut h = Harness::new();
let adapter = shared_adapter();
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx) = mpsc::channel();
let wiring = SourceMapWiring::disabled();
let req = json!({
"type": "request",
"command": "pasta/sourcePresentation",
"arguments": { "mode": "lua" },
}); assert!(handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&req,
&wiring
));
assert_eq!(wiring.source_mode.get(), SourceMode::Lua, "the toggle still applies");
let ack = h.recv();
assert_eq!(ack["command"], "pasta/sourcePresentation");
assert_eq!(ack["request_seq"], 0, "missing seq must degrade to request_seq 0");
assert_eq!(ack["body"]["mode"], "lua");
let ev = h.recv();
assert_eq!(ev["event"], "pasta/sourcePresentation");
assert_eq!(
cmd_rx.recv_timeout(WATCHDOG).expect("RefreshPresentation forwarded"),
SessionCommand::RefreshPresentation
);
}
#[test]
fn toggle_with_session_gone_still_acks_then_reports_stop() {
let mut h = Harness::new();
let adapter = shared_adapter();
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx) = mpsc::channel();
drop(cmd_rx); let wiring = SourceMapWiring::disabled();
let req = json!({
"seq": 5,
"type": "request",
"command": "pasta/sourcePresentation",
"arguments": { "mode": "lua" },
});
assert!(
!handle_inbound(&h.transport, &adapter, &breakpoints, &cmd_tx, &req, &wiring),
"a gone session must stop the bridge (false)"
);
let ack = h.recv();
assert_eq!(ack["command"], "pasta/sourcePresentation");
assert_eq!(ack["request_seq"], 5);
let ev = h.recv();
assert_eq!(ev["event"], "pasta/sourcePresentation");
}
#[test]
fn stop_context_command_with_session_gone_reports_stop_after_ack() {
let mut h = Harness::new();
let adapter = shared_adapter();
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx) = mpsc::channel();
drop(cmd_rx);
let req = json!({ "seq": 3, "type": "request", "command": "continue", "arguments": {} });
assert!(
!handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&req,
&SourceMapWiring::disabled()
),
"a gone session must stop the bridge (false)"
);
let ack = h.recv();
assert_eq!(ack["command"], "continue", "the ack precedes the failed forward");
assert_eq!(ack["request_seq"], 3);
}
#[test]
fn unknown_and_missing_command_are_ignored_and_bridge_keeps_serving() {
let mut h = Harness::new();
let adapter = shared_adapter();
let breakpoints = BreakpointSet::new();
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let wiring = SourceMapWiring::disabled();
let no_command = json!({ "seq": 1, "type": "request" });
assert!(handle_inbound(
&h.transport,
&adapter,
&breakpoints,
&cmd_tx,
&no_command,
&wiring
));
let bogus = json!({ "seq": 2, "type": "request", "command": "bogusCommand" });
assert!(handle_inbound(&h.transport, &adapter, &breakpoints, &cmd_tx, &bogus, &wiring));
assert!(
cmd_rx.try_recv().is_err(),
"ignored requests must forward no session command"
);
let init = json!({ "seq": 3, "type": "request", "command": "initialize", "arguments": {} });
assert!(handle_inbound(&h.transport, &adapter, &breakpoints, &cmd_tx, &init, &wiring));
let resp = h.recv();
assert_eq!(resp["command"], "initialize", "first wire frame is the initialize response");
assert_eq!(resp["request_seq"], 3);
let ev = h.recv();
assert_eq!(ev["event"], "initialized");
}
#[test]
fn poisoned_adapter_never_panics_resolver_attach_and_stops_inbound() {
let h = Harness::new();
let adapter = shared_adapter();
poison(&adapter);
attach_pasta_resolver(&adapter, &SourceMapWiring::disabled());
let active = SourceMapWiring {
source_map: Some(Arc::new(SourceMap::new())),
source_mode: crate::debug::SharedSourceMode::new(SourceMode::Pasta),
};
attach_pasta_resolver(&adapter, &active);
let (cmd_tx, _cmd_rx) = mpsc::channel();
let req = json!({ "seq": 1, "type": "request", "command": "initialize", "arguments": {} });
assert!(
!handle_inbound(
&h.transport,
&adapter,
&BreakpointSet::new(),
&cmd_tx,
&req,
&SourceMapWiring::disabled()
),
"a poisoned adapter must stop the bridge, never panic it"
);
}
}