Skip to content

Commit a38297f

Browse files
committed
fix: reclaim orphaned tailscale funnel listeners before retrying
A hard-killed wcode leaves an orphaned `tailscale funnel` child that keeps holding the node's only 443 listener, so every funnel attempt of the next run fails with "listener already exists" and the tailscale link never appears. Before each funnel start, scan for ppid-1 funnel processes forwarding to this exact local URL and reclaim them; funnels targeting anything else are left untouched.
1 parent 9182173 commit a38297f

1 file changed

Lines changed: 38 additions & 0 deletions

File tree

src/runtime/tunnel.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ fn ensure_tailscale() -> Result<()> {
406406

407407
async fn start_tailscale_funnel_once(local_url: &str) -> Result<(Child, String)> {
408408
let public_url = tailscale_funnel_url()?;
409+
reclaim_stale_funnel(local_url);
409410
let mut child = Command::new("tailscale")
410411
.args(["funnel", local_url])
411412
.stdin(std::process::Stdio::null())
@@ -503,6 +504,43 @@ async fn funnel_serving(public_url: &str) -> bool {
503504
&& String::from_utf8_lossy(&result.stdout).trim() != "000")
504505
}
505506

507+
/// A hard-killed wcode can leave an orphaned `tailscale funnel` child that
508+
/// still holds the node's only 443 listener, permanently blocking every
509+
/// funnel attempt of the next run. Reclaim orphans that forward to this
510+
/// exact local URL; anything else belongs to another operator process and
511+
/// must not be touched.
512+
fn reclaim_stale_funnel(local_url: &str) {
513+
let Ok(output) = StdCommand::new("ps")
514+
.args(["-axo", "pid=,ppid=,command="])
515+
.stdin(StdStdio::null())
516+
.stdout(StdStdio::piped())
517+
.stderr(StdStdio::null())
518+
.output()
519+
else {
520+
return;
521+
};
522+
let processes = String::from_utf8_lossy(&output.stdout);
523+
for line in processes.lines() {
524+
let mut fields = line.split_whitespace();
525+
let (Some(pid), Some(ppid)) = (fields.next(), fields.next()) else {
526+
continue;
527+
};
528+
if ppid != "1" || !line.contains("tailscale funnel") || !line.contains(local_url) {
529+
continue;
530+
}
531+
let Ok(pid) = pid.parse::<i32>() else {
532+
continue;
533+
};
534+
eprintln!(" ! tailscale reclaiming stale funnel process {pid} for {local_url}");
535+
let _ = StdCommand::new("kill")
536+
.arg(pid.to_string())
537+
.stdin(StdStdio::null())
538+
.stdout(StdStdio::null())
539+
.stderr(StdStdio::null())
540+
.status();
541+
}
542+
}
543+
506544
fn tailscale_funnel_url() -> Result<String> {
507545
let output = StdCommand::new("tailscale")
508546
.args(["status", "--json"])

0 commit comments

Comments
 (0)