Skip to main content

perl_dap/
bridge_adapter.rs

1//! Bridge adapter for Perl::LanguageServer DAP
2//!
3//! This module provides a bridge between VS Code's DAP client and Perl::LanguageServer's
4//! DAP implementation. It proxies messages via stdio, enabling immediate debugging capability
5//! while the native Rust adapter is developed.
6//!
7//! # Architecture
8//!
9//! ```text
10//! VS Code ↔ BridgeAdapter (Rust) ↔ Perl::LanguageServer (Perl)
11//!          (stdio)                  (stdio)
12//! ```
13//!
14//! # Usage
15//!
16//! ```no_run
17//! use perl_dap::BridgeAdapter;
18//!
19//! # #[tokio::main]
20//! # async fn main() -> anyhow::Result<()> {
21//! let mut adapter = BridgeAdapter::new();
22//! adapter.spawn_pls_dap().await?;
23//! adapter.proxy_messages().await?;
24//! adapter.shutdown().await?;
25//! # Ok(())
26//! # }
27//! ```
28
29use anyhow::{Context, Result};
30#[cfg(unix)]
31use nix::sys::signal::{self, Signal};
32#[cfg(unix)]
33use nix::unistd::Pid;
34use perl_lsp_rs_core::config::PerlOracleEnv;
35use std::path::PathBuf;
36use std::process::Stdio;
37use std::time::{Duration, Instant};
38use tokio::io::AsyncWriteExt;
39use tokio::process::{Child, Command};
40use tokio::time::sleep;
41
42const PLS_SHUTDOWN_GRACE_MS: u64 = 250;
43const PLS_SHUTDOWN_POLL_MS: u64 = 25;
44const PROXY_EXIT_POLL_MS: u64 = 25;
45
46/// Perl debugger flag to activate DAP protocol mode in Perl::LanguageServer
47const PLS_DAP_FLAG: &str = "-d:LanguageServer::DAP";
48
49/// Environment passthrough policy for the Perl::LanguageServer DAP bridge.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[non_exhaustive]
52pub struct DapBridgeEnvConfig {
53    /// Pass parent `PERL5LIB` through to the bridged Perl process.
54    pub perl5lib_passthrough: bool,
55    /// Pass parent `PERL5OPT` through to the bridged Perl process.
56    pub perl5opt_passthrough: bool,
57}
58
59impl DapBridgeEnvConfig {
60    /// Create an explicit bridge environment policy.
61    pub const fn new(perl5lib_passthrough: bool, perl5opt_passthrough: bool) -> Self {
62        Self { perl5lib_passthrough, perl5opt_passthrough }
63    }
64}
65
66/// Bridge adapter that proxies DAP messages to Perl::LanguageServer
67///
68/// This adapter spawns Perl::LanguageServer in DAP mode and forwards
69/// all DAP protocol messages bidirectionally via stdio.
70pub struct BridgeAdapter {
71    /// The spawned Perl::LanguageServer process
72    child_process: Option<Child>,
73    /// Env passthrough policy for the bridge subprocess.
74    env_config: DapBridgeEnvConfig,
75}
76
77impl BridgeAdapter {
78    /// Create a new bridge adapter
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use perl_dap::BridgeAdapter;
84    ///
85    /// let adapter = BridgeAdapter::new();
86    /// ```
87    pub fn new() -> Self {
88        Self::with_env_config(DapBridgeEnvConfig::default())
89    }
90
91    /// Create a bridge adapter with an explicit subprocess environment policy.
92    pub fn with_env_config(env_config: DapBridgeEnvConfig) -> Self {
93        Self { child_process: None, env_config }
94    }
95
96    /// Spawn Perl::LanguageServer in DAP mode
97    ///
98    /// This method starts the Perl::LanguageServer process with DAP protocol support.
99    /// It uses the platform-specific perl binary resolution.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if:
104    /// - Perl binary cannot be found on PATH
105    /// - Perl::LanguageServer module is not installed
106    /// - Process spawn fails
107    ///
108    /// # Examples
109    ///
110    /// ```no_run
111    /// use perl_dap::BridgeAdapter;
112    ///
113    /// # #[tokio::main]
114    /// # async fn main() -> anyhow::Result<()> {
115    /// let mut adapter = BridgeAdapter::new();
116    /// adapter.spawn_pls_dap().await?;
117    /// # Ok(())
118    /// # }
119    /// ```
120    pub async fn spawn_pls_dap(&mut self) -> Result<()> {
121        // Ensure any existing process is cleaned up
122        if self.child_process.is_some() {
123            let _ = self.shutdown().await;
124        }
125
126        // Find perl binary using platform module
127        let perl_path =
128            crate::platform::resolve_perl_path().context("Failed to find perl binary on PATH")?;
129
130        // Spawn Perl::LanguageServer in DAP mode
131        let child = self
132            .build_pls_dap_command(perl_path)
133            .arg(PLS_DAP_FLAG)
134            .stdin(Stdio::piped())
135            .stdout(Stdio::piped())
136            .stderr(Stdio::inherit())
137            .spawn()
138            .context("Failed to spawn Perl::LanguageServer DAP process")?;
139
140        let mut child = child;
141        if let Some(status) =
142            child.try_wait().context("Failed to check Perl::LanguageServer startup status")?
143        {
144            anyhow::bail!(
145                "Perl::LanguageServer DAP process exited immediately with status: {status}"
146            );
147        }
148
149        self.child_process = Some(child);
150        Ok(())
151    }
152
153    fn build_pls_dap_command(&self, perl_path: PathBuf) -> Command {
154        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
155        let oracle = PerlOracleEnv::for_dap_bridge(
156            perl_path,
157            cwd,
158            self.env_config.perl5lib_passthrough,
159            self.env_config.perl5opt_passthrough,
160        );
161        let mut command = Command::new(&oracle.perl_binary);
162        oracle.configure_command(command.as_std_mut());
163        command
164    }
165
166    /// Proxy messages between VS Code and Perl::LanguageServer
167    ///
168    /// This method forwards stdin/stdout bidirectionally between the DAP client
169    /// and the Perl::LanguageServer process.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if:
174    /// - Child process not spawned (call `spawn_pls_dap()` first)
175    /// - I/O error during message proxying
176    ///
177    /// # Examples
178    ///
179    /// ```no_run
180    /// use perl_dap::BridgeAdapter;
181    ///
182    /// # #[tokio::main]
183    /// # async fn main() -> anyhow::Result<()> {
184    /// let mut adapter = BridgeAdapter::new();
185    /// adapter.spawn_pls_dap().await?;
186    /// adapter.proxy_messages().await?;
187    /// adapter.shutdown().await?;
188    /// # Ok(())
189    /// # }
190    /// ```
191    pub async fn proxy_messages(&mut self) -> Result<()> {
192        // Verify child process is running
193        let Some(child) = self.child_process.as_mut() else {
194            anyhow::bail!("Child process not spawned. Call spawn_pls_dap() first.");
195        };
196
197        if let Some(status) = child
198            .try_wait()
199            .context("Failed to check Perl::LanguageServer process state before proxying")?
200        {
201            anyhow::bail!(
202                "Perl::LanguageServer DAP process exited before proxying with status: {status}"
203            );
204        }
205
206        // Get handles to child stdin/stdout
207        let mut child_stdin = child.stdin.take().context("Failed to capture child stdin")?;
208        let mut child_stdout = child.stdout.take().context("Failed to capture child stdout")?;
209
210        // Get handles to current process stdin/stdout
211        let mut parent_stdin = tokio::io::stdin();
212        let mut parent_stdout = tokio::io::stdout();
213
214        // DAP uses Content-Length framing, so we can safely proxy raw bytes
215        // without message-level inspection. The protocol is self-framing.
216        // The proxying strategy uses bidirectional tokio::io::copy for maximum efficiency.
217
218        // Create bidirectional copy tasks
219        // Task 1: Client (Parent Stdin) -> Server (Child Stdin)
220        let client_to_server = tokio::spawn(async move {
221            tokio::io::copy(&mut parent_stdin, &mut child_stdin)
222                .await
223                .context("Error copying from client to server")?;
224            // Shut down child_stdin to signal EOF to the server
225            let _ = child_stdin.shutdown().await;
226            Ok::<(), anyhow::Error>(())
227        });
228
229        // Task 2: Server (Child Stdout) -> Client (Parent Stdout)
230        let server_to_client = tokio::spawn(async move {
231            tokio::io::copy(&mut child_stdout, &mut parent_stdout)
232                .await
233                .context("Error copying from server to client")?;
234            parent_stdout.flush().await.context("Error flushing to client")?;
235            Ok::<(), anyhow::Error>(())
236        });
237
238        // JoinHandle<T> is already Unpin; no pin! macro needed here.
239        let mut client_to_server = client_to_server;
240        let mut server_to_client = server_to_client;
241
242        let mut client_result: Option<Result<()>> = None;
243        let mut server_result: Option<Result<()>> = None;
244
245        while client_result.is_none() || server_result.is_none() {
246            tokio::select! {
247                join_result = &mut client_to_server, if client_result.is_none() => {
248                    client_result = Some(Self::join_result_to_anyhow(join_result, "client->server"));
249                }
250                join_result = &mut server_to_client, if server_result.is_none() => {
251                    server_result = Some(Self::join_result_to_anyhow(join_result, "server->client"));
252                }
253                _ = sleep(Duration::from_millis(PROXY_EXIT_POLL_MS)) => {
254                    if child
255                        .try_wait()
256                        .context("Failed polling Perl::LanguageServer process during proxy")?
257                        .is_some()
258                    {
259                        if client_result.is_none() {
260                            client_to_server.abort();
261                            client_result = Some(Ok(()));
262                        }
263                        if server_result.is_none() {
264                            server_to_client.abort();
265                            server_result = Some(Ok(()));
266                        }
267                    }
268                }
269            }
270        }
271
272        // Propagate errors from both directions; log the secondary error so it isn't silently lost.
273        let client_err = client_result.and_then(|r| r.err());
274        let server_err = server_result.and_then(|r| r.err());
275        match (client_err, server_err) {
276            (Some(ce), Some(se)) => {
277                tracing::warn!(error = %se, "proxy server->client also failed");
278                return Err(ce);
279            }
280            (Some(ce), None) => return Err(ce),
281            (None, Some(se)) => return Err(se),
282            (None, None) => {}
283        }
284
285        Ok(())
286    }
287
288    /// Shutdown the bridge adapter and the Perl::LanguageServer process
289    ///
290    /// This method tries a graceful termination first and falls back to kill.
291    /// It should be used for cleanup in async contexts.
292    pub async fn shutdown(&mut self) -> Result<()> {
293        if let Some(mut child) = self.child_process.take() {
294            if !Self::wait_for_child_exit(&mut child, Duration::from_millis(0)).await {
295                #[cfg(unix)]
296                {
297                    if let Some(pid) = child.id() {
298                        if let Ok(()) = signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
299                            if Self::wait_for_child_exit(
300                                &mut child,
301                                Duration::from_millis(PLS_SHUTDOWN_GRACE_MS),
302                            )
303                            .await
304                            {
305                                return Ok(());
306                            }
307                        }
308                    }
309                }
310
311                let _ = child.kill().await;
312                if !Self::wait_for_child_exit(
313                    &mut child,
314                    Duration::from_millis(PLS_SHUTDOWN_GRACE_MS),
315                )
316                .await
317                {
318                    let _ = child.wait().await?;
319                }
320            }
321        }
322        Ok(())
323    }
324
325    async fn wait_for_child_exit(child: &mut Child, timeout: Duration) -> bool {
326        if let Ok(Some(_)) = child.try_wait() {
327            return true;
328        }
329
330        let deadline = Instant::now() + timeout;
331        while Instant::now() < deadline {
332            match child.try_wait() {
333                Ok(Some(_)) => return true,
334                Ok(None) => sleep(Duration::from_millis(PLS_SHUTDOWN_POLL_MS)).await,
335                Err(e) => {
336                    tracing::error!(error = %e, "Failed to poll Perl::LanguageServer process");
337                    return false;
338                }
339            }
340        }
341
342        false
343    }
344
345    fn join_result_to_anyhow(
346        join_result: std::result::Result<Result<()>, tokio::task::JoinError>,
347        direction: &'static str,
348    ) -> Result<()> {
349        match join_result {
350            Ok(inner) => inner,
351            Err(join_error) if join_error.is_cancelled() => Ok(()),
352            Err(join_error) => Err(anyhow::anyhow!(
353                "Proxy task {direction} panicked or failed to join: {join_error}"
354            )),
355        }
356    }
357}
358
359impl Default for BridgeAdapter {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365impl Drop for BridgeAdapter {
366    fn drop(&mut self) {
367        // Clean up child process on drop
368        // Note: In async code, drop is synchronous, so we can't await `child.kill()`
369        // But `start_kill` is non-blocking (available in newer tokio versions)
370        // or we can use the synchronous API if we held the std handle, but we don't.
371        // For tokio::process::Child, start_kill() starts the killing.
372        if let Some(mut child) = self.child_process.take() {
373            let _ = child.start_kill();
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::{BridgeAdapter, DapBridgeEnvConfig};
381    use anyhow::Result;
382    use std::path::PathBuf;
383    use std::process::Stdio;
384    use std::time::Duration;
385    use tokio::process::Command;
386
387    async fn spawn_short_lived_child() -> Result<tokio::process::Child> {
388        #[cfg(unix)]
389        {
390            let child = Command::new("sh")
391                .arg("-c")
392                .arg("exit 0")
393                .stdin(Stdio::piped())
394                .stdout(Stdio::piped())
395                .spawn()?;
396            Ok(child)
397        }
398
399        #[cfg(windows)]
400        {
401            let child = Command::new("cmd")
402                .arg("/C")
403                .arg("exit 0")
404                .stdin(Stdio::piped())
405                .stdout(Stdio::piped())
406                .spawn()?;
407            Ok(child)
408        }
409    }
410
411    async fn spawn_long_running_child() -> Result<tokio::process::Child> {
412        #[cfg(unix)]
413        {
414            let child = Command::new("sh")
415                .arg("-c")
416                .arg("sleep 30")
417                .stdin(Stdio::piped())
418                .stdout(Stdio::piped())
419                .spawn()?;
420            Ok(child)
421        }
422
423        #[cfg(windows)]
424        {
425            let child = Command::new("cmd")
426                .arg("/C")
427                .arg("timeout /T 30 /NOBREAK >NUL")
428                .stdin(Stdio::piped())
429                .stdout(Stdio::piped())
430                .spawn()?;
431            Ok(child)
432        }
433    }
434
435    #[test]
436    fn bridge_env_config_defaults_to_deny_ambient_perl_env() {
437        let adapter = BridgeAdapter::new();
438        let command = adapter.build_pls_dap_command(PathBuf::from("perl"));
439        let envs: Vec<_> = command.as_std().get_envs().collect();
440
441        assert!(
442            envs.iter().all(|(key, _)| *key != "PERL5LIB"),
443            "default bridge config must not pass PERL5LIB"
444        );
445        assert!(
446            envs.iter().all(|(key, _)| *key != "PERL5OPT"),
447            "default bridge config must not pass PERL5OPT"
448        );
449        assert!(
450            command.as_std().get_current_dir().is_some(),
451            "bridge command should set cwd explicitly"
452        );
453    }
454
455    #[test]
456    fn bridge_env_config_allows_debug_passthrough_when_opted_in() {
457        let adapter = BridgeAdapter::with_env_config(DapBridgeEnvConfig::new(true, true));
458        let oracle = perl_lsp_rs_core::config::PerlOracleEnv::for_dap_bridge(
459            PathBuf::from("perl"),
460            PathBuf::from("."),
461            adapter.env_config.perl5lib_passthrough,
462            adapter.env_config.perl5opt_passthrough,
463        );
464
465        assert!(oracle.allow_perl5lib, "opt-in bridge config should allow PERL5LIB");
466        assert!(oracle.allow_perl5opt, "opt-in bridge config should allow PERL5OPT");
467        assert!(!oracle.allow_local_lib, "bridge config should not widen local::lib");
468    }
469
470    async fn process_is_running(pid: u32) -> Result<bool> {
471        #[cfg(unix)]
472        {
473            let status = Command::new("sh")
474                .arg("-c")
475                .arg(format!("kill -0 {pid} 2>/dev/null"))
476                .stdout(Stdio::null())
477                .stderr(Stdio::null())
478                .status()
479                .await?;
480            Ok(status.success())
481        }
482
483        #[cfg(windows)]
484        {
485            let output = Command::new("tasklist")
486                .arg("/FI")
487                .arg(format!("PID eq {pid}"))
488                .arg("/NH")
489                .output()
490                .await?;
491            if !output.status.success() {
492                let stderr = String::from_utf8_lossy(&output.stderr);
493                anyhow::bail!("tasklist failed while checking child process {pid}: {stderr}");
494            }
495            Ok(String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
496        }
497    }
498
499    #[tokio::test]
500    async fn proxy_returns_error_when_child_already_exited() -> Result<()> {
501        let mut adapter = BridgeAdapter::new();
502        let mut child = spawn_short_lived_child().await?;
503        // Wait for the process to actually exit before handing it to the adapter.
504        // A sleep-based approach is flaky on slow CI (cmd.exe on Windows can take >40ms).
505        // child.wait() guarantees the exit status is available before try_wait() is called.
506        let _ = child.wait().await?;
507        adapter.child_process = Some(child);
508
509        let result =
510            tokio::time::timeout(Duration::from_millis(500), adapter.proxy_messages()).await;
511        assert!(result.is_ok(), "proxy_messages should complete quickly for exited child");
512        let proxy_result = result?;
513        assert!(proxy_result.is_err(), "proxy_messages should error for exited child");
514
515        Ok(())
516    }
517
518    #[tokio::test]
519    async fn repeated_start_stop_cycles_do_not_leave_child_processes() -> Result<()> {
520        let mut observed_pids = Vec::new();
521
522        for _ in 0..5 {
523            let mut adapter = BridgeAdapter::new();
524            let child = spawn_long_running_child().await?;
525            let pid = child
526                .id()
527                .ok_or_else(|| anyhow::anyhow!("spawned child should have a process id"))?;
528            observed_pids.push(pid);
529            adapter.child_process = Some(child);
530
531            let result = tokio::time::timeout(Duration::from_secs(2), adapter.shutdown()).await;
532            assert!(result.is_ok(), "shutdown should not hang for child process {pid}");
533            result??;
534
535            assert!(
536                !process_is_running(pid).await?,
537                "DAP bridge shutdown left child process {pid} running"
538            );
539        }
540
541        observed_pids.sort_unstable();
542        observed_pids.dedup();
543        assert_eq!(observed_pids.len(), 5, "each cycle should own a distinct child process");
544        Ok(())
545    }
546
547    #[tokio::test]
548    async fn shutdown_terminates_running_child() -> Result<()> {
549        let mut adapter = BridgeAdapter::new();
550        adapter.child_process = Some(spawn_long_running_child().await?);
551
552        let result = tokio::time::timeout(Duration::from_secs(2), adapter.shutdown()).await;
553        assert!(result.is_ok(), "shutdown should not hang for a running child process");
554        result??;
555
556        Ok(())
557    }
558
559    /// Verify the poll-arm path: proxy_messages() must not hang when the child
560    /// process exits *during* active proxying (after the pre-flight liveness check
561    /// has already passed but while the I/O copy tasks are still running).
562    ///
563    /// We use a child that sleeps briefly then exits on its own, bypassing the
564    /// pre-flight check (which only fires when the child is already dead at call
565    /// time). The select! poll arm is responsible for detecting this mid-proxy
566    /// exit and aborting the I/O tasks within PROXY_EXIT_POLL_MS + margin.
567    #[tokio::test]
568    async fn proxy_completes_when_child_exits_during_proxy() -> Result<()> {
569        // Spawn a child that stays alive briefly, then exits.
570        // We need it to survive the pre-flight try_wait() call (so it must be alive
571        // at proxy_messages() entry), then exit during the poll loop.
572        #[cfg(unix)]
573        let child = tokio::process::Command::new("sh")
574            .arg("-c")
575            .arg("sleep 0.1")
576            .stdin(Stdio::piped())
577            .stdout(Stdio::piped())
578            .spawn()?;
579
580        #[cfg(windows)]
581        let child = tokio::process::Command::new("cmd")
582            .arg("/C")
583            // ping with -n 1 exits after ~0ms; -w 100 waits 100ms for the reply
584            // (it will fail, but the process exits after the timeout).
585            .arg("ping -n 1 -w 100 127.0.0.1 >NUL")
586            .stdin(Stdio::piped())
587            .stdout(Stdio::piped())
588            .spawn()?;
589
590        let mut adapter = BridgeAdapter::new();
591        adapter.child_process = Some(child);
592
593        // Give the runtime a yield so the child can be scheduled; the child
594        // should still be alive at this point (it sleeps 100ms on Unix,
595        // 100ms ping timeout on Windows).
596        tokio::task::yield_now().await;
597
598        // proxy_messages() must return without hanging. Allow up to 5 seconds:
599        // 100ms child lifetime + PROXY_EXIT_POLL_MS (25ms) + generous CI margin.
600        let result = tokio::time::timeout(Duration::from_secs(5), adapter.proxy_messages()).await;
601
602        assert!(
603            result.is_ok(),
604            "proxy_messages should not hang when child exits mid-proxy; timed out"
605        );
606        // The function should return Ok(()) -- the child exited cleanly and the
607        // poll arm aborted the I/O tasks without an I/O error.
608        let proxy_result = result?;
609        assert!(
610            proxy_result.is_ok(),
611            "proxy_messages should return Ok(()) when child exits cleanly mid-proxy, got: {proxy_result:?}"
612        );
613
614        Ok(())
615    }
616}