perl_dap/
bridge_adapter.rs1use 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
46const PLS_DAP_FLAG: &str = "-d:LanguageServer::DAP";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[non_exhaustive]
52pub struct DapBridgeEnvConfig {
53 pub perl5lib_passthrough: bool,
55 pub perl5opt_passthrough: bool,
57}
58
59impl DapBridgeEnvConfig {
60 pub const fn new(perl5lib_passthrough: bool, perl5opt_passthrough: bool) -> Self {
62 Self { perl5lib_passthrough, perl5opt_passthrough }
63 }
64}
65
66pub struct BridgeAdapter {
71 child_process: Option<Child>,
73 env_config: DapBridgeEnvConfig,
75}
76
77impl BridgeAdapter {
78 pub fn new() -> Self {
88 Self::with_env_config(DapBridgeEnvConfig::default())
89 }
90
91 pub fn with_env_config(env_config: DapBridgeEnvConfig) -> Self {
93 Self { child_process: None, env_config }
94 }
95
96 pub async fn spawn_pls_dap(&mut self) -> Result<()> {
121 if self.child_process.is_some() {
123 let _ = self.shutdown().await;
124 }
125
126 let perl_path =
128 crate::platform::resolve_perl_path().context("Failed to find perl binary on PATH")?;
129
130 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 pub async fn proxy_messages(&mut self) -> Result<()> {
192 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 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 let mut parent_stdin = tokio::io::stdin();
212 let mut parent_stdout = tokio::io::stdout();
213
214 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 let _ = child_stdin.shutdown().await;
226 Ok::<(), anyhow::Error>(())
227 });
228
229 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 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 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 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 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 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 #[tokio::test]
568 async fn proxy_completes_when_child_exits_during_proxy() -> Result<()> {
569 #[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 .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 tokio::task::yield_now().await;
597
598 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 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}