feat: add tunnel start/stop with PID file tracking

Implements build_autossh_cmd, start_tunnel, and stop_tunnel in process.py.
Adds _reap() helper to handle zombie processes in test environments where
the parent process is long-lived (SIGKILL leaves zombies unless waitpid is called).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 12:19:06 +08:00
parent 8a2e4b6cc0
commit 5218431349
2 changed files with 187 additions and 0 deletions

View File

@@ -1,5 +1,9 @@
import os
import shutil
import signal
import socket
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -7,6 +11,8 @@ from typing import Optional
import click
from .config import TunnelConfig
@dataclass
class TunnelStatus:
@@ -87,3 +93,99 @@ def get_status(config_dir: Path, name: str) -> TunnelStatus:
if not is_process_alive(pid):
return TunnelStatus(state="stale", pid=pid)
return TunnelStatus(state="running", pid=pid, uptime=format_uptime(started_at))
def build_autossh_cmd(tunnel: TunnelConfig) -> list[str]:
cmd = [
"autossh", "-M", "0", "-N",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-i", os.path.expanduser(tunnel.identity_file),
"-p", str(tunnel.port),
"-R", (
f"{tunnel.remote_host}:{tunnel.remote_port}"
f":{tunnel.local_host}:{tunnel.local_port}"
),
f"{tunnel.user}@{tunnel.host}",
]
if tunnel.ssh_options:
for opt in tunnel.ssh_options.split():
cmd.append(opt)
return cmd
def _is_port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
def start_tunnel(config_dir: Path, tunnel: TunnelConfig) -> int:
if not shutil.which("autossh"):
raise click.ClickException("autossh not found. Install it first.")
if _is_port_in_use(tunnel.local_port):
raise click.ClickException(f"Port {tunnel.local_port} is already in use")
log_path = config_dir / "logs" / f"{tunnel.name}.log"
env = os.environ.copy()
env["AUTOSSH_GATETIME"] = "0"
with open(log_path, "a") as log:
proc = subprocess.Popen(
build_autossh_cmd(tunnel),
stdin=subprocess.DEVNULL,
stdout=log,
stderr=log,
env=env,
)
started_at = datetime.now(timezone.utc)
write_pid_file(config_dir, tunnel.name, proc.pid, started_at)
time.sleep(1)
if not is_process_alive(proc.pid):
delete_pid_file(config_dir, tunnel.name)
raise click.ClickException(
f"autossh process for '{tunnel.name}' exited immediately. "
f"Check logs: {log_path}"
)
return proc.pid
def _reap(pid: int) -> None:
"""Best-effort waitpid to reap a zombie (only works if we are the parent)."""
try:
os.waitpid(pid, 0)
except (ChildProcessError, PermissionError, OSError):
pass
def stop_tunnel(config_dir: Path, name: str) -> None:
result = read_pid_file(config_dir, name)
if result is None:
return
pid, _ = result
if not is_process_alive(pid):
delete_pid_file(config_dir, name)
return
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
delete_pid_file(config_dir, name)
return
for _ in range(50):
time.sleep(0.1)
if not is_process_alive(pid):
_reap(pid)
break
else:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
_reap(pid)
delete_pid_file(config_dir, name)