pasta_lua/debug/mod.rs
1//! Rust-hosted DAP debug backend for pasta_lua (SHIORI-independent).
2//!
3//! This module is the single entry point and enablement gate for the debug
4//! backend. It is host-agnostic: it MUST NOT import `pasta_shiori` (R6).
5//!
6//! # Enablement gate (R5)
7//!
8//! Debugging is opt-in. [`DebugConfig`] is resolved from BOTH the pasta.toml
9//! `[debug]` section ([`DebugFileConfig`]) AND the environment variables
10//! `PASTA_DEBUG` / `PASTA_DEBUG_PORT`. When disabled, the backend is true
11//! zero-cost: [`enable`] returns `Ok(None)`, installs no VM hook, opens no
12//! network port, and never exposes Lua's `debug` / `std_debug` to scripts
13//! (R5.2 / R5.3 / R5.5).
14//!
15//! # Resolution precedence
16//!
17//! - `enabled`: `PASTA_DEBUG` (if set) overrides `[debug] enabled` (default `false`).
18//! - `port`: `PASTA_DEBUG_PORT` (if set) overrides `[debug] port` (default `9276`).
19//! - The listener address is materialised only when `enabled` is true; otherwise
20//! `listen` is `None` so no port is ever opened.
21//!
22//! # Wiring
23//!
24//! [`enable`] is the fully wired entry point: when enabled it installs the VM
25//! line hook, binds the DAP transport listener, and spawns the socket-bridge /
26//! event-encoder threads, returning a [`DebugHandle`] that owns the teardown.
27//! See [`enable`] and [`wiring`] for the thread topology.
28
29use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::mpsc;
32use std::sync::{Arc, Mutex};
33use std::thread::JoinHandle;
34
35use serde_json::Value;
36use thiserror::Error;
37
38pub use crate::loader::{DebugFileConfig, default_debug_port};
39
40use crate::debug::breakpoints::BreakpointSet;
41use crate::debug::dap::DapAdapter;
42use crate::debug::session::DebugSession;
43use crate::debug::transport::Transport;
44// `SessionCommand` / `SessionEvent` are already in scope via the `pub use
45// types::{…}` re-export below; do not re-import them here.
46
47pub(crate) mod breakpoints;
48pub(crate) mod dap;
49pub(crate) mod hook;
50pub(crate) mod inspect;
51pub(crate) mod session;
52pub(crate) mod transport;
53pub(crate) mod wiring;
54pub mod types;
55
56// `.pasta`↔生成 `.lua` ソースマップの consumer 側モジュール。
57// 本仕様 `pasta-source-map` で本番化(常時コンパイル)した(7.3)。
58pub mod source_map;
59pub use types::{
60 Breakpoint, FrameInfo, LineEvent, ResolvedBreakpoint, Scope, SessionCommand, SessionEvent,
61 SourceRef, StopReason, ThreadId, ThreadInfo, Variable,
62};
63
64/// Loopback host the DAP listener binds to when debugging is enabled.
65///
66/// Debugging is local-only by design; the address is never externally routable.
67const LOOPBACK: Ipv4Addr = Ipv4Addr::LOCALHOST;
68
69/// Source presentation mode for the debug session.
70///
71/// Selects whether stop positions, call stacks and breakpoints are presented in
72/// `.pasta` coordinates (via the source map) or in the raw generated `.lua`
73/// coordinates. The default is [`SourceMode::Pasta`] (requirements 6.1). This
74/// field replaces the dead `source_map_slice: bool` reserve removed in task 3.1
75/// (requirements 7.3).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum SourceMode {
78 /// Present in `.pasta` coordinates via the source map. Default (6.1).
79 #[default]
80 Pasta,
81 /// Present in the raw generated `.lua` coordinates (6.2).
82 Lua,
83}
84
85impl SourceMode {
86 /// Parse a case-insensitive string (`"pasta"` / `"lua"`, surrounding
87 /// whitespace ignored) into a [`SourceMode`].
88 ///
89 /// Any other value falls back to the default [`SourceMode::Pasta`] and emits
90 /// a warning (design "Error Categories": 不正な `sourcePresentation` 値 →
91 /// 既定 `pasta` へフォールバック+警告). This keeps an invalid env / file /
92 /// attach value from breaking the session.
93 pub fn parse(raw: &str) -> Self {
94 match raw.trim().to_ascii_lowercase().as_str() {
95 "pasta" => SourceMode::Pasta,
96 "lua" => SourceMode::Lua,
97 other => {
98 tracing::warn!(
99 value = other,
100 "invalid source presentation mode; falling back to default `pasta`"
101 );
102 SourceMode::default()
103 }
104 }
105 }
106
107 /// Encode a [`SourceMode`] as a `u8` for an [`AtomicU8`]-backed shared cell.
108 fn as_u8(self) -> u8 {
109 match self {
110 SourceMode::Pasta => 0,
111 SourceMode::Lua => 1,
112 }
113 }
114
115 /// Decode a `u8` produced by [`as_u8`](Self::as_u8) back to a [`SourceMode`].
116 /// Any unexpected value defaults to [`SourceMode::Pasta`] (6.1).
117 fn from_u8(v: u8) -> Self {
118 match v {
119 1 => SourceMode::Lua,
120 _ => SourceMode::Pasta,
121 }
122 }
123}
124
125/// A shared, interior-mutable EFFECTIVE present mode for one debug session
126/// (task 5.5 / requirements 6.3).
127///
128/// The resolved [`DebugConfig::source_mode`] (env > file > 既定) is baked at
129/// [`enable`] time, BEFORE the DAP `attach` request arrives. When that `attach`
130/// request carries an explicit `sourcePresentation` argument (the HIGHEST
131/// precedence, design 581: `attach > env > file > 既定`), the server must apply
132/// it to the CURRENT session — switching BOTH the `.pasta` source RESOLVER
133/// presentation (task 5.2) AND the `.pasta`-granular STEP granularity
134/// (task 5.4). Those two consumers live on DIFFERENT threads — the resolver on
135/// the socket-bridge thread (it owns the [`DapAdapter`]) and the stepper on the
136/// VM thread (inside the line hook) — so the effective mode is shared here.
137///
138/// Mirrors the established [`BreakpointSet`](crate::debug::breakpoints::BreakpointSet)
139/// pattern (a cheap `Arc` clone of settable-while-running shared state): the
140/// socket-bridge thread WRITES the new mode when the `attach` arg is received,
141/// and the VM-thread stepper READS it per line. An [`AtomicU8`] is sufficient
142/// (the value is `Copy`, a single scalar, with no compound invariant) and needs
143/// no lock on the hot per-line read path.
144///
145/// When the `attach` request carries NO `sourcePresentation`, the cell is left
146/// at the [`enable`]-time resolved mode, so the env > file > default decision
147/// stands (design 581: a client default must NOT override env/file).
148#[derive(Clone, Debug)]
149pub(crate) struct SharedSourceMode {
150 inner: Arc<std::sync::atomic::AtomicU8>,
151}
152
153impl SharedSourceMode {
154 /// Construct a shared cell initialised to `mode` (the [`enable`]-time
155 /// resolved mode).
156 pub(crate) fn new(mode: SourceMode) -> Self {
157 Self {
158 inner: Arc::new(std::sync::atomic::AtomicU8::new(mode.as_u8())),
159 }
160 }
161
162 /// Read the current effective present mode (VM-thread stepper / resolver).
163 pub(crate) fn get(&self) -> SourceMode {
164 SourceMode::from_u8(self.inner.load(Ordering::SeqCst))
165 }
166
167 /// Write a new effective present mode (socket bridge, on `attach`).
168 pub(crate) fn set(&self, mode: SourceMode) {
169 self.inner.store(mode.as_u8(), Ordering::SeqCst);
170 }
171}
172
173/// Runtime-resolved debug configuration and zero-cost gate.
174///
175/// Produced by [`DebugConfig::resolve`] (pure) or the [`DebugConfig::from_env`]
176/// / [`DebugConfig::from_file`] wrappers. When `enabled` is `false`, `listen`
177/// is guaranteed to be `None` (no port is opened — R5.5).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct DebugConfig {
180 /// Whether the debug backend is active.
181 pub enabled: bool,
182
183 /// Listen address for the DAP transport. `None` when disabled (R5.5).
184 pub listen: Option<SocketAddr>,
185
186 /// Source presentation mode. Default [`SourceMode::Pasta`] (6.1).
187 ///
188 /// Composed in [`resolve`](Self::resolve) with precedence
189 /// `DAP attach 引数 > env > pasta.toml [debug] > 既定 Pasta`.
190 pub source_mode: SourceMode,
191
192 /// Whether to additionally write the on-disk `.lua.map` sidecar (3.2).
193 /// Default `false`; the in-memory source map is always the primary path.
194 ///
195 /// Composed in [`resolve`](Self::resolve) with precedence `env > file >
196 /// default` (same convention as `enabled`/`port`).
197 pub source_map_sidecar: bool,
198}
199
200impl Default for DebugConfig {
201 /// The disabled, zero-cost configuration (R5.2 / R5.5).
202 ///
203 /// Equivalent to `DebugConfig::resolve(None, None, None, None, None, None,
204 /// None, None)`: `enabled = false`, `listen = None` (no port is ever opened),
205 /// `source_mode = Pasta` (6.1), `source_map_sidecar = false` (3.2). This lets
206 /// every existing `RuntimeConfig` constructor stay zero-cost by deriving
207 /// `RuntimeConfig::debug` from this default.
208 fn default() -> Self {
209 Self {
210 enabled: false,
211 listen: None,
212 source_mode: SourceMode::Pasta,
213 source_map_sidecar: false,
214 }
215 }
216}
217
218impl DebugConfig {
219 /// Resolve a [`DebugConfig`] from explicit inputs (pure, deterministic).
220 ///
221 /// This is the single resolution point and is unit-testable without a Lua
222 /// VM or process-global environment. Wrappers ([`from_env`](Self::from_env),
223 /// [`from_file`](Self::from_file)) feed it real inputs.
224 ///
225 /// # Arguments
226 /// * `file` - parsed `[debug]` section, if present in pasta.toml.
227 /// * `env_enabled` - `PASTA_DEBUG` parsed to a bool, if the var was set.
228 /// * `env_port` - `PASTA_DEBUG_PORT` parsed to a port, if the var was set.
229 /// * `env_source_mode` - `PASTA_DEBUG_SOURCE_MODE` parsed to a [`SourceMode`],
230 /// if the var was set.
231 /// * `env_sidecar` - `PASTA_DEBUG_SOURCE_MAP_SIDECAR` parsed to a bool, if the
232 /// var was set.
233 /// * `file_source_mode` - the `[debug]` source-presentation mode, if present.
234 /// * `file_sidecar` - the `[debug]` sidecar flag, if present.
235 /// The two `file_*` mode/sidecar values are supplied separately (not via
236 /// [`DebugFileConfig`]) because the pasta.toml loading of these fields lands
237 /// in task 4.4 (`loader/config.rs`); `resolve` only needs to ACCEPT them.
238 /// * `attach_source_mode` - the DAP `attach` `sourcePresentation` override,
239 /// set ONLY when the client explicitly specifies it (task 5.5 plumbing).
240 /// A client default is NOT passed here, so it never overrides env/file.
241 ///
242 /// # Precedence
243 /// - `enabled` / `port`: `env` (when `Some`) beats `file` beats default
244 /// (`enabled = false`, `port = 9276`). (unchanged)
245 /// - `source_mode`: `attach_source_mode` beats `env_source_mode` beats
246 /// `file_source_mode` beats 既定 [`SourceMode::Pasta`] (6.1). The DAP attach
247 /// 引数 wins, then env, then the pasta.toml `[debug]` value, consistent with
248 /// the env>file convention above.
249 /// - `source_map_sidecar`: `env_sidecar` beats `file_sidecar` beats default
250 /// `false` (3.2; same env>file convention).
251 #[allow(clippy::too_many_arguments)]
252 pub fn resolve(
253 file: Option<&DebugFileConfig>,
254 env_enabled: Option<bool>,
255 env_port: Option<u16>,
256 env_source_mode: Option<SourceMode>,
257 env_sidecar: Option<bool>,
258 file_source_mode: Option<SourceMode>,
259 file_sidecar: Option<bool>,
260 attach_source_mode: Option<SourceMode>,
261 ) -> Self {
262 let file_enabled = file.map(|f| f.enabled).unwrap_or(false);
263 let file_port = file.map(|f| f.port).unwrap_or_else(default_debug_port);
264
265 let enabled = env_enabled.unwrap_or(file_enabled);
266 let port = env_port.unwrap_or(file_port);
267
268 // R5.5: only materialise a listen address when actually enabled.
269 let listen = if enabled {
270 Some(SocketAddr::V4(SocketAddrV4::new(LOOPBACK, port)))
271 } else {
272 None
273 };
274
275 // 6.1: source presentation mode. Precedence attach > env > file > Pasta.
276 let source_mode = attach_source_mode
277 .or(env_source_mode)
278 .or(file_source_mode)
279 .unwrap_or_default();
280
281 // 3.2: disk sidecar output. Precedence env > file > false (A1 convention).
282 let source_map_sidecar = env_sidecar.or(file_sidecar).unwrap_or(false);
283
284 Self {
285 enabled,
286 listen,
287 source_mode,
288 source_map_sidecar,
289 }
290 }
291
292 /// Resolve from a file config plus the process environment.
293 ///
294 /// Reads `PASTA_DEBUG` / `PASTA_DEBUG_PORT` / `PASTA_DEBUG_SOURCE_MODE` /
295 /// `PASTA_DEBUG_SOURCE_MAP_SIDECAR` via [`std::env`]. Prefer
296 /// [`resolve`](Self::resolve) in tests to avoid global-env races.
297 ///
298 /// The pasta.toml `[debug]` source-mode (`present_as`) / sidecar
299 /// (`source_map_sidecar`) values are loaded by `loader/config.rs` (task 4.4)
300 /// and SUPPLIED here from `file`: they are fed to [`resolve`](Self::resolve)
301 /// as the `file_*` inputs so the precedence becomes `env > file > 既定`
302 /// (requirements 6.3 / 3.2). No DAP attach override is available at this
303 /// layer (task 5.5).
304 pub fn from_env(file: Option<&DebugFileConfig>) -> Self {
305 let env_enabled = std::env::var("PASTA_DEBUG")
306 .ok()
307 .and_then(|v| parse_env_bool(&v));
308 let env_port = std::env::var("PASTA_DEBUG_PORT")
309 .ok()
310 .and_then(|v| v.trim().parse::<u16>().ok());
311 let env_source_mode = std::env::var("PASTA_DEBUG_SOURCE_MODE")
312 .ok()
313 .map(|v| SourceMode::parse(&v));
314 let env_sidecar = std::env::var("PASTA_DEBUG_SOURCE_MAP_SIDECAR")
315 .ok()
316 .and_then(|v| parse_env_bool(&v));
317 Self::resolve(
318 file,
319 env_enabled,
320 env_port,
321 env_source_mode,
322 env_sidecar,
323 file_source_mode(file), // pasta.toml [debug] present_as (task 4.4)
324 file_sidecar(file), // pasta.toml [debug] source_map_sidecar (task 4.4)
325 None, // no DAP attach override at this layer (task 5.5)
326 )
327 }
328
329 /// Resolve from a file config only, ignoring the environment.
330 ///
331 /// Equivalent to feeding [`resolve`](Self::resolve) the file's
332 /// `present_as`→[`SourceMode`] and `source_map_sidecar` as the `file_*`
333 /// inputs with no env / attach override (precedence `file > 既定`).
334 pub fn from_file(file: Option<&DebugFileConfig>) -> Self {
335 Self::resolve(
336 file,
337 None,
338 None,
339 None,
340 None,
341 file_source_mode(file),
342 file_sidecar(file),
343 None,
344 )
345 }
346}
347
348/// Map a pasta.toml `[debug]` `present_as` string to a [`SourceMode`], if the
349/// key was present. `None` (key omitted) lets env/default decide; an invalid
350/// value is tolerated and parsed back to the default `.pasta` via
351/// [`SourceMode::parse`] (requirements 6.1 / 6.3).
352fn file_source_mode(file: Option<&DebugFileConfig>) -> Option<SourceMode> {
353 file.and_then(|f| f.present_as.as_deref())
354 .map(SourceMode::parse)
355}
356
357/// The pasta.toml `[debug]` `source_map_sidecar` flag, supplied to `resolve`
358/// only when a file config is present (3.2). When no file config is present this
359/// is `None` so the env/default decides.
360fn file_sidecar(file: Option<&DebugFileConfig>) -> Option<bool> {
361 file.map(|f| f.source_map_sidecar)
362}
363
364/// Parse an environment variable value into a boolean.
365///
366/// Truthy: `1`, `true`, `yes`, `on` (case-insensitive, surrounding whitespace
367/// ignored). Falsy: `0`, `false`, `no`, `off`, and the empty string. Any other
368/// value yields `None` (treated as "not specified" by callers).
369fn parse_env_bool(raw: &str) -> Option<bool> {
370 match raw.trim().to_ascii_lowercase().as_str() {
371 "1" | "true" | "yes" | "on" => Some(true),
372 "0" | "false" | "no" | "off" | "" => Some(false),
373 _ => None,
374 }
375}
376
377/// Errors surfaced by the debug backend.
378///
379/// `mlua::Error` is `!Send`; it is stringified at the boundary into [`Vm`]
380/// (or carried as a `SessionEvent::Error` string in later tasks) so debug
381/// state can cross the VM/transport thread boundary.
382///
383/// [`Vm`]: DebugError::Vm
384#[derive(Error, Debug)]
385pub enum DebugError {
386 /// Failed to bind the DAP transport listener (R3.1 / R5.5).
387 #[error("debug transport bind failed: {0}")]
388 Bind(#[source] std::io::Error),
389
390 /// DAP protocol framing or message error.
391 #[error("debug protocol error: {0}")]
392 Protocol(String),
393
394 /// Lua VM / FFI error stringified at the boundary (`mlua::Error` is `!Send`).
395 #[error("debug VM error: {0}")]
396 Vm(String),
397
398 /// The DAP client disconnected.
399 #[error("debug client disconnected")]
400 Disconnected,
401}
402
403/// Owner of the debug backend's bridge threads and shared state (task 4.1 full
404/// wiring).
405///
406/// Constructed by [`enable`] when debugging is active. It holds:
407/// - the bound listen address (read back from the transport so a caller using
408/// port 0 can discover the OS-assigned port),
409/// - a shared shutdown flag and the socket-bridge / event-encoder join handles.
410///
411/// The shared [`BreakpointSet`] is NOT held here: it is owned by the VM-thread
412/// hook (reads) and the socket-bridge thread (writes — settable while running);
413/// the handle needs no clone of it for task 4.1. (Runtime integration, task 4.2,
414/// may surface it on the handle when it actually consumes it.)
415///
416/// The [`Transport`] itself is `!Sync` (it holds a `Receiver`), so it is owned
417/// solely by the socket-bridge thread (see [`wiring`]); the handle never holds
418/// it. The VM-thread line hook (installed by [`enable`] via
419/// [`hook::install`](crate::debug::hook::install)) owns the [`DebugSession`] and
420/// the session ends of the command/event channels; `mlua::Lua` never crosses a
421/// thread (it is `!Send`).
422///
423/// # Teardown (synchronous port release, bounded)
424///
425/// [`Drop`] sets the shared shutdown flag and then SYNCHRONOUSLY JOINS the
426/// socket-bridge thread (task 3.1): the bridge observes the flag within one
427/// `POLL_INTERVAL`, returns, and drops its by-value [`Transport`], whose own
428/// `Drop` joins the `serve()` listener thread — so the listening port is
429/// RELEASED before this `Drop` returns. This makes a SHIORI unload free the
430/// fixed DAP port deterministically before the next reload re-binds it (R1.x /
431/// R2.x). The join is bounded because every downstream blocking point is an
432/// interruptible `POLL_INTERVAL` poll, so teardown cannot hang. The
433/// event-encoder thread (which owns no socket/port) is left DETACHED — joining
434/// it while this `Drop` still holds `terminate_tx` would deadlock. The backend
435/// also winds down naturally when the VM thread finishes Lua execution (the
436/// session's channel ends drop, closing the encoder) or the DAP client
437/// disconnects (the transport closes the inbound channel).
438pub struct DebugHandle {
439 /// Resolved configuration this handle was created from.
440 config: DebugConfig,
441 /// The bound listen address (read from the transport at construction), or
442 /// `None` when no listener was opened.
443 local_addr: Option<SocketAddr>,
444 /// Shared shutdown flag: setting it makes the socket bridge stop and drop
445 /// the transport (non-blocking teardown).
446 shutdown: Arc<AtomicBool>,
447 /// Socket-bridge thread join handle (sole `Transport` owner: reads + writes).
448 socket_handle: Option<JoinHandle<()>>,
449 /// Event-encoder thread join handle (session events → DAP frames).
450 encoder_handle: Option<JoinHandle<()>>,
451 /// A clone of the session's event sender, used SOLELY to emit a final
452 /// [`SessionEvent::Terminated`] on teardown (task 4.2).
453 ///
454 /// In the long-lived SHIORI runtime there is no per-request "execution end":
455 /// the debuggee is the runtime ITSELF, so a per-request `exec()` return must
456 /// NOT terminate the session (R3.5's "execution end" maps to RUNTIME
457 /// TEARDOWN, not request end). On `Drop` we send `Terminated` through this
458 /// clone BEFORE signalling shutdown, so the event-encoder thread can encode a
459 /// DAP `terminated` frame for the socket bridge to flush to any connected
460 /// client (best-effort; the encoder/bridge channels then wind down). The
461 /// existing disconnect→terminated path (the session's `Disconnect` handler)
462 /// remains for the client-initiated case.
463 terminate_tx: mpsc::Sender<SessionEvent>,
464}
465
466impl std::fmt::Debug for DebugHandle {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 f.debug_struct("DebugHandle")
469 .field("config", &self.config)
470 .field("local_addr", &self.local_addr)
471 .finish_non_exhaustive()
472 }
473}
474
475impl DebugHandle {
476 /// The resolved [`DebugConfig`] this handle owns.
477 pub fn config(&self) -> &DebugConfig {
478 &self.config
479 }
480
481 /// The bound DAP listen address, or `None` when no listener is active
482 /// (R3.1: the OS-assigned port is read back from the transport so a caller
483 /// using port 0 can discover the concrete bound port).
484 pub fn local_addr(&self) -> Option<SocketAddr> {
485 self.local_addr
486 }
487}
488
489impl Drop for DebugHandle {
490 fn drop(&mut self) {
491 // (1) Natural-end `terminated` (task 4.2): emit a final `Terminated`
492 // session event so the event-encoder thread encodes a DAP `terminated`
493 // frame and the socket bridge flushes it to any connected client. In the
494 // long-lived SHIORI runtime the "execution end" of R3.5 is RUNTIME
495 // TEARDOWN (this Drop), NOT a per-request `exec()` return — the debuggee
496 // is the runtime itself, so per-request returns deliberately do not
497 // terminate the session. A send failure (encoder already gone) is ignored.
498 let _ = self.terminate_tx.send(SessionEvent::Terminated);
499
500 // (2) Give the encoder + socket bridge a brief, BOUNDED window to encode
501 // and flush that frame before we tear the bridge down. The socket bridge
502 // polls/drains every `wiring::POLL_INTERVAL` (5ms); a few intervals is
503 // enough for the `Terminated` frame to traverse encoder → out channel →
504 // socket while staying effectively non-blocking for teardown.
505 std::thread::sleep(std::time::Duration::from_millis(30));
506
507 // (3) Synchronous teardown (task 3.1, R1.1/R1.2/R1.3/R2.1/R2.2): signal
508 // the socket bridge to stop FIRST (it observes the flag within one
509 // `POLL_INTERVAL`, returns, and drops the `Transport`), THEN JOIN it. The
510 // flag MUST be set before the join, otherwise the join would wait on a
511 // bridge that was never told to stop. Joining the socket bridge waits for
512 // `run_socket_bridge` to return → the by-value `Transport` is dropped →
513 // `Transport::drop` synchronously joins its `serve()` listener thread
514 // (tasks 2.1-2.4) → the listening port is RELEASED before this `drop`
515 // returns. The join is bounded: every blocking point downstream is an
516 // interruptible `POLL_INTERVAL` poll, so this never hangs (a hang would
517 // wedge the test; production teardown is watchdog-free by design).
518 self.shutdown.store(true, Ordering::SeqCst);
519 if let Some(h) = self.socket_handle.take() {
520 // Synchronous JOIN: blocks until the bridge returns → Transport drop →
521 // serve join → port freed. A panicked bridge yields `Err`; ignore it,
522 // teardown still completed (the thread is no longer running).
523 let _ = h.join();
524 }
525
526 // The event-encoder owns no socket / port, so joining it is unnecessary
527 // for releasing the port. Keep it DETACHED: this `Drop` still holds
528 // `terminate_tx` (a `Sender` clone of the encoder's `event_rx`), so the
529 // encoder cannot observe channel disconnect and exit until AFTER this
530 // method returns and drops `terminate_tx`; joining it here would deadlock.
531 let _ = self.encoder_handle.take();
532 }
533}
534
535/// Enable the debug backend for `lua` according to `cfg` (task 4.1 full wiring).
536///
537/// - When `cfg.enabled == false`: returns `Ok(None)`. No VM hook is installed,
538/// no port is opened, no thread is spawned, and `std_debug` is NOT exposed to
539/// scripts. This is the true zero-cost path (R5.2 / R5.3 / R5.5).
540/// - When `cfg.enabled == true`: builds a FULLY WIRED backend and returns
541/// `Ok(Some(DebugHandle))`:
542/// 1. a shared [`BreakpointSet`] (settable while the VM runs),
543/// 2. a [`DebugSession`] over the VM-thread ends of the command/event
544/// channels, installed into the line hook via
545/// [`hook::install`](crate::debug::hook::install) (engine-wide `jit.off()` +
546/// a coroutine-crossing `EVERY_LINE` hook) — this is the VM-thread stop
547/// core; inspect/step/continue are processed in its hook loop ON THIS
548/// THREAD (the `mlua::Lua` never crosses a thread, R6 / `!Send`),
549/// 3. a [`Transport`] bound to `cfg.listen` (the OS-assigned port is readable
550/// via [`DebugHandle::local_addr`] when `listen` uses port 0),
551/// 4. a shared [`DapAdapter`] and two bridge threads connecting the transport
552/// to the session (see [`wiring`] for the thread topology).
553///
554/// # Thread topology (design "Architecture" / "System Flows")
555///
556/// One VM host thread (the caller, owns `mlua::Lua` and the session in the hook) +
557/// one socket-bridge thread (sole [`Transport`] owner: multiplexes inbound
558/// socket reads and outbound socket writes, since `Transport` is `!Sync` and
559/// `mpsc` has no `select`) + one event-encoder thread (session events → DAP
560/// frames). The socket bridge and encoder share the [`DapAdapter`] behind an
561/// `Arc<Mutex<…>>` (its `seq` + correlation table). See [`wiring`] for the full
562/// topology and the inbound-poll / outbound-frame-channel structure.
563///
564/// # SHIORI independence (R6)
565///
566/// This function does not import or reference `pasta_shiori`; any pasta host (or
567/// a test harness) drives it directly.
568///
569/// # Preconditions
570/// `lua` must already be constructed on the VM thread.
571///
572/// # Source map injection (task 4.2 — `pasta-source-map`)
573///
574/// `source_map` is the OPTIONAL immutable shared `.pasta`↔`.lua` map (design
575/// "Architecture": `Arc<SourceMap>` 不変共有). Together with `cfg.source_mode`
576/// (task 4.1) it is threaded to the three `.pasta` CONSUMERS — the DAP source
577/// resolver (task 5.2), the breakpoint translator (task 5.3) and the stepper
578/// (task 5.4) — via this injection path: `enable → wiring → DebugSession`
579/// (design 548). The map+mode REACH those points only when BOTH a map is
580/// supplied AND `cfg.source_mode == SourceMode::Pasta` (design 582, requirements
581/// 6.1); for `None` or [`SourceMode::Lua`] every consumer keeps its existing
582/// default `.lua` behavior byte-for-byte (requirements 6.2 / 7.2). This task
583/// wires the SKELETON only — the consumer LOGIC is tasks 5.x.
584///
585/// # Errors
586/// [`DebugError::Bind`] if the DAP listener fails to bind; [`DebugError::Vm`] if
587/// the hook install fails (`mlua::Error` is stringified at the boundary, it is
588/// `!Send`). The disabled path never errors.
589pub fn enable(
590 lua: &mlua::Lua,
591 cfg: &DebugConfig,
592 source_map: Option<Arc<source_map::SourceMap>>,
593) -> Result<Option<DebugHandle>, DebugError> {
594 if !cfg.enabled {
595 // Zero-cost disabled path (R5.2 / R5.3 / R5.5): no hook, no port, no
596 // thread, no std_debug exposure. Leave `lua` untouched. The `source_map`
597 // (if any) is simply dropped here — the disabled gate never consumes it.
598 return Ok(None);
599 }
600
601 // Effective present-mode cell (task 5.5 / requirement 6.3): initialise the
602 // SHARED, interior-mutable mode from the resolved `cfg.source_mode` (env >
603 // file > 既定). The socket bridge flips it when a DAP `attach`
604 // `sourcePresentation` arrives (highest precedence, design 581); the resolver
605 // (task 5.2) and the VM-thread stepper (task 5.4) both read it, so an `attach`
606 // switches BOTH for this session. One clone goes to the wiring, one to the
607 // session.
608 let shared_mode = SharedSourceMode::new(cfg.source_mode);
609
610 // Gating (design 582, requirements 6.1 / 6.2 / 6.3): the `.pasta` consumers
611 // (resolver / BP translator / stepper) are reached only when a map is supplied
612 // AND the EFFECTIVE mode is `SourceMode::Pasta`. The mode part is now decided
613 // at CONSUMPTION time (`pasta_active()` reads the shared cell) rather than
614 // frozen here, because a DAP `attach` `sourcePresentation` can flip the mode
615 // AFTER `enable` (it arrives later) — including Lua→Pasta, which needs the map
616 // available. So the map is ALWAYS threaded when supplied; the per-consumption
617 // `pasta_active()` gate (map present AND effective mode Pasta) keeps `None`/
618 // `Lua`/no-attach paths byte-for-byte (7.2). Cloning the `Arc` is a refcount
619 // bump (immutable shared map).
620 let source_map_wiring = wiring::SourceMapWiring {
621 source_map: source_map.clone(),
622 source_mode: shared_mode.clone(),
623 };
624
625 // (1) Shared breakpoint store: one clone goes to the VM-thread hook (reads),
626 // one clone to the handle / socket bridge (writes — settable while running).
627 let breakpoints = BreakpointSet::new();
628
629 // (2) Channel seam (the ONLY thing that crosses the VM/transport boundary):
630 // cmd: controller (socket bridge) → session (VM thread)
631 // event: session (VM thread) → controller (event encoder)
632 let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
633 let (event_tx, event_rx) = mpsc::channel::<SessionEvent>();
634
635 // A clone of the event sender for the handle's teardown `terminated` (task
636 // 4.2). The session keeps the original `event_tx`; this clone outlives the VM
637 // thread (it lives on the handle) so `Drop` can emit `Terminated` even after
638 // the VM has finished executing — the event-encoder thread stays alive as long
639 // as ANY `Sender` (this clone) is held, then winds down when the handle drops.
640 let terminate_tx = event_tx.clone();
641
642 // (3) The stop core: a DebugSession over the VM-thread channel ends, plugged
643 // into the line hook. install() applies engine-wide jit.off() and registers
644 // the coroutine-crossing EVERY_LINE hook (R1.7 / R5.2). The session is moved
645 // INTO the hook closure and thereafter lives on this VM thread inside `lua`.
646 // The session is the STEPPER consumer (task 5.4 / 5.5): thread the map plus
647 // the SHARED effective mode into it. The map is threaded whenever supplied
648 // (the `effective_mode == Pasta` gate is applied per line via
649 // `resolve_current_pasta`), so a DAP `attach` Lua→Pasta flip can activate
650 // `.pasta` stepping; `with_shared_mode` lets the socket-bridge `attach` flip
651 // be observed here. With no map / `Lua` effective mode the session keeps its
652 // default `.lua` granularity (7.2). The baked `source_mode` is the `attach`-
653 // absent fallback (matches the env > file > 既定 resolution).
654 let session = DebugSession::new(breakpoints.clone(), cmd_rx, event_tx)
655 .with_source_map(source_map.clone(), cfg.source_mode)
656 .with_shared_mode(Some(shared_mode.clone()));
657 crate::debug::hook::install(lua, session).map_err(|e| DebugError::Vm(e.to_string()))?;
658
659 // (4) I/O side: bind the transport (None → no port; Some → bind + accept one
660 // client). A bind failure surfaces as DebugError::Bind (R3.1 / R5.5). The
661 // bound addr is read NOW and stored in the handle, because the transport is
662 // moved into the socket-bridge thread (it is `!Sync`, single-owner).
663 let transport = Transport::start(cfg.listen).map_err(|e| {
664 // 2.1/2.3 (failure warn): name the attempted bind addr + io cause, then
665 // propagate `DebugError::Bind` unchanged. `cfg.listen` is `Option`, so
666 // bind it (the enabled gate guarantees `Some` — R5.5 only materialises a
667 // listen addr when enabled) before applying `%` (Display).
668 let Some(listen) = cfg.listen else {
669 unreachable!("enabled => cfg.listen is Some (R5.5)")
670 };
671 tracing::warn!(addr = %listen, error = %e, "debug transport bind failed");
672 e
673 })?;
674 let local_addr = transport.local_addr();
675
676 // 1.1/1.3/1.4/1.5 (success info): one line carrying the REAL bound loopback
677 // addr (`local_addr()`'s `Some`, defensively matched). On port 0 this is the
678 // OS-assigned port read back from the transport.
679 if let Some(addr) = local_addr {
680 tracing::info!(addr = %addr, "debug backend listening");
681 }
682
683 // (5) Shared DAP adapter (seq counter + per-kind FIFO request correlation),
684 // mutated by BOTH the socket bridge and the event encoder → Arc<Mutex<…>>.
685 let adapter: wiring::SharedAdapter = Arc::new(Mutex::new(DapAdapter::new()));
686
687 // (6) Encoded-frame channel: the event encoder produces DAP frames; the
688 // socket bridge (sole Transport owner) writes them to the socket.
689 let (out_tx, out_rx) = mpsc::channel::<Value>();
690
691 // (7) Shared shutdown flag (non-blocking teardown via the handle's Drop).
692 let shutdown = Arc::new(AtomicBool::new(false));
693
694 // (8) Socket-bridge thread: owns the Transport; multiplexes inbound decode
695 // (reply / apply setBreakpoints / forward stop-context commands) and
696 // outbound frame writes.
697 let socket_handle = {
698 let adapter = Arc::clone(&adapter);
699 let breakpoints = breakpoints.clone();
700 let shutdown = Arc::clone(&shutdown);
701 // The socket bridge owns the DapAdapter (source RESOLVER attach point,
702 // task 5.2) and applies setBreakpoints (BP TRANSLATION attach point, task
703 // 5.3): deliver the gated map+mode there too (task 4.2 plumbing).
704 let source_map_wiring = source_map_wiring.clone();
705 std::thread::spawn(move || {
706 wiring::run_socket_bridge(
707 transport,
708 adapter,
709 breakpoints,
710 cmd_tx,
711 out_rx,
712 shutdown,
713 source_map_wiring,
714 );
715 })
716 };
717
718 // (9) Event-encoder thread: session events → DAP frames → out_tx.
719 let encoder_handle = {
720 let adapter = Arc::clone(&adapter);
721 std::thread::spawn(move || {
722 wiring::run_event_encoder(adapter, event_rx, out_tx);
723 })
724 };
725
726 Ok(Some(DebugHandle {
727 config: cfg.clone(),
728 local_addr,
729 shutdown,
730 socket_handle: Some(socket_handle),
731 encoder_handle: Some(encoder_handle),
732 terminate_tx,
733 }))
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 // --- Resolution: pure, deterministic (no global env, no Lua VM) ---
741
742 #[test]
743 fn disabled_by_default_no_inputs() {
744 let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
745 assert!(!cfg.enabled, "default must be disabled");
746 assert!(cfg.listen.is_none(), "disabled => no listen address (R5.5)");
747 }
748
749 #[test]
750 fn disabled_when_file_enabled_false() {
751 let file = DebugFileConfig {
752 enabled: false,
753 port: 9276,
754 ..Default::default()
755 };
756 let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
757 assert!(!cfg.enabled);
758 assert!(cfg.listen.is_none());
759 }
760
761 #[test]
762 fn enabled_via_file_default_port() {
763 let file = DebugFileConfig {
764 enabled: true,
765 port: 9276,
766 ..Default::default()
767 };
768 let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
769 assert!(cfg.enabled);
770 assert_eq!(
771 cfg.listen,
772 Some("127.0.0.1:9276".parse().unwrap()),
773 "enabled => listen 127.0.0.1:<port> (default 9276)"
774 );
775 }
776
777 #[test]
778 fn enabled_via_env_when_no_file() {
779 let cfg = DebugConfig::resolve(None, Some(true), None, None, None, None, None, None);
780 assert!(cfg.enabled);
781 assert_eq!(cfg.listen, Some("127.0.0.1:9276".parse().unwrap()));
782 }
783
784 #[test]
785 fn file_port_overrides_default() {
786 let file = DebugFileConfig {
787 enabled: true,
788 port: 5000,
789 ..Default::default()
790 };
791 let cfg = DebugConfig::resolve(Some(&file), None, None, None, None, None, None, None);
792 assert_eq!(cfg.listen, Some("127.0.0.1:5000".parse().unwrap()));
793 }
794
795 #[test]
796 fn env_port_overrides_file_port() {
797 let file = DebugFileConfig {
798 enabled: true,
799 port: 5000,
800 ..Default::default()
801 };
802 let cfg = DebugConfig::resolve(Some(&file), None, Some(7000), None, None, None, None, None);
803 assert_eq!(
804 cfg.listen,
805 Some("127.0.0.1:7000".parse().unwrap()),
806 "PASTA_DEBUG_PORT overrides [debug] port"
807 );
808 }
809
810 #[test]
811 fn env_enabled_overrides_file_disabled() {
812 let file = DebugFileConfig {
813 enabled: false,
814 port: 9276,
815 ..Default::default()
816 };
817 let cfg = DebugConfig::resolve(Some(&file), Some(true), None, None, None, None, None, None);
818 assert!(cfg.enabled, "PASTA_DEBUG truthy overrides [debug] enabled=false");
819 assert_eq!(cfg.listen, Some("127.0.0.1:9276".parse().unwrap()));
820 }
821
822 #[test]
823 fn env_disabled_overrides_file_enabled() {
824 let file = DebugFileConfig {
825 enabled: true,
826 port: 9276,
827 ..Default::default()
828 };
829 let cfg = DebugConfig::resolve(Some(&file), Some(false), None, None, None, None, None, None);
830 assert!(!cfg.enabled, "explicit PASTA_DEBUG=false overrides [debug] enabled=true");
831 assert!(cfg.listen.is_none());
832 }
833
834 #[test]
835 fn env_port_only_without_enable_stays_disabled() {
836 // Setting a port but never enabling must NOT open anything.
837 let cfg = DebugConfig::resolve(None, None, Some(7000), None, None, None, None, None);
838 assert!(!cfg.enabled);
839 assert!(cfg.listen.is_none());
840 }
841
842 #[test]
843 fn parse_truthy_env_values() {
844 for v in ["1", "true", "TRUE", "yes", "on", " on "] {
845 assert_eq!(parse_env_bool(v), Some(true), "{v:?} should be truthy");
846 }
847 for v in ["0", "false", "no", "off", ""] {
848 assert_eq!(parse_env_bool(v), Some(false), "{v:?} should be falsy");
849 }
850 assert_eq!(parse_env_bool("garbage"), None);
851 }
852
853 // --- enable() gate ---
854
855 #[tracing_test::traced_test]
856 #[test]
857 fn enable_disabled_returns_none_and_no_trace() {
858 let lua = mlua::Lua::new();
859 let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
860 let handle = enable(&lua, &cfg, None).expect("enable must not error when disabled");
861 assert!(handle.is_none(), "disabled enable() returns Ok(None) (R5.2)");
862
863 // No std_debug exposure as a side effect of the disabled gate (R5.3).
864 let debug_is_nil: bool = lua
865 .load("return debug == nil")
866 .eval()
867 .expect("eval should succeed");
868 assert!(debug_is_nil, "disabled gate must not expose std_debug");
869
870 // 3.1 (無効時は無言): the disabled gate is the true zero-cost path — it
871 // opens no port and binds nothing, so NEITHER the success `info` NOR the
872 // failure `warn` must ever be emitted. Verifying both negatives here makes
873 // the previously-unchecked "no_trace" name effective and completes the
874 // output/no-output matrix (design Testing Strategy item 2).
875 assert!(
876 !logs_contain("debug backend listening"),
877 "disabled enable() must emit no listening info (3.1)"
878 );
879 assert!(
880 !logs_contain("debug transport bind failed"),
881 "disabled enable() must emit no bind-failure warn (3.1)"
882 );
883 }
884
885 #[test]
886 fn enable_enabled_returns_handle() {
887 // ALL_SAFE VM so the hook's engine-wide `jit.off()` is callable (the
888 // backend now installs a real hook). Port 0 → OS-assigned free loopback
889 // port so the test never clashes with a fixed port across parallel runs.
890 let lua = unsafe {
891 mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
892 };
893 let cfg = DebugConfig {
894 enabled: true,
895 listen: Some("127.0.0.1:0".parse().unwrap()),
896 ..Default::default()
897 };
898 let handle = enable(&lua, &cfg, None).expect("enable must succeed when enabled");
899 let handle = handle.expect("enabled enable() returns Ok(Some(DebugHandle))");
900
901 // The handle echoes the config it was built from.
902 assert_eq!(handle.config().listen, cfg.listen);
903
904 // The transport bound a concrete loopback port (R3.1): readable back even
905 // though the request used port 0.
906 let addr = handle
907 .local_addr()
908 .expect("enabled handle must expose a bound addr (R3.1)");
909 assert_eq!(addr.ip().to_string(), "127.0.0.1");
910 assert_ne!(addr.port(), 0, "OS must assign a concrete port");
911
912 // The hook was installed: engine-wide jit.off() took effect (R5.2/R5.4).
913 let jit_off: bool = lua
914 .load("return (jit.status() == false)")
915 .eval()
916 .expect("jit.status() must be callable on an ALL_SAFE VM");
917 assert!(jit_off, "enable must install the hook and apply engine-wide jit.off()");
918
919 // Dropping the handle tears the backend down without hanging.
920 drop(handle);
921 lua.remove_global_hook();
922 }
923
924 #[test]
925 fn unload_synchronously_frees_port_for_plain_rebind() {
926 // R1.1/R1.2/R1.3/R2.1/R2.2: `DebugHandle::drop` must JOIN the socket
927 // bridge (not detach), so the bridge returns → `Transport` drops →
928 // `serve()` join releases the listening port BEFORE drop returns. We
929 // prove the port is freed synchronously by immediately re-binding it with
930 // a PLAIN `TcpListener::bind` (NO SO_REUSEADDR / NO socket2) — a masking
931 // -aware rebind. With the pre-3.1 detached bridge, drop returns while the
932 // bridge is still winding down asynchronously, so this plain rebind races
933 // the still-open listener and fails with AddrInUse (10048 on Windows).
934 let lua = unsafe {
935 mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
936 };
937 let cfg = DebugConfig {
938 enabled: true,
939 listen: Some("127.0.0.1:0".parse().unwrap()),
940 ..Default::default()
941 };
942 let handle = enable(&lua, &cfg, None)
943 .expect("enable must succeed when enabled")
944 .expect("enabled enable() returns Ok(Some(DebugHandle))");
945
946 // The OS-assigned loopback port the backend is listening on. NO client
947 // connects — the serve listener is parked in its interruptible accept.
948 let port = handle
949 .local_addr()
950 .expect("enabled handle must expose a bound addr (R3.1)")
951 .port();
952 assert_ne!(port, 0, "OS must assign a concrete port");
953
954 // Synchronous teardown: drop must block until the bridge joins → Transport
955 // drops → serve join → listener dropped → port released.
956 drop(handle);
957
958 // Immediately rebind the SAME port with a PLAIN listener (no SO_REUSEADDR).
959 // This succeeds only if the previous listener was fully released by the
960 // time `drop` returned — i.e. teardown was synchronous (R2.1/R2.2).
961 let rebind = std::net::TcpListener::bind(("127.0.0.1", port));
962 assert!(
963 rebind.is_ok(),
964 "plain rebind of port {port} must succeed after synchronous unload \
965 (got {:?}); a failure proves the listener was still open (detached \
966 teardown / AddrInUse 10048)",
967 rebind.as_ref().err()
968 );
969 drop(rebind);
970
971 lua.remove_global_hook();
972 }
973
974 #[test]
975 fn enable_bind_failure_surfaces_debug_error_bind() {
976 // Occupy a concrete loopback port so the backend's bind must fail.
977 let blocker =
978 std::net::TcpListener::bind("127.0.0.1:0").expect("test listener must bind");
979 let taken = blocker.local_addr().expect("bound addr");
980
981 // ALL_SAFE VM: the hook (installed BEFORE the transport bind) needs jit.
982 let lua = unsafe {
983 mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
984 };
985 let cfg = DebugConfig {
986 enabled: true,
987 listen: Some(taken),
988 ..Default::default()
989 };
990
991 // R3.1 / R5.5: a bind failure is surfaced as DebugError::Bind, not a
992 // panic and not a silently disabled backend.
993 let err = enable(&lua, &cfg, None).expect_err("bind to an occupied port must fail");
994 assert!(
995 matches!(err, DebugError::Bind(_)),
996 "expected DebugError::Bind, got: {err:?}"
997 );
998 assert!(
999 format!("{err}").to_lowercase().contains("bind"),
1000 "Bind display names the failure: {err}"
1001 );
1002
1003 // Clean up the hook the failed enable() left installed (the install
1004 // step precedes the bind; the test VM is dropped right after anyway).
1005 lua.remove_global_hook();
1006 drop(blocker);
1007 }
1008
1009 // --- enable() startup logging (task 1.1 / requirements 1, 2, 3) ---
1010
1011 #[tracing_test::traced_test]
1012 #[test]
1013 fn enable_enabled_emits_listening_info() {
1014 // 1.1/1.3/1.4: enabling the backend emits a single `info` carrying the
1015 // real bound loopback addr. ALL_SAFE so the hook's `jit.off()` works;
1016 // port 0 → OS-assigned free loopback port (env-independent, no clash).
1017 let lua = unsafe {
1018 mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
1019 };
1020 let cfg = DebugConfig {
1021 enabled: true,
1022 listen: Some("127.0.0.1:0".parse().unwrap()),
1023 ..Default::default()
1024 };
1025 let handle = enable(&lua, &cfg, None)
1026 .expect("enable must succeed when enabled")
1027 .expect("enabled enable() returns Some(handle)");
1028
1029 // The fixed identifying message is emitted (1.3) at `info` (1.2)...
1030 assert!(
1031 logs_contain("debug backend listening"),
1032 "enable() must emit the listening info (1.1/1.3)"
1033 );
1034 // ...and carries the real bound loopback host:port (1.4/1.5).
1035 let port = handle.local_addr().expect("bound addr").port();
1036 assert!(
1037 logs_contain(&format!("addr=127.0.0.1:{port}")),
1038 "listening info must carry the real bound addr (1.4/1.5)"
1039 );
1040
1041 drop(handle);
1042 lua.remove_global_hook();
1043 }
1044
1045 #[tracing_test::traced_test]
1046 #[test]
1047 fn enable_bind_failure_emits_warn_and_no_info() {
1048 // 2.1/2.2/2.3: a bind failure emits a `warn` naming the attempted addr,
1049 // and NO listening `info` is emitted. Occupy a concrete loopback port so
1050 // the backend's bind must fail.
1051 let blocker =
1052 std::net::TcpListener::bind("127.0.0.1:0").expect("test listener must bind");
1053 let taken = blocker.local_addr().expect("bound addr");
1054
1055 let lua = unsafe {
1056 mlua::Lua::unsafe_new_with(mlua::StdLib::ALL_SAFE, mlua::LuaOptions::default())
1057 };
1058 let cfg = DebugConfig {
1059 enabled: true,
1060 listen: Some(taken),
1061 ..Default::default()
1062 };
1063
1064 let err = enable(&lua, &cfg, None).expect_err("bind to an occupied port must fail");
1065 assert!(matches!(err, DebugError::Bind(_)), "expected Bind, got {err:?}");
1066
1067 // 2.1/2.3: a warn names the bind failure...
1068 assert!(
1069 logs_contain("debug transport bind failed"),
1070 "bind failure must emit a warn (2.1)"
1071 );
1072 // 2.2: ...and no listening info is emitted on the failure path.
1073 assert!(
1074 !logs_contain("debug backend listening"),
1075 "no listening info must be emitted when the bind fails (2.2)"
1076 );
1077
1078 lua.remove_global_hook();
1079 drop(blocker);
1080 }
1081
1082 // --- SharedSourceMode: shared effective-mode cell (task 5.5 / 6.3) ---
1083
1084 #[test]
1085 fn shared_source_mode_get_set_round_trip() {
1086 // The cell is initialised to the enable-time resolved mode...
1087 let cell = SharedSourceMode::new(SourceMode::Pasta);
1088 assert_eq!(cell.get(), SourceMode::Pasta);
1089
1090 // ...a clone shares the SAME underlying cell (Arc semantics: the
1091 // socket-bridge writer and the VM-thread reader observe one value)...
1092 let reader = cell.clone();
1093 cell.set(SourceMode::Lua);
1094 assert_eq!(reader.get(), SourceMode::Lua, "clone observes the write");
1095
1096 // ...and the flip is reversible (attach can switch Lua→Pasta too).
1097 cell.set(SourceMode::Pasta);
1098 assert_eq!(reader.get(), SourceMode::Pasta);
1099 }
1100
1101 #[test]
1102 fn source_mode_u8_codec_round_trips_and_defends_unknown() {
1103 // as_u8 / from_u8 round-trip for both variants.
1104 assert_eq!(SourceMode::from_u8(SourceMode::Pasta.as_u8()), SourceMode::Pasta);
1105 assert_eq!(SourceMode::from_u8(SourceMode::Lua.as_u8()), SourceMode::Lua);
1106 // Defensive default: any unknown byte decodes to Pasta (6.1).
1107 assert_eq!(SourceMode::from_u8(42), SourceMode::Pasta);
1108 assert_eq!(SourceMode::from_u8(u8::MAX), SourceMode::Pasta);
1109 }
1110
1111 // --- file_source_mode: invalid [debug] present_as tolerated (6.1/6.3) ---
1112
1113 #[test]
1114 fn from_file_invalid_present_as_falls_back_to_pasta() {
1115 // An invalid pasta.toml `present_as` value must not break resolution:
1116 // it parses back to the default `.pasta` (design Error line 615).
1117 let file = DebugFileConfig {
1118 present_as: Some("garbage".to_string()),
1119 ..Default::default()
1120 };
1121 let cfg = DebugConfig::from_file(Some(&file));
1122 assert_eq!(
1123 cfg.source_mode,
1124 SourceMode::Pasta,
1125 "invalid present_as tolerated → default .pasta"
1126 );
1127 }
1128
1129 // --- DebugFileConfig serde defaults ---
1130
1131 #[test]
1132 fn file_config_defaults() {
1133 let parsed: DebugFileConfig = toml::from_str("").unwrap();
1134 assert!(!parsed.enabled, "default enabled=false");
1135 assert_eq!(parsed.port, 9276, "default port=9276");
1136 }
1137
1138 #[test]
1139 fn file_config_parses_section() {
1140 let parsed: DebugFileConfig =
1141 toml::from_str("enabled = true\nport = 1234").unwrap();
1142 assert!(parsed.enabled);
1143 assert_eq!(parsed.port, 1234);
1144 }
1145
1146 // --- DebugError discriminants ---
1147
1148 #[test]
1149 fn debug_error_variants_display() {
1150 let bind = DebugError::Bind(std::io::Error::new(
1151 std::io::ErrorKind::AddrInUse,
1152 "in use",
1153 ));
1154 assert!(format!("{bind}").to_lowercase().contains("bind"));
1155 let proto = DebugError::Protocol("bad frame".into());
1156 assert!(format!("{proto}").contains("bad frame"));
1157 let vm = DebugError::Vm("lua boom".into());
1158 assert!(format!("{vm}").contains("lua boom"));
1159 let disc = DebugError::Disconnected;
1160 assert!(!format!("{disc}").is_empty());
1161 }
1162
1163 // --- SourceMode: default + string parse (6.1, design Error line 615) ---
1164
1165 #[test]
1166 fn source_mode_default_is_pasta() {
1167 // 6.1: 既定の提示モードは `.pasta`。
1168 assert_eq!(SourceMode::default(), SourceMode::Pasta);
1169 }
1170
1171 #[test]
1172 fn source_mode_parse_case_insensitive() {
1173 assert_eq!(SourceMode::parse("pasta"), SourceMode::Pasta);
1174 assert_eq!(SourceMode::parse("lua"), SourceMode::Lua);
1175 assert_eq!(SourceMode::parse("PASTA"), SourceMode::Pasta);
1176 assert_eq!(SourceMode::parse("Lua"), SourceMode::Lua);
1177 assert_eq!(SourceMode::parse(" pasta "), SourceMode::Pasta);
1178 }
1179
1180 #[test]
1181 fn source_mode_parse_invalid_falls_back_to_pasta() {
1182 // design Error line 615: 不正な値 → 既定 `pasta` へフォールバック。
1183 assert_eq!(SourceMode::parse("garbage"), SourceMode::Pasta);
1184 assert_eq!(SourceMode::parse(""), SourceMode::Pasta);
1185 }
1186
1187 // --- DebugConfig: new field defaults (6.1, 3.2) ---
1188 //
1189 // resolve signature:
1190 // resolve(file, env_enabled, env_port,
1191 // env_source_mode, env_sidecar,
1192 // file_source_mode, file_sidecar, attach_source_mode)
1193
1194 #[test]
1195 fn default_source_mode_is_pasta_and_sidecar_false() {
1196 // 6.1: 既定 source_mode == Pasta; 3.2: 既定 sidecar == false.
1197 let cfg = DebugConfig::resolve(None, None, None, None, None, None, None, None);
1198 assert_eq!(cfg.source_mode, SourceMode::Pasta, "6.1: default present mode is .pasta");
1199 assert!(!cfg.source_map_sidecar, "3.2: sidecar disabled by default");
1200
1201 // The struct Default mirrors the no-input resolve (zero-cost config).
1202 let d = DebugConfig::default();
1203 assert_eq!(d.source_mode, SourceMode::Pasta);
1204 assert!(!d.source_map_sidecar);
1205 }
1206
1207 // --- DebugConfig::resolve: source_mode precedence attach > env > file > default ---
1208
1209 #[test]
1210 fn source_mode_file_overrides_default() {
1211 // file Lua, no env, no attach => Lua (file beats default Pasta).
1212 let cfg = DebugConfig::resolve(
1213 None,
1214 None,
1215 None,
1216 None, // env source_mode
1217 None, // env sidecar
1218 Some(SourceMode::Lua), // file source_mode
1219 None, // file sidecar
1220 None, // attach source_mode
1221 );
1222 assert_eq!(cfg.source_mode, SourceMode::Lua, "file overrides default");
1223 }
1224
1225 #[test]
1226 fn source_mode_env_overrides_file() {
1227 // file Pasta, env Lua => Lua (env beats file), matching enabled/port env>file.
1228 let cfg = DebugConfig::resolve(
1229 None,
1230 None,
1231 None,
1232 Some(SourceMode::Lua), // env source_mode
1233 None, // env sidecar
1234 Some(SourceMode::Pasta), // file source_mode
1235 None, // file sidecar
1236 None, // attach
1237 );
1238 assert_eq!(cfg.source_mode, SourceMode::Lua, "env overrides file");
1239 }
1240
1241 #[test]
1242 fn source_mode_attach_overrides_env() {
1243 // attach Lua beats env Pasta beats file Pasta (DAP attach 引数 > env > file).
1244 let cfg = DebugConfig::resolve(
1245 None,
1246 None,
1247 None,
1248 Some(SourceMode::Pasta), // env
1249 None, // env sidecar
1250 Some(SourceMode::Pasta), // file
1251 None, // file sidecar
1252 Some(SourceMode::Lua), // attach
1253 );
1254 assert_eq!(cfg.source_mode, SourceMode::Lua, "attach overrides env");
1255 }
1256
1257 // --- DebugConfig::resolve: source_map_sidecar precedence env > file > default ---
1258
1259 #[test]
1260 fn sidecar_file_overrides_default() {
1261 // file_sidecar=true, no env => true (file beats default false).
1262 let cfg = DebugConfig::resolve(None, None, None, None, None, None, Some(true), None);
1263 assert!(cfg.source_map_sidecar, "file sidecar=true overrides default false");
1264 }
1265
1266 #[test]
1267 fn sidecar_env_overrides_file() {
1268 // env false beats file true; and env true beats file false.
1269 // env false, file none:
1270 let off = DebugConfig::resolve(None, None, None, None, Some(false), None, None, None)
1271 .source_map_sidecar;
1272 // file true alone:
1273 let file_on = DebugConfig::resolve(None, None, None, None, None, None, Some(true), None)
1274 .source_map_sidecar;
1275 // env false over file true:
1276 let env_off_over_file_on =
1277 DebugConfig::resolve(None, None, None, None, Some(false), None, Some(true), None)
1278 .source_map_sidecar;
1279 // env true over file false:
1280 let env_on_over_file_off =
1281 DebugConfig::resolve(None, None, None, None, Some(true), None, Some(false), None)
1282 .source_map_sidecar;
1283 assert!(!off);
1284 assert!(file_on);
1285 assert!(!env_off_over_file_on, "PASTA_DEBUG_SOURCE_MAP_SIDECAR=false overrides file true");
1286 assert!(env_on_over_file_off, "PASTA_DEBUG_SOURCE_MAP_SIDECAR=true overrides file false");
1287 }
1288}