Skip to main content

nodejs/
dap.rs

1//! Debug Adapter Protocol over stdio (`node --dap`).
2//!
3//! A single-threaded source-line debugger. The program is compiled with
4//! per-statement line markers (`Op::CallBuiltin(DBG_LINE, 1)`, emitted only in
5//! this mode — normal runs carry zero extra ops) and run on the pure interpreter
6//! (the tracing JIT would compile hot loops and skip the markers, so `--dap`
7//! compiles with `set_debug_mode(true)`, which installs the marker hook instead
8//! of `enable_tracing_jit`). The `DBG_LINE` builtin fires synchronously at each
9//! marker; when it lands on a breakpoint or a step target it pauses IN PLACE and
10//! services DAP requests (`stackTrace`/`scopes`/`variables`/`continue`/`next`/
11//! `stepIn`/`stepOut`) from stdin until a resume command, then returns control to
12//! the VM.
13//!
14//! Because it is single-threaded, an async `pause` of a free-running program is
15//! not supported (the adapter only reads requests while stopped at a marker);
16//! breakpoints and stepping — the load-bearing features — work inside function,
17//! loop, and try/catch bodies. Program stdout is redirected to a pipe during the
18//! run and forwarded as `output` events, so `console.log`/`process.stdout.write`
19//! never corrupt the JSON protocol channel on the saved stdout fd.
20
21use serde_json::{json, Value as J};
22use std::cell::RefCell;
23use std::collections::HashSet;
24use std::io::{Read, Write};
25use std::os::unix::io::{FromRawFd, RawFd};
26
27use fusevm::{Op, VM};
28
29/// How the debuggee should proceed from a stop.
30#[derive(Clone, Copy, PartialEq)]
31enum Mode {
32    Continue,
33    StepIn,
34    StepOver(usize),
35    StepOut(usize),
36}
37
38struct DebugState {
39    breakpoints: HashSet<u32>,
40    /// Lines that actually carry a marker (so a breakpoint on them can fire).
41    verified: HashSet<u32>,
42    /// Function names on which to break at entry (`setFunctionBreakpoints`).
43    function_breakpoints: HashSet<String>,
44    /// Frame depth seen at the previous marker; a jump upward means a call was
45    /// entered — the trigger for a function breakpoint.
46    last_depth: usize,
47    mode: Mode,
48    /// Real stdout, saved before the program's stdout is redirected to a pipe;
49    /// all DAP protocol is written here.
50    proto_fd: RawFd,
51    /// Read end of the program-stdout pipe (non-blocking), drained into `output`
52    /// events. `-1` until `launch` sets it up.
53    pipe_r: RawFd,
54    /// Source path reported in stack frames.
55    program: String,
56    seq: i64,
57    /// True once `launch` has redirected stdout and the debuggee is running.
58    active: bool,
59}
60
61thread_local! {
62    static DBG: RefCell<DebugState> = RefCell::new(DebugState {
63        breakpoints: HashSet::new(),
64        verified: HashSet::new(),
65        function_breakpoints: HashSet::new(),
66        last_depth: 0,
67        mode: Mode::Continue,
68        proto_fd: 1,
69        pipe_r: -1,
70        program: String::new(),
71        seq: 1,
72        active: false,
73    });
74}
75
76/// Entry point for `node --dap`.
77pub fn run() -> Result<(), String> {
78    // Save the real stdout up front; all DAP protocol goes here even after the
79    // program's stdout is redirected to a pipe during `launch`.
80    let proto = unsafe { libc::dup(1) };
81    DBG.with(|d| d.borrow_mut().proto_fd = proto);
82
83    let mut input = std::io::stdin();
84    while let Some(msg) = read_message(&mut input)? {
85        let command = msg.get("command").and_then(|c| c.as_str()).unwrap_or("");
86        let req_seq = msg.get("seq").and_then(|s| s.as_i64()).unwrap_or(0);
87        match command {
88            "initialize" => {
89                respond(
90                    req_seq,
91                    command,
92                    json!({
93                        "supportsConfigurationDoneRequest": true,
94                        "supportsEvaluateForHovers": true,
95                        "supportsFunctionBreakpoints": true,
96                        "supportsTerminateRequest": true,
97                    }),
98                );
99                event("initialized", json!({}));
100            }
101            "setBreakpoints" => set_breakpoints(&msg, req_seq),
102            "setFunctionBreakpoints" => set_function_breakpoints(&msg, req_seq),
103            "setExceptionBreakpoints" => {
104                // Accepted so clients that always send it proceed; the
105                // single-threaded adapter does not stop on exceptions (the VM has
106                // already returned control by the time one surfaces).
107                respond(req_seq, command, json!({ "breakpoints": [] }));
108            }
109            "evaluate" => {
110                // Nothing is on the stack before `launch`; ack with an empty
111                // result so a watch/hover registered up front does not error.
112                respond(
113                    req_seq,
114                    command,
115                    json!({ "result": "", "variablesReference": 0 }),
116                );
117            }
118            "pause" => respond(req_seq, command, json!({})),
119            "configurationDone" => respond(req_seq, command, json!({})),
120            "threads" => respond(
121                req_seq,
122                command,
123                json!({ "threads": [{ "id": 1, "name": "main" }] }),
124            ),
125            "launch" => {
126                let program = msg
127                    .get("arguments")
128                    .and_then(|a| a.get("program"))
129                    .and_then(|p| p.as_str())
130                    .unwrap_or("")
131                    .to_string();
132                respond(req_seq, command, json!({}));
133                launch(&program);
134            }
135            "disconnect" | "terminate" => {
136                respond(req_seq, command, json!({}));
137                break;
138            }
139            _ => respond(req_seq, command, json!({})),
140        }
141    }
142    unsafe {
143        libc::close(proto);
144    }
145    Ok(())
146}
147
148/// `setBreakpoints`: store the requested lines and report each verified only if
149/// the program actually emits a marker on that line (a blank/comment line with no
150/// compiled statement is reported unverified — a breakpoint there would never
151/// fire).
152fn set_breakpoints(msg: &J, req_seq: i64) {
153    let path = msg
154        .get("arguments")
155        .and_then(|a| a.get("source"))
156        .and_then(|s| s.get("path"))
157        .and_then(|p| p.as_str())
158        .unwrap_or("")
159        .to_string();
160    let lines: Vec<u32> = msg
161        .get("arguments")
162        .and_then(|a| a.get("breakpoints"))
163        .and_then(|b| b.as_array())
164        .map(|bps| {
165            bps.iter()
166                .filter_map(|b| b.get("line").and_then(|l| l.as_u64()).map(|l| l as u32))
167                .collect()
168        })
169        .unwrap_or_default();
170
171    let markers = marker_lines(&path);
172    DBG.with(|d| {
173        let mut s = d.borrow_mut();
174        if !path.is_empty() {
175            s.program = path;
176        }
177        s.breakpoints = lines.iter().copied().collect();
178        s.verified = markers;
179    });
180    let bps: Vec<J> = DBG.with(|d| {
181        let s = d.borrow();
182        lines
183            .iter()
184            .map(|l| json!({ "verified": s.verified.contains(l), "line": l }))
185            .collect()
186    });
187    respond(req_seq, "setBreakpoints", json!({ "breakpoints": bps }));
188}
189
190/// `setFunctionBreakpoints`: store the requested function names. Each is reported
191/// verified; the marker hook stops on the first marker executed inside a frame
192/// whose name matches — break-on-entry, see [`on_debug_line`].
193fn set_function_breakpoints(msg: &J, req_seq: i64) {
194    let names: Vec<String> = msg
195        .get("arguments")
196        .and_then(|a| a.get("breakpoints"))
197        .and_then(|b| b.as_array())
198        .map(|arr| {
199            arr.iter()
200                .filter_map(|b| b.get("name").and_then(|n| n.as_str()).map(String::from))
201                .collect()
202        })
203        .unwrap_or_default();
204    DBG.with(|d| d.borrow_mut().function_breakpoints = names.iter().cloned().collect());
205    let bps: Vec<J> = names.iter().map(|_| json!({ "verified": true })).collect();
206    respond(
207        req_seq,
208        "setFunctionBreakpoints",
209        json!({ "breakpoints": bps }),
210    );
211}
212
213/// Evaluate a debugger expression. v1 resolves a bare variable name against the
214/// paused frame's locals (mirrors awkrs's snapshot lookup); anything else returns
215/// a hint rather than spawning a sub-interpreter.
216fn evaluate_expression(expr: &str) -> String {
217    if expr.is_empty() {
218        return String::new();
219    }
220    for (name, repr) in crate::host::with_host(|h| h.dbg_locals()) {
221        if name == expr {
222            return repr;
223        }
224    }
225    format!("<cannot evaluate `{expr}`>")
226}
227
228/// The set of source lines that carry a `DBG_LINE` marker in the compiled program
229/// (module main + every function body + every try/catch/finally block) — the
230/// lines on which a breakpoint can actually stop.
231fn marker_lines(path: &str) -> HashSet<u32> {
232    let mut set = HashSet::new();
233    let Ok(src) = std::fs::read_to_string(path) else {
234        return set;
235    };
236    let Ok(prog) = crate::compile_debug(&src) else {
237        return set;
238    };
239    let mut scan = |chunk: &fusevm::Chunk| {
240        for (i, op) in chunk.ops.iter().enumerate() {
241            if let Op::CallBuiltin(id, _) = op {
242                if *id == crate::host::ops::DBG_LINE {
243                    if let Some(l) = chunk.lines.get(i) {
244                        set.insert(*l);
245                    }
246                }
247            }
248        }
249    };
250    scan(&prog.main);
251    for (_, f) in &prog.functions {
252        scan(&f.chunk);
253    }
254    for t in &prog.tries {
255        scan(&t.block);
256        if let Some((_name, handler)) = &t.handler {
257            scan(handler);
258        }
259        if let Some(finalizer) = &t.finalizer {
260            scan(finalizer);
261        }
262    }
263    set
264}
265
266/// Run the program under the debugger: redirect its stdout to a pipe, run with
267/// the debug marker hook (which pauses at breakpoints/steps), then restore
268/// stdout, flush remaining output, and emit `terminated`.
269fn launch(program: &str) {
270    if program.is_empty() {
271        return;
272    }
273    DBG.with(|d| {
274        let mut s = d.borrow_mut();
275        if s.program.is_empty() {
276            s.program = program.to_string();
277        }
278    });
279    // SAFETY: standard pipe + dup2 on the process's own stdout fd; the read end
280    // is set non-blocking so `drain_output` never stalls the debugger.
281    let pipe_r = unsafe {
282        let mut fds = [0i32; 2];
283        if libc::pipe(fds.as_mut_ptr()) != 0 {
284            -1
285        } else {
286            libc::dup2(fds[1], 1);
287            libc::close(fds[1]);
288            let flags = libc::fcntl(fds[0], libc::F_GETFL);
289            libc::fcntl(fds[0], libc::F_SETFL, flags | libc::O_NONBLOCK);
290            fds[0]
291        }
292    };
293    DBG.with(|d| {
294        let mut s = d.borrow_mut();
295        s.pipe_r = pipe_r;
296        s.mode = Mode::Continue;
297        s.active = true;
298    });
299
300    if let Err(e) = crate::eval_file_debug(program) {
301        eprintln!("node: {e}");
302    }
303
304    // Restore stdout, drain any trailing program output, then close the pipe.
305    let _ = std::io::stdout().flush();
306    DBG.with(|d| d.borrow_mut().active = false);
307    drain_output();
308    let saved = DBG.with(|d| d.borrow().proto_fd);
309    unsafe {
310        if saved >= 0 {
311            libc::dup2(saved, 1);
312        }
313        if pipe_r >= 0 {
314            libc::close(pipe_r);
315        }
316    }
317    DBG.with(|d| d.borrow_mut().pipe_r = -1);
318    event("terminated", json!({}));
319}
320
321/// Extension-handler shim kept for the `Op::Extended` dispatch seam registered in
322/// `host::run_chunk_on`. node-js emits `DBG_LINE` as `Op::CallBuiltin`, not
323/// `Op::Extended`, so in the current wiring the live hook is `on_debug_line`
324/// (invoked from the `DBG_LINE` builtin — see `builtins::b_dbg_line`). This shim
325/// routes an `Extended(DBG_LINE)` marker to the same logic should the emission
326/// seam ever be switched, and is a no-op for every other extension id.
327pub fn on_ext(vm: &mut VM, id: u16) {
328    if id == crate::host::ops::DBG_LINE {
329        let line = *vm.chunk.lines.get(vm.ip.saturating_sub(1)).unwrap_or(&0);
330        on_debug_line(line);
331    }
332}
333
334/// Called by the VM at each statement marker (via the `DBG_LINE` builtin, which
335/// passes the marker's source `line`). If it is a breakpoint or the active step
336/// target, pauses and services DAP requests until a resume command.
337pub fn on_debug_line(line: u32) {
338    if line == 0 {
339        return;
340    }
341    let (depth, fname) = crate::host::with_host(|h| {
342        h.set_cur_line(line);
343        (
344            h.frame_depth(),
345            h.dbg_stack()
346                .first()
347                .map(|(n, _)| n.clone())
348                .unwrap_or_default(),
349        )
350    });
351    let (stop, reason) = DBG.with(|d| {
352        let mut s = d.borrow_mut();
353        if !s.active {
354            s.last_depth = depth;
355            return (false, "");
356        }
357        let bp = s.breakpoints.contains(&line) && s.verified.contains(&line);
358        // A deeper frame than the previous marker means a call was just entered;
359        // stop if that frame's name matches a function breakpoint.
360        let fbp = depth > s.last_depth && s.function_breakpoints.contains(&fname);
361        let step = match s.mode {
362            Mode::Continue => false,
363            Mode::StepIn => true,
364            Mode::StepOver(d0) => depth <= d0,
365            Mode::StepOut(d0) => depth < d0,
366        };
367        s.last_depth = depth;
368        let reason = if bp {
369            "breakpoint"
370        } else if fbp {
371            "function breakpoint"
372        } else {
373            "step"
374        };
375        (bp || fbp || step, reason)
376    });
377    if !stop {
378        return;
379    }
380    drain_output();
381    event(
382        "stopped",
383        json!({
384            "reason": reason,
385            "threadId": 1,
386            "allThreadsStopped": true,
387        }),
388    );
389
390    // Service requests until a resume command returns control to the VM.
391    let mut stdin = std::io::stdin();
392    loop {
393        match read_message(&mut stdin) {
394            Ok(Some(msg)) => {
395                if handle_stopped(&msg, depth) {
396                    break;
397                }
398            }
399            _ => {
400                // EOF / read error: let the program run to completion.
401                DBG.with(|d| d.borrow_mut().mode = Mode::Continue);
402                break;
403            }
404        }
405    }
406}
407
408/// Handle one request while stopped. Returns true when a resume command
409/// (`continue`/`next`/`stepIn`/`stepOut`) was processed and the VM should run on.
410fn handle_stopped(msg: &J, depth: usize) -> bool {
411    let command = msg.get("command").and_then(|c| c.as_str()).unwrap_or("");
412    let req_seq = msg.get("seq").and_then(|s| s.as_i64()).unwrap_or(0);
413    match command {
414        "threads" => {
415            respond(
416                req_seq,
417                command,
418                json!({ "threads": [{ "id": 1, "name": "main" }] }),
419            );
420            false
421        }
422        "stackTrace" => {
423            let program = DBG.with(|d| d.borrow().program.clone());
424            let frames: Vec<J> = crate::host::with_host(|h| h.dbg_stack())
425                .into_iter()
426                .enumerate()
427                .map(|(i, (name, line))| {
428                    json!({
429                        "id": i,
430                        "name": name,
431                        "line": line,
432                        "column": 1,
433                        "source": { "path": program },
434                    })
435                })
436                .collect();
437            respond(
438                req_seq,
439                command,
440                json!({ "stackFrames": frames, "totalFrames": frames.len() }),
441            );
442            false
443        }
444        "scopes" => {
445            respond(
446                req_seq,
447                command,
448                json!({ "scopes": [{ "name": "Locals", "variablesReference": 1, "expensive": false }] }),
449            );
450            false
451        }
452        "variables" => {
453            let vars: Vec<J> = crate::host::with_host(|h| h.dbg_locals())
454                .into_iter()
455                .map(|(n, v)| json!({ "name": n, "value": v, "variablesReference": 0 }))
456                .collect();
457            respond(req_seq, command, json!({ "variables": vars }));
458            false
459        }
460        "setBreakpoints" => {
461            set_breakpoints(msg, req_seq);
462            false
463        }
464        "setFunctionBreakpoints" => {
465            set_function_breakpoints(msg, req_seq);
466            false
467        }
468        "setExceptionBreakpoints" => {
469            respond(req_seq, command, json!({ "breakpoints": [] }));
470            false
471        }
472        "evaluate" => {
473            let expr = msg
474                .get("arguments")
475                .and_then(|a| a.get("expression"))
476                .and_then(|e| e.as_str())
477                .unwrap_or("")
478                .trim()
479                .to_string();
480            let result = evaluate_expression(&expr);
481            respond(
482                req_seq,
483                command,
484                json!({ "result": result, "variablesReference": 0 }),
485            );
486            false
487        }
488        "pause" => {
489            // Already stopped at this marker; `pause` is a no-op ack for the
490            // single-threaded adapter.
491            respond(req_seq, command, json!({}));
492            false
493        }
494        "continue" => {
495            DBG.with(|d| d.borrow_mut().mode = Mode::Continue);
496            respond(req_seq, command, json!({ "allThreadsContinued": true }));
497            true
498        }
499        "next" => {
500            DBG.with(|d| d.borrow_mut().mode = Mode::StepOver(depth));
501            respond(req_seq, command, json!({}));
502            true
503        }
504        "stepIn" => {
505            DBG.with(|d| d.borrow_mut().mode = Mode::StepIn);
506            respond(req_seq, command, json!({}));
507            true
508        }
509        "stepOut" => {
510            DBG.with(|d| d.borrow_mut().mode = Mode::StepOut(depth));
511            respond(req_seq, command, json!({}));
512            true
513        }
514        "disconnect" | "terminate" => {
515            DBG.with(|d| d.borrow_mut().mode = Mode::Continue);
516            respond(req_seq, command, json!({}));
517            true
518        }
519        _ => {
520            respond(req_seq, command, json!({}));
521            false
522        }
523    }
524}
525
526/// Read whatever the program has written to its stdout pipe so far (non-blocking)
527/// and forward it as an `output` event.
528fn drain_output() {
529    let fd = DBG.with(|d| d.borrow().pipe_r);
530    if fd < 0 {
531        return;
532    }
533    let mut out = Vec::new();
534    let mut buf = [0u8; 4096];
535    loop {
536        let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
537        if n > 0 {
538            out.extend_from_slice(&buf[..n as usize]);
539        } else {
540            break;
541        }
542    }
543    if !out.is_empty() {
544        let text = String::from_utf8_lossy(&out).to_string();
545        event("output", json!({ "category": "stdout", "output": text }));
546    }
547}
548
549// ---- wire protocol --------------------------------------------------------
550
551/// Read one `Content-Length`-framed JSON message; `None` at EOF.
552fn read_message(input: &mut std::io::Stdin) -> Result<Option<J>, String> {
553    let mut header = Vec::new();
554    let mut byte = [0u8; 1];
555    loop {
556        match input.read(&mut byte) {
557            Ok(0) => return Ok(None),
558            Ok(_) => {
559                header.push(byte[0]);
560                if header.ends_with(b"\r\n\r\n") {
561                    break;
562                }
563            }
564            Err(e) => return Err(format!("dap read: {e}")),
565        }
566    }
567    let header = String::from_utf8_lossy(&header);
568    let len: usize = header
569        .lines()
570        .find_map(|l| l.strip_prefix("Content-Length:"))
571        .and_then(|v| v.trim().parse().ok())
572        .ok_or("dap: missing Content-Length")?;
573    let mut body = vec![0u8; len];
574    input
575        .read_exact(&mut body)
576        .map_err(|e| format!("dap body: {e}"))?;
577    serde_json::from_slice(&body)
578        .map(Some)
579        .map_err(|e| format!("dap json: {e}"))
580}
581
582/// Write a framed JSON message to the saved protocol fd (never to fd 1, which is
583/// the program's redirected stdout during a run).
584fn send(msg: &J) {
585    let body = msg.to_string();
586    let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
587    let fd = DBG.with(|d| d.borrow().proto_fd);
588    // SAFETY: `fd` is a valid duplicated stdout fd owned by this process; wrapped
589    // in ManuallyDrop so the File does not close it on drop.
590    unsafe {
591        let mut f = std::mem::ManuallyDrop::new(std::fs::File::from_raw_fd(fd));
592        let _ = f.write_all(frame.as_bytes());
593        let _ = f.flush();
594    }
595}
596
597fn next_seq() -> i64 {
598    DBG.with(|d| {
599        let mut s = d.borrow_mut();
600        let n = s.seq;
601        s.seq += 1;
602        n
603    })
604}
605
606fn respond(req_seq: i64, command: &str, body: J) {
607    send(&json!({
608        "seq": next_seq(),
609        "type": "response",
610        "request_seq": req_seq,
611        "success": true,
612        "command": command,
613        "body": body,
614    }));
615}
616
617fn event(ev: &str, body: J) {
618    send(&json!({ "seq": next_seq(), "type": "event", "event": ev, "body": body }));
619}