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}