Skip to main content

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}