Skip to main content

zsh/extensions/
dap.rs

1//! DAP server for zshrs — `zshrs --dap [HOST:PORT]` (TCP connect-back, or stdio
2//! when no address is given).
3//!
4//! Mirrors strykelang's DAP architecture (`strykelang/dap.rs`):
5//! connect TCP → spawn reader thread → wait for `launch` → run the
6//! script IN-PROCESS so per-statement breakpoint checks have a live
7//! interpreter to pause. The compiler emits `BUILTIN_SET_LINENO` at
8//! every top-level statement; that builtin's handler in
9//! `fusevm_bridge.rs` calls [`check_line`] which consults the
10//! private `DAP_SHARED` static for matching breakpoints and
11//! condvar-waits in [`DapShared::pause`].
12//!
13//! NOT subprocess-based — earlier v1 spawned the script as a child,
14//! which made it impossible to honor breakpoints (no IPC channel to
15//! pause the child). The new model keeps the script in-process so the
16//! cv-wait literally blocks the executor thread until the IDE sends
17//! `continue`.
18
19use serde_json::{json, Value};
20use std::collections::HashMap;
21use std::io::{self, BufRead, BufReader, Read, Write};
22use std::net::TcpStream;
23use std::process::{Child, ChildStdout, Command, Stdio};
24use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
25use std::sync::{Arc, Condvar, Mutex, OnceLock};
26use std::thread;
27use std::time::Duration;
28
29// ── Pause-and-resume shared state ───────────────────────────────────────
30
31/// What the IDE asked the paused executor to do.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DebugAction {
34    /// Resume normal execution.
35    Continue,
36    /// Step over the current statement (treat as Continue + pause at
37    /// the next `check_line` call). v1 simplification — Continue is
38    /// the same as StepOver in absence of frame depth tracking.
39    StepOver,
40    /// Step in — same simplification as StepOver in v1.
41    StepIn,
42    /// Step out — same simplification as Continue in v1.
43    StepOut,
44    /// Client disconnected — bail out of the script.
45    Quit,
46}
47
48/// Snapshot captured when the executor pauses. Sent back to the IDE
49/// as part of the `stopped` event body.
50#[derive(Debug, Clone, Default)]
51pub struct PauseSnapshot {
52    pub reason: String, // "breakpoint" | "step" | "pause" | "entry"
53    /// `file` field.
54    pub file: String,
55    /// `line` field.
56    pub line: u32,
57}
58
59/// Breakpoint state shared between the reader thread (which updates
60/// it on `setBreakpoints`) and the executor thread (which consults
61/// it on every `check_line`).
62#[derive(Debug, Default)]
63pub struct BreakpointState {
64    /// Absolute file path → set of 1-based line numbers.
65    pub line_breakpoints: HashMap<String, Vec<u32>>,
66}
67
68struct DapSharedInner {
69    pending_action: Option<DebugAction>,
70    is_paused: bool,
71    pause_request: bool, // client asked us to pause asap (via `pause`)
72    step_mode: bool,     // true = pause at every check_line (StepOver/In)
73}
74
75/// Reader thread + executor thread coordinate through this. Owns the
76/// TCP writer, the request seq counter, the pause-cv, and the
77/// disconnected flag.
78pub struct DapShared {
79    /// `inner` field.
80    inner: Mutex<DapSharedInner>,
81    /// `cv` field.
82    cv: Condvar,
83    /// `seq` field.
84    seq: AtomicU64,
85    /// `writer` field — the DAP output sink (a TCP socket in connect-back mode,
86    /// stdout in stdio mode).
87    writer: Mutex<Box<dyn Write + Send>>,
88    /// `configuration_done` field.
89    pub configuration_done: AtomicBool,
90    /// `disconnected` field.
91    pub disconnected: AtomicBool,
92    /// Set once the executor has been handed the launched program.
93    /// `disconnect` / `terminate` consult it: before launch the reader
94    /// thread simply exits and the main thread falls out of
95    /// `launch_rx.recv()`, but afterwards the executor owns the thread
96    /// and may be blocked in a `waitpid` on a long-running child, so
97    /// the adapter needs the watchdog in `terminate_debuggee`.
98    pub launched: AtomicBool,
99    /// Absolute path of the launched program — set on `launch`, read
100    /// by `check_line` to know which file's breakpoint set to check.
101    pub program: Mutex<String>,
102}
103
104impl DapShared {
105    fn new(writer: Box<dyn Write + Send>) -> Arc<Self> {
106        Arc::new(Self {
107            inner: Mutex::new(DapSharedInner {
108                pending_action: None,
109                is_paused: false,
110                pause_request: false,
111                step_mode: false,
112            }),
113            cv: Condvar::new(),
114            seq: AtomicU64::new(1),
115            writer: Mutex::new(writer),
116            configuration_done: AtomicBool::new(false),
117            disconnected: AtomicBool::new(false),
118            launched: AtomicBool::new(false),
119            program: Mutex::new(String::new()),
120        })
121    }
122
123    /// Called by the executor thread when a `check_line` matches a
124    /// breakpoint (or step mode is on). Captures the snapshot, emits
125    /// a `stopped` event, then condvar-waits for the IDE to send
126    /// `continue` / `next` / `stepIn` / `stepOut`.
127    pub fn pause(&self, snap: PauseSnapshot) -> DebugAction {
128        // Flush stdout/stderr so any `echo` / `print` output the user
129        // produced before this breakpoint is visible in the IDE
130        // Console BEFORE the suspend UI shows.
131        let _ = io::Write::flush(&mut io::stdout());
132        let _ = io::Write::flush(&mut io::stderr());
133        tracing::info!(
134            target: "zshrs::dap::pause",
135            reason = %snap.reason,
136            file = %snap.file,
137            line = snap.line,
138            "executor PAUSED (cv-wait)",
139        );
140        {
141            let mut s = self.inner.lock().expect("dap lock");
142            s.is_paused = true;
143            s.pending_action = None;
144            s.pause_request = false;
145        }
146        let _ = self.emit_event(
147            "stopped",
148            json!({
149                "reason": snap.reason,
150                "threadId": 1,
151                "allThreadsStopped": true,
152                "preserveFocusHint": false,
153                "description": snap.reason,
154                "text": format!("{}:{}", snap.file, snap.line),
155            }),
156        );
157        let mut guard = self.inner.lock().expect("dap lock");
158        while guard.pending_action.is_none() && !self.disconnected.load(Ordering::SeqCst) {
159            guard = self.cv.wait(guard).expect("dap cv");
160        }
161        let action = guard.pending_action.take().unwrap_or(DebugAction::Continue);
162        guard.is_paused = false;
163        // Step mode persists until the IDE sends a plain `continue`.
164        guard.step_mode = matches!(action, DebugAction::StepOver | DebugAction::StepIn);
165        tracing::info!(
166            target: "zshrs::dap::pause",
167            ?action,
168            step_mode = guard.step_mode,
169            "executor RESUMED",
170        );
171        action
172    }
173
174    fn resume_with(&self, action: DebugAction) {
175        let mut g = self.inner.lock().expect("dap lock");
176        g.pending_action = Some(action);
177        self.cv.notify_all();
178    }
179
180    fn request_pause(&self) {
181        let mut g = self.inner.lock().expect("dap lock");
182        g.pause_request = true;
183    }
184
185    fn want_pause(&self) -> bool {
186        self.inner.lock().map(|g| g.pause_request).unwrap_or(false)
187    }
188
189    fn step_mode(&self) -> bool {
190        self.inner.lock().map(|g| g.step_mode).unwrap_or(false)
191    }
192
193    fn next_seq(&self) -> u64 {
194        self.seq.fetch_add(1, Ordering::SeqCst)
195    }
196
197    fn write_message(&self, body: Value) -> io::Result<()> {
198        let s = serde_json::to_string(&body)?;
199        let mut w = self.writer.lock().expect("dap writer");
200        write!(w, "Content-Length: {}\r\n\r\n{}", s.len(), s)?;
201        w.flush()
202    }
203
204    fn emit_response(
205        &self,
206        req_seq: i64,
207        command: &str,
208        success: bool,
209        body: Value,
210    ) -> io::Result<()> {
211        let seq = self.next_seq();
212        let msg = json!({
213            "seq": seq,
214            "type": "response",
215            "request_seq": req_seq,
216            "command": command,
217            "success": success,
218            "body": body,
219        });
220        tracing::trace!(target: "zshrs::dap::send", seq, %command, "response");
221        self.write_message(msg)
222    }
223    /// `emit_event` — see implementation.
224    pub fn emit_event(&self, event: &str, body: Value) -> io::Result<()> {
225        let seq = self.next_seq();
226        let milestone = matches!(
227            event,
228            "stopped" | "terminated" | "exited" | "initialized" | "process" | "breakpoint"
229        );
230        if milestone {
231            tracing::info!(target: "zshrs::dap::send", seq, %event, "event (milestone)");
232        } else {
233            tracing::trace!(target: "zshrs::dap::send", seq, %event, "event");
234        }
235        let msg = json!({
236            "seq": seq,
237            "type": "event",
238            "event": event,
239            "body": body,
240        });
241        self.write_message(msg)
242    }
243}
244
245/// Global handle to the live DAP server (set by `run_dap` before the
246/// script starts). The `BUILTIN_SET_LINENO` handler in
247/// `fusevm_bridge.rs` reads this on every statement; if unset
248/// (normal shell mode) the lookup is a single atomic load and falls
249/// through to no-op.
250static DAP_SHARED: OnceLock<Arc<DapShared>> = OnceLock::new();
251
252/// Per-thread breakpoint snapshot — read in the hot path of every
253/// `check_line` call. Cloned from `BreakpointState` after each
254/// `setBreakpoints` so the executor doesn't need to lock the global
255/// state on every statement.
256static DAP_BREAKPOINTS: OnceLock<Arc<Mutex<BreakpointState>>> = OnceLock::new();
257
258/// Called by `BUILTIN_SET_LINENO` in `fusevm_bridge.rs` for every
259/// top-level statement. O(1) when DAP is not active (single atomic
260/// load). When active, checks the breakpoint set for the launched
261/// program and pauses if the current line matches OR if step mode
262/// is on OR if the client asked for a pause.
263pub fn check_line(line: u32) {
264    let Some(shared) = DAP_SHARED.get() else {
265        return;
266    };
267    if shared.disconnected.load(Ordering::SeqCst) {
268        return;
269    }
270    let program = shared.program.lock().map(|g| g.clone()).unwrap_or_default();
271    let reason = if shared.want_pause() {
272        "pause"
273    } else if shared.step_mode() {
274        "step"
275    } else {
276        // Breakpoint match check
277        let bp_arc = match DAP_BREAKPOINTS.get() {
278            Some(b) => b,
279            None => return,
280        };
281        let bp = match bp_arc.lock() {
282            Ok(g) => g,
283            Err(_) => return,
284        };
285        let hit = bp
286            .line_breakpoints
287            .get(&program)
288            .map(|lines| lines.contains(&line))
289            .unwrap_or(false);
290        if !hit {
291            return;
292        }
293        "breakpoint"
294    };
295    let snap = PauseSnapshot {
296        reason: reason.to_string(),
297        file: program,
298        line,
299    };
300    let action = shared.pause(snap);
301    if matches!(action, DebugAction::Quit) {
302        // Terminate the script. We don't have a clean unwind from
303        // inside the VM here; signal via errflag so the executor
304        // halts at the next safe point.
305        crate::ported::utils::errflag
306            .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
307    }
308}
309
310// ── Public entry point ──────────────────────────────────────────────────
311
312/// Serve the DAP protocol until the client disconnects.
313///
314/// `addr` selects the transport (called from `bins/zshrs.rs` on `--dap`):
315///   * `Some("127.0.0.1:55123")` — **TCP connect-back**: dial the IDE's DAP
316///     listener (the path JetBrains uses; stdout stays free for the script).
317///   * `None` — **stdio**: DAP over stdin/stdout, for clients that spawn the
318///     adapter as an executable (e.g. VS Code). Unusable under IntelliJ, whose
319///     OSProcessHandler reads stdout and would steal DAP bytes.
320///
321/// Stryke-mirror architecture:
322///   1. Connect the transport (TCP socket or stdin/stdout).
323///   2. Spawn reader thread to handle `initialize`, `setBreakpoints`,
324///      `launch`, `continue`, etc. Each `launch` request goes through
325///      a oneshot channel back to the main thread.
326///   3. Block on the channel until `launch` arrives.
327///   4. Install DAP_SHARED + DAP_BREAKPOINTS globals (the BUILTIN_SET_LINENO
328///      hook in fusevm_bridge.rs consults them on every statement).
329///   5. Redirect fd 1 / fd 2 into `output` events, then run the script
330///      IN-PROCESS via `ShellExecutor::execute_script_file`.
331///      The executor blocks inside `check_line → pause()` whenever a
332///      breakpoint hits; the reader thread sends `continue` to resume.
333///   6. After execution: emit `exited` + `terminated`, drop globals.
334pub fn run_dap(addr: Option<&str>) -> i32 {
335    tracing::info!(
336        target: "zshrs::dap",
337        pid = std::process::id(),
338        mode = if addr.is_some() { "tcp" } else { "stdio" },
339        "starting --dap",
340    );
341
342    // Own the process group before anything is forked, so every
343    // descendant of the debugged script lands in it and `disconnect`
344    // with `terminateDebuggee` can take them all down.
345    #[cfg(unix)]
346    claim_process_group();
347
348    // Two transports. Both run the identical DAP server below — only the
349    // reader/writer differ.
350    let (reader, writer): (Box<dyn Read + Send>, Box<dyn Write + Send>) = match addr {
351        // TCP connect-back: dial the IDE's DAP listener (the path JetBrains
352        // uses). The protocol has its own socket, so the script's stdout /
353        // stderr stay separate from it — they are captured at the fd level
354        // during `launch` and re-emitted as `output` events (see
355        // `install_output_capture`), which is what a DAP client reads.
356        Some(addr) => {
357            let stream = match TcpStream::connect(addr) {
358                Ok(s) => s,
359                Err(e) => {
360                    tracing::error!(target: "zshrs::dap", %addr, %e, "tcp connect failed");
361                    eprintln!("zshrs: --dap: connect {} failed: {}", addr, e);
362                    return 1;
363                }
364            };
365            if let Err(e) = stream.set_nodelay(true) {
366                tracing::warn!(target: "zshrs::dap", %e, "TCP_NODELAY failed (non-fatal)");
367            }
368            let reader_stream = match stream.try_clone() {
369                Ok(s) => s,
370                Err(e) => {
371                    tracing::error!(target: "zshrs::dap", %e, "tcp clone failed");
372                    eprintln!("zshrs: --dap: clone socket: {}", e);
373                    return 1;
374                }
375            };
376            tracing::info!(target: "zshrs::dap", %addr, "tcp connected");
377            (Box::new(reader_stream), Box::new(stream))
378        }
379        // Stdio mode: DAP traffic over stdin/stdout. For clients that spawn the
380        // adapter as an executable and talk over its pipes (e.g. VS Code's
381        // DebugAdapterExecutable). NOTE: unusable under IntelliJ, whose
382        // OSProcessHandler reads stdout and would steal DAP bytes — use TCP there.
383        None => {
384            tracing::info!(target: "zshrs::dap", "stdio mode");
385            // Take a PRIVATE dup of fd 1 for the protocol before
386            // `install_output_capture` re-points fd 1 at a pipe. Without
387            // it the launched script's own `echo` would land in the
388            // middle of a `Content-Length` frame and desync the client
389            // — and with it, that output correctly arrives as `output`
390            // events instead.
391            #[cfg(unix)]
392            let writer: Box<dyn Write + Send> = {
393                use std::os::unix::io::FromRawFd;
394                // SAFETY: dup(2) on the process's own stdout; the
395                // returned fd is owned solely by this File.
396                let fd = unsafe { libc::dup(libc::STDOUT_FILENO) };
397                if fd >= 0 {
398                    Box::new(unsafe { std::fs::File::from_raw_fd(fd) })
399                } else {
400                    Box::new(io::stdout())
401                }
402            };
403            #[cfg(not(unix))]
404            let writer: Box<dyn Write + Send> = Box::new(io::stdout());
405            (Box::new(io::stdin()), writer)
406        }
407    };
408
409    let shared = DapShared::new(writer);
410    let bp_state = Arc::new(Mutex::new(BreakpointState::default()));
411
412    // Reader thread: parses DAP requests + dispatches. Sends a
413    // LaunchParams down the channel when `launch` arrives.
414    let (launch_tx, launch_rx) = std::sync::mpsc::channel::<LaunchParams>();
415    let shared_reader = shared.clone();
416    let bp_reader = bp_state.clone();
417    let _reader = thread::spawn(move || {
418        let mut br = BufReader::new(reader);
419        loop {
420            let msg = match read_message(&mut br) {
421                Ok(Some(m)) => m,
422                Ok(None) => {
423                    tracing::info!(target: "zshrs::dap", "client disconnected (EOF)");
424                    break;
425                }
426                Err(e) => {
427                    tracing::error!(target: "zshrs::dap", %e, "read error");
428                    break;
429                }
430            };
431            handle_request(&shared_reader, &bp_reader, &launch_tx, msg);
432            if shared_reader.disconnected.load(Ordering::SeqCst) {
433                break;
434            }
435        }
436        // Reader exiting — unblock any cv-wait so the executor can
437        // see Quit and bail.
438        shared_reader.disconnected.store(true, Ordering::SeqCst);
439        shared_reader.resume_with(DebugAction::Quit);
440    });
441
442    // Block until the IDE sends `launch`.
443    let lp = match launch_rx.recv() {
444        Ok(p) => p,
445        Err(_) => {
446            tracing::warn!(target: "zshrs::dap", "no launch received before disconnect");
447            return 1;
448        }
449    };
450    tracing::info!(
451        target: "zshrs::dap",
452        program = %lp.program,
453        cwd = ?lp.cwd,
454        args = ?lp.args,
455        stop_on_entry = lp.stop_on_entry,
456        "launch received",
457    );
458
459    // Install global hooks for BUILTIN_SET_LINENO.
460    *shared.program.lock().expect("program lock") = lp.program.clone();
461    let _ = DAP_SHARED.set(shared.clone());
462    let _ = DAP_BREAKPOINTS.set(bp_state.clone());
463    if lp.stop_on_entry {
464        // Force a pause at the first statement.
465        shared.inner.lock().expect("dap lock").step_mode = true;
466    }
467
468    // Cosmetic events for IDE UI.
469    let _ = shared.emit_event(
470        "process",
471        json!({
472            "name": lp.program,
473            "systemProcessId": std::process::id(),
474            "isLocalProcess": true,
475            "startMethod": "launch",
476        }),
477    );
478    let _ = shared.emit_event("thread", json!({ "reason": "started", "threadId": 1 }));
479
480    if let Some(cwd) = &lp.cwd {
481        let _ = std::env::set_current_dir(cwd);
482    }
483
484    // Everything the script writes to fd 1 / fd 2 from here on becomes
485    // a DAP `output` event (see `install_output_capture`). Installed
486    // BEFORE the executor starts so the very first `echo` is captured.
487    let capture = install_output_capture(&shared);
488
489    // Run the script in-process. The BUILTIN_SET_LINENO hook will
490    // block on `shared.pause()` whenever a breakpoint matches.
491    tracing::info!(target: "zshrs::dap", "entering executor (in-process)");
492    let mut exec = crate::vm_helper::ShellExecutor::new();
493    // `launch.args` → the script's positional parameters. DAP spec:
494    // "arguments: Command line arguments passed to the program". They
495    // were parsed into `LaunchParams` but never handed to the executor,
496    // so `$1` / `$@` were empty for every debugged script no matter
497    // what the launch config said.
498    exec.set_pparams(lp.args.clone());
499    let exit_code = match exec.execute_script_file(&lp.program) {
500        Ok(status) => status,
501        Err(e) => {
502            tracing::error!(target: "zshrs::dap", %e, "executor returned error");
503            let _ = shared.emit_event(
504                "output",
505                json!({
506                    "category": "stderr",
507                    "output": format!("zshrs --dap: {}\n", e),
508                }),
509            );
510            1
511        }
512    };
513    // Restore fd 1 / fd 2 and drain the capture threads before the
514    // `exited` / `terminated` events so the IDE has every line of the
515    // program's output before it tears the session down.
516    if let Some(c) = capture {
517        c.restore();
518    }
519    tracing::info!(target: "zshrs::dap", exit_code, "executor exited");
520
521    let _ = shared.emit_event("exited", json!({ "exitCode": exit_code }));
522    let _ = shared.emit_event("terminated", json!({}));
523    let _ = shared.emit_event("thread", json!({ "reason": "exited", "threadId": 1 }));
524    // Brief grace period for the writer to drain before the process exits.
525    thread::sleep(Duration::from_millis(50));
526    exit_code
527}
528
529#[derive(Debug, Clone)]
530struct LaunchParams {
531    program: String,
532    cwd: Option<String>,
533    args: Vec<String>,
534    stop_on_entry: bool,
535}
536
537/// Dispatch a single DAP request. `launch_tx` is used to hand the
538/// program info back to the main thread; everything else (breakpoints,
539/// continue, etc.) is handled here directly.
540fn handle_request(
541    shared: &Arc<DapShared>,
542    bp_state: &Arc<Mutex<BreakpointState>>,
543    launch_tx: &std::sync::mpsc::Sender<LaunchParams>,
544    msg: Value,
545) {
546    let cmd = msg
547        .get("command")
548        .and_then(|v| v.as_str())
549        .unwrap_or("")
550        .to_string();
551    let req_seq = msg.get("seq").and_then(|v| v.as_i64()).unwrap_or(0);
552    let args = msg.get("arguments").cloned().unwrap_or(Value::Null);
553    tracing::trace!(
554        target: "zshrs::dap::recv",
555        seq = req_seq,
556        %cmd,
557        "request",
558    );
559
560    match cmd.as_str() {
561        "initialize" => {
562            let _ = shared.emit_response(
563                req_seq,
564                &cmd,
565                true,
566                json!({
567                    "supportsConfigurationDoneRequest": true,
568                    "supportsEvaluateForHovers": true,
569                    "supportsTerminateRequest": true,
570                    "supportsStepBack": false,
571                    "supportsSetVariable": false,
572                    "supportsConditionalBreakpoints": false,
573                    "supportsHitConditionalBreakpoints": false,
574                    "supportsFunctionBreakpoints": false,
575                    "supportsRestartFrame": false,
576                    "supportsGotoTargetsRequest": false,
577                    "supportsStepInTargetsRequest": false,
578                    "supportsCompletionsRequest": false,
579                    "supportsModulesRequest": false,
580                    "supportsExceptionInfoRequest": false,
581                }),
582            );
583            let _ = shared.emit_event("initialized", json!({}));
584        }
585        "setBreakpoints" => {
586            let path = args["source"]["path"].as_str().unwrap_or("").to_string();
587            // Canonicalize so `check_line` lookup hits regardless of
588            // relative vs absolute path differences between IDE +
589            // executor.
590            let canon_path = std::fs::canonicalize(&path)
591                .map(|p| p.to_string_lossy().into_owned())
592                .unwrap_or_else(|_| path.clone());
593            let mut lines: Vec<u32> = Vec::new();
594            let mut verified = Vec::new();
595            if let Some(arr) = args["breakpoints"].as_array() {
596                for b in arr {
597                    if let Some(l) = b["line"].as_u64() {
598                        lines.push(l as u32);
599                        verified.push(json!({ "verified": true, "line": l }));
600                    }
601                }
602            }
603            tracing::info!(
604                target: "zshrs::dap::breakpoints",
605                path = %canon_path,
606                count = lines.len(),
607                lines = ?lines,
608                "registered",
609            );
610            if let Ok(mut bp) = bp_state.lock() {
611                if !canon_path.is_empty() {
612                    bp.line_breakpoints.insert(canon_path, lines);
613                }
614            }
615            let _ = shared.emit_response(req_seq, &cmd, true, json!({ "breakpoints": verified }));
616        }
617        "setExceptionBreakpoints" => {
618            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
619        }
620        "configurationDone" => {
621            shared.configuration_done.store(true, Ordering::SeqCst);
622            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
623        }
624        "launch" => {
625            let program_raw = args["program"].as_str().unwrap_or("").to_string();
626            // Canonicalize so it matches the canonicalized path the
627            // setBreakpoints handler stored.
628            let program = std::fs::canonicalize(&program_raw)
629                .map(|p| p.to_string_lossy().into_owned())
630                .unwrap_or(program_raw);
631            let cwd = args["cwd"].as_str().map(|s| s.to_string());
632            let lp_args: Vec<String> = args["args"]
633                .as_array()
634                .map(|a| {
635                    a.iter()
636                        .filter_map(|v| v.as_str().map(String::from))
637                        .collect()
638                })
639                .unwrap_or_default();
640            let stop_on_entry = args["stopOnEntry"].as_bool().unwrap_or(false);
641            // Mark launched HERE, not on the executor thread: from the
642            // moment this send happens the main thread is committed to
643            // running the script and will never come back to
644            // `launch_rx.recv()`, so a `disconnect` racing the executor
645            // startup still needs the watchdog. Setting the flag next to
646            // `execute_script_file` left a window (ShellExecutor::new,
647            // fd capture install) in which disconnect saw `false` and
648            // spawned nothing.
649            shared.launched.store(true, Ordering::SeqCst);
650            // The program path is recorded before the response goes out: a client
651            // may send `stackTrace` the moment it sees the reply, and the main
652            // thread has not necessarily taken the launch parameters yet, so
653            // that request read an empty path.
654            if let Ok(mut p) = shared.program.lock() {
655                *p = program.clone();
656            }
657            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
658            let _ = launch_tx.send(LaunchParams {
659                program,
660                cwd,
661                args: lp_args,
662                stop_on_entry,
663            });
664        }
665        "threads" => {
666            let _ = shared.emit_response(
667                req_seq,
668                &cmd,
669                true,
670                json!({ "threads": [{ "id": 1, "name": "main" }] }),
671            );
672        }
673        "stackTrace" => {
674            // v1: synthesize a single frame from the launched program +
675            // current $LINENO. Multi-frame call-stack walk-back is
676            // future work (needs funcstack reflection).
677            let program = shared.program.lock().map(|g| g.clone()).unwrap_or_default();
678            let line = current_lineno();
679            let frames = vec![json!({
680                "id": 1,
681                "name": "main",
682                "source": { "path": program, "name": file_name(&program) },
683                "line": line,
684                "column": 1,
685            })];
686            let _ = shared.emit_response(
687                req_seq,
688                &cmd,
689                true,
690                json!({ "stackFrames": frames, "totalFrames": 1 }),
691            );
692        }
693        "scopes" => {
694            // Three separate scopes so IntelliJ renders them as
695            // collapsible groups in this fixed order — `Locals` (user
696            // vars) first, then `Specials` (zsh PM_SPECIAL params),
697            // then `Environment` (caps-name env vars). One big scope
698            // gets alpha-sorted client-side, burying the user's vars
699            // under 300 env entries. The IDE's sort toggle still works
700            // WITHIN each scope but the groups themselves are stable.
701            let _ = shared.emit_response(
702                req_seq,
703                &cmd,
704                true,
705                json!({
706                    "scopes": [
707                        { "name": "Locals",      "variablesReference": 1, "expensive": false, "presentationHint": "locals" },
708                        { "name": "Specials",    "variablesReference": 2, "expensive": false },
709                        { "name": "Environment", "variablesReference": 3, "expensive": false },
710                    ],
711                }),
712            );
713        }
714        "variables" => {
715            let r = args["variablesReference"].as_u64().unwrap_or(1);
716            let vars = match r {
717                1 => snapshot_user_vars(),
718                2 => snapshot_special_vars(),
719                3 => snapshot_env_vars(),
720                _ => snapshot_user_vars(),
721            };
722            let _ = shared.emit_response(req_seq, &cmd, true, json!({ "variables": vars }));
723        }
724        "evaluate" => {
725            let expr = args["expression"].as_str().unwrap_or("");
726            let (result, ty) = evaluate_expression(expr);
727            let _ = shared.emit_response(
728                req_seq,
729                &cmd,
730                true,
731                json!({
732                    "result": result,
733                    "type": ty,
734                    "variablesReference": 0,
735                }),
736            );
737        }
738        "continue" => {
739            let _ =
740                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
741            shared.resume_with(DebugAction::Continue);
742        }
743        "next" => {
744            let _ =
745                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
746            shared.resume_with(DebugAction::StepOver);
747        }
748        "stepIn" => {
749            let _ =
750                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
751            shared.resume_with(DebugAction::StepIn);
752        }
753        "stepOut" => {
754            let _ =
755                shared.emit_response(req_seq, &cmd, true, json!({ "allThreadsContinued": true }));
756            shared.resume_with(DebugAction::StepOut);
757        }
758        "pause" => {
759            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
760            shared.request_pause();
761        }
762        "disconnect" | "terminate" => {
763            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
764            shared.disconnected.store(true, Ordering::SeqCst);
765            shared.resume_with(DebugAction::Quit);
766            // Before `launch`, that is enough: the reader loop breaks on
767            // `disconnected`, drops `launch_tx`, and the main thread
768            // falls out of `launch_rx.recv()` and returns.
769            //
770            // After `launch` the script owns the main thread. Setting
771            // `errflag` only halts it at the next VM safe point, and the
772            // executor may be parked in `waitpid` on an external command
773            // (`sleep 30`) that will not return for a long time — so the
774            // adapter would sit there long after the IDE let go, holding
775            // the debuggee alive with it. DAP requires the adapter to
776            // shut down promptly on `disconnect`, and with
777            // `terminateDebuggee` (the default for `launch` sessions) to
778            // take the debuggee with it. Hand that to a watchdog.
779            if shared.launched.load(Ordering::SeqCst) {
780                let terminate_debuggee = args["terminateDebuggee"].as_bool().unwrap_or(true);
781                thread::spawn(move || terminate_debuggee_watchdog(terminate_debuggee));
782            }
783        }
784        "source" => {
785            let _ = shared.emit_response(req_seq, &cmd, true, json!({}));
786        }
787        _ => {
788            tracing::debug!(target: "zshrs::dap", %cmd, "unsupported request");
789            let _ = shared.emit_response(req_seq, &cmd, false, json!({ "error": "unsupported" }));
790        }
791    }
792}
793
794/// Grace period between `disconnect` and the hard exit. Long enough for
795/// an executor parked at a `check_line` cv-wait to observe `Quit` and
796/// unwind on its own (the tidy path, which still runs `always` blocks
797/// and EXIT traps), short enough that an IDE never waits on us.
798const DISCONNECT_GRACE: Duration = Duration::from_millis(300);
799
800/// Watchdog spawned by `disconnect` / `terminate` once the script is
801/// running. Waits [`DISCONNECT_GRACE`] for the executor to unwind by
802/// itself; if the process is still here after that (the executor may be
803/// blocked in a `waitpid` on a child, or anywhere else the `errflag`
804/// signal cannot reach), takes the debuggee down and exits the adapter.
805///
806/// The takedown is two-part because the two kinds of child live in
807/// different places: [`kill_live_jobs`] SIGKILLs the pids the shell's
808/// job table knows about, and [`terminate_process_group`] SIGTERMs the
809/// process group for the foreground children that never reach the job
810/// table.
811///
812/// `terminate_debuggee` mirrors the request's `terminateDebuggee`
813/// field: false leaves the children running (DAP "detach" semantics)
814/// but still shuts the adapter down, because a disconnected adapter
815/// has nobody to talk to either way.
816#[cfg(unix)]
817fn terminate_debuggee_watchdog(terminate_debuggee: bool) {
818    thread::sleep(DISCONNECT_GRACE);
819    if terminate_debuggee {
820        kill_live_jobs();
821        terminate_process_group();
822    }
823    tracing::info!(
824        target: "zshrs::dap",
825        terminate_debuggee,
826        "disconnect watchdog: grace period elapsed, exiting adapter",
827    );
828    let _ = io::Write::flush(&mut io::stdout());
829    let _ = io::Write::flush(&mut io::stderr());
830    std::process::exit(0);
831}
832
833#[cfg(not(unix))]
834fn terminate_debuggee_watchdog(_terminate_debuggee: bool) {
835    thread::sleep(DISCONNECT_GRACE);
836    std::process::exit(0);
837}
838
839/// True when [`claim_process_group`] succeeded, i.e. every process the
840/// debuggee forks is in a process group this adapter leads and nothing
841/// else is.
842static OWN_PROCESS_GROUP: AtomicBool = AtomicBool::new(false);
843
844/// Put the adapter (and therefore everything the debugged script forks)
845/// into its own process group, so `terminateDebuggee` can take the whole
846/// tree down with one `killpg`.
847///
848/// Needed because the shell's job table only records pids for jobs that
849/// went through `addproc` — a foreground external command in a
850/// non-interactive script does not, so `kill_live_jobs` alone cannot see
851/// a `sleep 30` the script is sitting in, and the debuggee would outlive
852/// the session.
853///
854/// Skipped when stdin is a terminal: moving out of the terminal's
855/// foreground process group would make any read from it raise SIGTTIN.
856/// Under an IDE (and in the tests) stdin is a pipe or /dev/null, so the
857/// group is claimed; a human running `zshrs --dap` by hand at a terminal
858/// keeps the old behaviour and falls back to the job-table walk.
859#[cfg(unix)]
860fn claim_process_group() {
861    use std::io::IsTerminal;
862    if io::stdin().is_terminal() {
863        tracing::debug!(target: "zshrs::dap", "stdin is a tty; not claiming a process group");
864        return;
865    }
866    // SAFETY: setpgid(0, 0) on ourselves; fails only with EPERM when we
867    // are already a session leader, which is reported, not fatal.
868    if unsafe { libc::setpgid(0, 0) } == 0 {
869        OWN_PROCESS_GROUP.store(true, Ordering::SeqCst);
870        tracing::info!(target: "zshrs::dap", pgid = std::process::id(), "own process group claimed");
871    } else {
872        tracing::warn!(
873            target: "zshrs::dap",
874            err = %io::Error::last_os_error(),
875            "setpgid failed; terminateDebuggee limited to job-table pids",
876        );
877    }
878}
879
880/// SIGTERM the debuggee's process group.
881///
882/// Only runs when [`claim_process_group`] took ownership, so the group
883/// contains exactly this adapter and the processes the debugged script
884/// forked — never the IDE or the shell that launched us.
885///
886/// SIGTERM (not SIGKILL) because the signal necessarily reaches this
887/// process too and SIGKILL cannot be ignored: the adapter ignores
888/// SIGTERM for the instant it takes to deliver, and children — which
889/// inherited the DEFAULT disposition when they were forked, before this
890/// ignore was installed — die from it.
891#[cfg(unix)]
892fn terminate_process_group() {
893    if !OWN_PROCESS_GROUP.load(Ordering::SeqCst) {
894        return;
895    }
896    // SAFETY: signal(2) + killpg(2) on our own process group.
897    unsafe {
898        libc::signal(libc::SIGTERM, libc::SIG_IGN);
899        let pgid = libc::getpgrp();
900        tracing::info!(target: "zshrs::dap", pgid, "SIGTERM debuggee process group");
901        libc::killpg(pgid, libc::SIGTERM);
902    }
903}
904
905/// SIGKILL every process the shell's job table still lists as ours.
906///
907/// This is the DAP `terminateDebuggee` action for the in-process model:
908/// the "debuggee" is this process plus whatever external commands the
909/// script has forked, and the background ones among those are the pids
910/// recorded in `ported::jobs::JOBTAB` (`Src/jobs.c` `addproc`). A job
911/// already marked DONE is skipped, and our own pid is never signalled.
912/// Foreground children never reach `addproc`, which is what
913/// [`terminate_process_group`] covers.
914///
915/// SIGKILL rather than the SIGHUP that `signals::killrunjobs` sends:
916/// that walk is gated on the HUP option and on STAT_LOCKED background
917/// jobs, neither of which holds for the foreground child of a
918/// non-interactive debug session.
919#[cfg(unix)]
920fn kill_live_jobs() {
921    let my_pid = unsafe { libc::getpid() };
922    let Some(tab) = crate::ported::jobs::JOBTAB.get() else {
923        return;
924    };
925    let Ok(tab) = tab.lock() else {
926        return;
927    };
928    for job in tab.iter() {
929        if (job.stat & crate::ported::jobs::stat::DONE) != 0 {
930            continue;
931        }
932        for proc_ in job.procs.iter().chain(job.auxprocs.iter()) {
933            if proc_.pid > 0 && proc_.pid != my_pid {
934                tracing::info!(target: "zshrs::dap", pid = proc_.pid, "SIGKILL debuggee child");
935                unsafe { libc::kill(proc_.pid, libc::SIGKILL) };
936            }
937        }
938    }
939}
940
941/// Redirect fd 1 / fd 2 into pipes and re-emit everything written to
942/// them as DAP `output` events (`category: "stdout"` / `"stderr"`).
943///
944/// The adapter runs the script IN-PROCESS, so "the program's output" is
945/// literally this process's stdout/stderr plus that of any command it
946/// forks — and a DAP client has no other way to see it. Per the DAP
947/// spec, `output` events are how a debug adapter reports program output
948/// unless `runInTerminal` is used, and a client attached over TCP (VS
949/// Code, or anything that is not also reading our pipes) shows nothing
950/// at all without them.
951///
952/// fd-level (`dup2`) rather than capturing Rust's `io::stdout` so that
953/// forked children — external commands the script runs — are captured
954/// too: they inherit fd 1 / fd 2, not a Rust handle.
955///
956/// Returns `None` if the pipes cannot be created, in which case output
957/// keeps going straight to the adapter's own stdout/stderr (the old
958/// behaviour) rather than being lost.
959#[cfg(unix)]
960fn install_output_capture(shared: &Arc<DapShared>) -> Option<OutputCapture> {
961    use std::os::unix::io::FromRawFd;
962
963    fn make_pipe() -> Option<(i32, i32)> {
964        let mut fds = [0i32; 2];
965        // SAFETY: `fds` is a valid 2-element array, the only contract pipe(2) has.
966        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
967            return None;
968        }
969        Some((fds[0], fds[1]))
970    }
971
972    let (out_r, out_w) = make_pipe()?;
973    let (err_r, err_w) = make_pipe()?;
974
975    // SAFETY: plain fd bookkeeping on fds we own.
976    let (saved_out, saved_err) = unsafe {
977        let saved_out = libc::dup(libc::STDOUT_FILENO);
978        let saved_err = libc::dup(libc::STDERR_FILENO);
979        libc::dup2(out_w, libc::STDOUT_FILENO);
980        libc::dup2(err_w, libc::STDERR_FILENO);
981        // fd 1 / fd 2 are now the only references to the write ends;
982        // restore() closing them is what gives the readers their EOF.
983        libc::close(out_w);
984        libc::close(err_w);
985        (saved_out, saved_err)
986    };
987
988    let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
989    let mut pump = |fd: i32, category: &'static str| {
990        let shared = Arc::clone(shared);
991        let done = done_tx.clone();
992        thread::spawn(move || {
993            // SAFETY: `fd` is a pipe read end this function just created
994            // and hands to exactly one thread.
995            let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
996            let mut buf = [0u8; 8192];
997            loop {
998                match file.read(&mut buf) {
999                    Ok(0) | Err(_) => break,
1000                    Ok(n) => {
1001                        let text = String::from_utf8_lossy(&buf[..n]).into_owned();
1002                        // `write_message`, NOT `emit_event`: emit_event
1003                        // logs through `tracing`, and if a subscriber is
1004                        // ever pointed at stderr that log line lands back
1005                        // in the pipe this thread is draining — one
1006                        // chunk in, one log line out, forever. Framing
1007                        // the event by hand keeps the pump free of any
1008                        // path that can write to fd 1 / fd 2.
1009                        let _ = shared.write_message(json!({
1010                            "seq": shared.next_seq(),
1011                            "type": "event",
1012                            "event": "output",
1013                            "body": { "category": category, "output": text },
1014                        }));
1015                    }
1016                }
1017            }
1018            let _ = done.send(());
1019        })
1020    };
1021    pump(out_r, "stdout");
1022    pump(err_r, "stderr");
1023    drop(done_tx);
1024
1025    Some(OutputCapture {
1026        saved_out,
1027        saved_err,
1028        done_rx,
1029    })
1030}
1031
1032#[cfg(not(unix))]
1033fn install_output_capture(_shared: &Arc<DapShared>) -> Option<OutputCapture> {
1034    None
1035}
1036
1037/// Live fd-redirection installed by [`install_output_capture`].
1038struct OutputCapture {
1039    /// dup of the original fd 1, restored by [`OutputCapture::restore`].
1040    saved_out: i32,
1041    /// dup of the original fd 2.
1042    saved_err: i32,
1043    /// Both pump threads send on this when they hit EOF.
1044    done_rx: std::sync::mpsc::Receiver<()>,
1045}
1046
1047impl OutputCapture {
1048    /// Put fd 1 / fd 2 back and wait for the pump threads to drain.
1049    ///
1050    /// Restoring closes the last write-end reference, which is what ends
1051    /// the pumps — unless a background child of the script still holds
1052    /// an inherited copy, so the drain wait is bounded and the threads
1053    /// are left detached if it expires.
1054    #[cfg(unix)]
1055    fn restore(self) {
1056        let _ = io::Write::flush(&mut io::stdout());
1057        let _ = io::Write::flush(&mut io::stderr());
1058        // SAFETY: both saved fds came from `dup` in install_output_capture.
1059        unsafe {
1060            libc::dup2(self.saved_out, libc::STDOUT_FILENO);
1061            libc::dup2(self.saved_err, libc::STDERR_FILENO);
1062            libc::close(self.saved_out);
1063            libc::close(self.saved_err);
1064        }
1065        for _ in 0..2 {
1066            if self
1067                .done_rx
1068                .recv_timeout(Duration::from_millis(250))
1069                .is_err()
1070            {
1071                break;
1072            }
1073        }
1074    }
1075
1076    #[cfg(not(unix))]
1077    fn restore(self) {}
1078}
1079
1080/// Read the current `$LINENO` via paramtab — same path BUILTIN_SET_LINENO
1081/// writes to. Default to 1 when unset.
1082fn current_lineno() -> u32 {
1083    crate::ported::params::paramtab()
1084        .read()
1085        .ok()
1086        .and_then(|t| t.get("LINENO").map(|pm| pm.u_val as u32))
1087        .unwrap_or(1)
1088}
1089
1090/// Buckets all paramtab entries into (user, specials, env) by zsh
1091/// `PM_SPECIAL` flag + caps-name + process-env presence. Returned
1092/// tuples are sorted alpha within each bucket. Used by the three
1093/// `snapshot_*_vars` helpers so each scope returns just its bucket.
1094///
1095/// Splitting into separate scopes (instead of one big list) means
1096/// IntelliJ renders them as collapsible groups in fixed order, even
1097/// when the user has "Sort Values Alphabetically" enabled — that
1098/// toggle only sorts WITHIN a scope, not across scopes.
1099fn snapshot_bucketed() -> (
1100    Vec<(String, String)>,
1101    Vec<(String, String)>,
1102    Vec<(String, String)>,
1103) {
1104    let mut user: Vec<(String, String)> = Vec::new();
1105    let mut specials: Vec<(String, String)> = Vec::new();
1106    let mut env: Vec<(String, String)> = Vec::new();
1107
1108    if let Ok(tab) = crate::ported::params::paramtab().read() {
1109        for (name, pm) in tab.iter() {
1110            if matches!(
1111                name.as_str(),
1112                "_" | "PIPESTATUS" | "pipestatus" | "ZSH_ARGZERO"
1113            ) {
1114                continue;
1115            }
1116            let value = if pm.u_val != 0
1117                && (pm.node.flags & crate::ported::zsh_h::PM_INTEGER as i32) != 0
1118            {
1119                pm.u_val.to_string()
1120            } else if let Some(s) = pm.u_str.as_ref() {
1121                s.clone()
1122            } else {
1123                String::new()
1124            };
1125            let is_special = (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0;
1126            // Environment FIRST — most users think of PATH/HOME/USER
1127            // as env vars even though zsh marks them PM_SPECIAL. The
1128            // process-env presence is what makes them env, not the
1129            // zsh flag.
1130            let in_process_env = std::env::var(name).is_ok();
1131            // Caps-only names not in env → zsh internal specials
1132            // bucket (CPUTYPE, MACHTYPE, OSTYPE, HOST, etc).
1133            let is_caps_only = !name.is_empty()
1134                && name
1135                    .chars()
1136                    .all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
1137                && name
1138                    .chars()
1139                    .next()
1140                    .map(|c| c.is_ascii_uppercase())
1141                    .unwrap_or(false);
1142            if in_process_env {
1143                env.push((name.clone(), value));
1144            } else if is_special || is_caps_only {
1145                specials.push((name.clone(), value));
1146            } else {
1147                user.push((name.clone(), value));
1148            }
1149        }
1150    }
1151    if user.is_empty() && specials.is_empty() && env.is_empty() {
1152        for (k, v) in std::env::vars() {
1153            env.push((k, v));
1154        }
1155    }
1156    user.sort_by(|a, b| a.0.cmp(&b.0));
1157    specials.sort_by(|a, b| a.0.cmp(&b.0));
1158    env.sort_by(|a, b| a.0.cmp(&b.0));
1159    (user, specials, env)
1160}
1161
1162fn vars_to_json(vars: &[(String, String)]) -> Vec<Value> {
1163    vars.iter()
1164        .take(500)
1165        .map(|(n, v)| {
1166            json!({
1167                "name": n,
1168                "value": v,
1169                "type": "scalar",
1170                "variablesReference": 0,
1171            })
1172        })
1173        .collect()
1174}
1175
1176fn snapshot_user_vars() -> Vec<Value> {
1177    let (user, _, _) = snapshot_bucketed();
1178    vars_to_json(&user)
1179}
1180
1181fn snapshot_special_vars() -> Vec<Value> {
1182    let (_, specials, _) = snapshot_bucketed();
1183    vars_to_json(&specials)
1184}
1185
1186fn snapshot_env_vars() -> Vec<Value> {
1187    let (_, _, env) = snapshot_bucketed();
1188    vars_to_json(&env)
1189}
1190
1191fn evaluate_expression(expr: &str) -> (String, String) {
1192    // v1: run a fresh subshell to evaluate. Doesn't see the paused
1193    // executor's local scope, but works for `$VAR` / `$(cmd)` / `$((expr))`.
1194    let exe = std::env::current_exe().unwrap_or_else(|_| "zshrs".into());
1195    let mut cmd = Command::new(exe);
1196    cmd.arg("-c").arg(expr);
1197    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1198    match cmd.output() {
1199        Ok(o) => {
1200            if o.status.success() {
1201                let s = String::from_utf8_lossy(&o.stdout).trim_end().to_string();
1202                (s, "scalar".into())
1203            } else {
1204                let s = String::from_utf8_lossy(&o.stderr).trim_end().to_string();
1205                (s, "error".into())
1206            }
1207        }
1208        Err(e) => (format!("evaluate: {}", e), "error".into()),
1209    }
1210}
1211// ── Framing ──────────────────────────────────────────────────────────────
1212
1213fn read_message<R: BufRead>(reader: &mut R) -> io::Result<Option<Value>> {
1214    let mut content_length: Option<usize> = None;
1215    loop {
1216        let mut line = String::new();
1217        let n = reader.read_line(&mut line)?;
1218        if n == 0 {
1219            return Ok(None);
1220        }
1221        if line == "\r\n" || line == "\n" {
1222            break;
1223        }
1224        if let Some(rest) = line.strip_prefix("Content-Length:") {
1225            content_length =
1226                Some(rest.trim().parse().map_err(|_| {
1227                    io::Error::new(io::ErrorKind::InvalidData, "bad Content-Length")
1228                })?);
1229        }
1230    }
1231    let len = content_length
1232        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
1233    let mut buf = vec![0u8; len];
1234    reader.read_exact(&mut buf)?;
1235    let v: Value =
1236        serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1237    Ok(Some(v))
1238}
1239
1240fn write_message<W: Write>(mut writer: W, msg: &Value) -> io::Result<()> {
1241    let body = serde_json::to_vec(msg)?;
1242    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
1243    writer.write_all(&body)?;
1244    writer.flush()
1245}
1246
1247fn file_name(path: &str) -> String {
1248    path.rsplit_once('/')
1249        .map(|x| x.1)
1250        .unwrap_or(path)
1251        .to_string()
1252}