From 7fc954506c11e6bba0003ecc6b883b5787e5bf8a Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 12:29:19 -0700 Subject: [PATCH 01/25] better tunnel error reporting --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 38de07845..d9613663d 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -242,6 +242,9 @@ async def _cleanup(self) -> None: def recent_output(self) -> list[str]: """Last N lines of frpc output (thread-safe). Falls back to startup output.""" if hasattr(self, "_output_lock"): + if not self.is_running and hasattr(self, "_drain_threads"): + for t in self._drain_threads: + t.join(timeout=2.0) with self._output_lock: return list(self._recent_output) return list(self._output_lines) @@ -256,7 +259,7 @@ def _start_pipe_drain(self) -> None: if self._process is None: return - self._recent_output: list[str] = [] + self._recent_output: list[str] = list(self._output_lines) self._output_lock = threading.Lock() max_lines = 50 @@ -275,8 +278,11 @@ def drain_pipe(pipe): except (OSError, ValueError): pass # Pipe closed + self._drain_threads: list[threading.Thread] = [] for pipe in (self._process.stdout, self._process.stderr): - threading.Thread(target=drain_pipe, args=(pipe,), daemon=True).start() + t = threading.Thread(target=drain_pipe, args=(pipe,), daemon=True) + t.start() + self._drain_threads.append(t) def _write_frpc_config(self) -> Path: """Generate and write frpc configuration file.""" From cae287ce5f1df67a18f9bcc7376322daf1ab0e3c Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 12:36:09 -0700 Subject: [PATCH 02/25] max lines --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index d9613663d..9a46ba615 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -259,9 +259,9 @@ def _start_pipe_drain(self) -> None: if self._process is None: return - self._recent_output: list[str] = list(self._output_lines) self._output_lock = threading.Lock() max_lines = 50 + self._recent_output: list[str] = list(self._output_lines[-max_lines:]) def drain_pipe(pipe): """Read output from a pipe, retaining recent lines.""" From 201d3650aa99e79f5a8ea47cd01547e3120d60f6 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 14:03:27 -0700 Subject: [PATCH 03/25] frpc errors --- .../prime-tunnel/src/prime_tunnel/__init__.py | 10 +- .../src/prime_tunnel/core/client.py | 17 ++- .../src/prime_tunnel/exceptions.py | 31 ++++- .../prime-tunnel/src/prime_tunnel/models.py | 1 + .../prime-tunnel/src/prime_tunnel/tunnel.py | 121 +++++++++++++++--- .../prime/src/prime_cli/commands/tunnel.py | 27 ++++ 6 files changed, 181 insertions(+), 26 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/__init__.py b/packages/prime-tunnel/src/prime_tunnel/__init__.py index ff175c5d4..baa63e24d 100644 --- a/packages/prime-tunnel/src/prime_tunnel/__init__.py +++ b/packages/prime-tunnel/src/prime_tunnel/__init__.py @@ -4,9 +4,11 @@ from prime_tunnel.core import Config, TunnelClient from prime_tunnel.exceptions import ( + BinaryDownloadError, TunnelAuthError, - TunnelConnectionError, TunnelError, + TunnelLimitReachedError, + TunnelNotRunningError, TunnelTimeoutError, ) from prime_tunnel.models import TunnelInfo @@ -22,8 +24,10 @@ # Models "TunnelInfo", # Exceptions - "TunnelError", + "BinaryDownloadError", "TunnelAuthError", - "TunnelConnectionError", + "TunnelError", + "TunnelLimitReachedError", + "TunnelNotRunningError", "TunnelTimeoutError", ] diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 6c97ad838..07d4c38b4 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -10,7 +10,12 @@ ) from prime_tunnel.core.config import Config -from prime_tunnel.exceptions import TunnelAuthError, TunnelError, TunnelTimeoutError +from prime_tunnel.exceptions import ( + TunnelAuthError, + TunnelError, + TunnelLimitReachedError, + TunnelTimeoutError, +) from prime_tunnel.models import TunnelInfo # Retry configuration for transient connection errors @@ -105,6 +110,14 @@ async def _handle_response(self, response: httpx.Response, operation: str) -> Di raise TunnelAuthError("Payment required. Check billing status.") elif response.status_code == 404: return {} # Handle 404 specially in callers + elif response.status_code == 400: + try: + error_detail = response.json().get("detail", response.text) + except Exception: + error_detail = response.text + if "maximum number of" in error_detail.lower(): + raise TunnelLimitReachedError(error_detail) + raise TunnelError(f"Failed to {operation}: {error_detail}") elif response.status_code >= 400: try: error_detail = response.json().get("detail", response.text) @@ -197,6 +210,7 @@ async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: server_port=7000, expires_at=data["expires_at"], user_id=data.get("user_id"), + status=data.get("status"), ) async def delete_tunnel(self, tunnel_id: str) -> bool: @@ -280,6 +294,7 @@ async def list_tunnels(self, team_id: Optional[str] = None) -> list[TunnelInfo]: server_port=7000, expires_at=t["expires_at"], user_id=t.get("user_id"), + status=t.get("status"), ) ) return tunnels diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py index e95d2992d..d194e4c7c 100644 --- a/packages/prime-tunnel/src/prime_tunnel/exceptions.py +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -4,20 +4,43 @@ class TunnelError(Exception): pass +class TunnelNotRunningError(TunnelError): + """Structured error for tunnel failures with diagnostic fields.""" + + def __init__( + self, + tunnel_id: str | None = None, + error_type: str | None = None, + message: str | None = None, + ): + self.tunnel_id = tunnel_id + self.error_type = error_type + + if message: + msg = message + elif error_type: + msg = f"Tunnel {tunnel_id} failed ({error_type})" + elif tunnel_id: + msg = f"Tunnel {tunnel_id} is not running" + else: + msg = "Tunnel is not running" + super().__init__(msg) + + class TunnelAuthError(TunnelError): """Authentication failed when registering tunnel.""" pass -class TunnelConnectionError(TunnelError): - """Failed to establish tunnel connection.""" +class TunnelTimeoutError(TunnelError): + """Tunnel operation timed out.""" pass -class TunnelTimeoutError(TunnelError): - """Tunnel operation timed out.""" +class TunnelLimitReachedError(TunnelError): + """Tunnel quota exceeded.""" pass diff --git a/packages/prime-tunnel/src/prime_tunnel/models.py b/packages/prime-tunnel/src/prime_tunnel/models.py index 11ad2626a..5d0210eec 100644 --- a/packages/prime-tunnel/src/prime_tunnel/models.py +++ b/packages/prime-tunnel/src/prime_tunnel/models.py @@ -17,6 +17,7 @@ class TunnelInfo(BaseModel): expires_at: datetime = Field(..., description="Token expiration time") # Optional because create_tunnel response doesn't include user_id user_id: Optional[str] = Field(None, description="Owner user ID") + status: Optional[str] = Field(None, description="Current tunnel status") class Config: from_attributes = True diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 9a46ba615..777636b34 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -11,10 +11,101 @@ from prime_tunnel.binary import get_frpc_path from prime_tunnel.core.client import TunnelClient -from prime_tunnel.exceptions import TunnelConnectionError, TunnelError, TunnelTimeoutError +from prime_tunnel.exceptions import ( + TunnelError, + TunnelNotRunningError, + TunnelTimeoutError, +) from prime_tunnel.models import TunnelInfo +def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) -> TunnelError: + """Classify frpc output into a specific tunnel exception.""" + combined = "\n".join(output_lines).lower() + + # Auth failures + if "login failed" in combined: + if "tunnel not registered" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message=( + "Tunnel no longer registered. It may have expired or been " + "deleted. Create a new one." + ), + ) + if "invalid authentication token" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message="Tunnel token is invalid or expired. Create a new tunnel.", + ) + if "invalid binding secret" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message=( + "Binding secret mismatch. The tunnel may have been recreated. Create a new one." + ), + ) + + if "token in login doesn't match" in combined or "authorization failed" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message="Server rejected authorization. Tunnel may have expired or been deleted.", + ) + + # Connection lost + if "heartbeat timeout" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="connection_lost", + message="Lost connection to tunnel server (heartbeat timeout). Check your network.", + ) + if "pong message contains error" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="connection_lost", + message="Tunnel server sent an error during keepalive.", + ) + + # Config errors (proxy type, custom domains, subdomain) + if "unsupported proxy type" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="config_error", + message="Only HTTP tunnels are supported.", + ) + if "custom_domains not allowed" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="config_error", + message="Custom domains are not permitted.", + ) + if "subdomain does not match" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="config_error", + message="Subdomain mismatch. Tunnel configuration error.", + ) + + # Network errors + if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: + return TunnelNotRunningError( + tunnel_id=tunnel_id, + error_type="connection_lost", + message="Cannot reach tunnel server. Check internet and firewall.", + ) + + # Fallback + output_text = "\n".join(output_lines) if output_lines else "(no output captured)" + return TunnelNotRunningError( + tunnel_id=tunnel_id, + message=f"frpc connection failed\n--- frpc output ---\n{output_text}\n-------------------", + ) + + class Tunnel: """Tunnel interface for exposing local services.""" @@ -83,7 +174,7 @@ async def start(self) -> str: Raises: TunnelError: If tunnel registration fails - TunnelConnectionError: If frpc fails to connect + TunnelNotRunningError: If frpc fails to connect TunnelTimeoutError: If connection times out """ if self._started: @@ -126,7 +217,7 @@ async def start(self) -> str: await self._cleanup() if isinstance(e, asyncio.CancelledError): raise - raise TunnelConnectionError(f"Failed to start frpc: {e}") from e + raise TunnelNotRunningError(message=f"Failed to start frpc: {e}") from e # 5. Wait for connection try: @@ -142,7 +233,7 @@ async def start(self) -> str: await self._cleanup() if isinstance(e, asyncio.CancelledError): raise - raise TunnelConnectionError(f"Failed to start pipe drain: {e}") from e + raise TunnelNotRunningError(message=f"Failed to start pipe drain: {e}") from e self._started = True @@ -347,7 +438,7 @@ async def _wait_for_connection(self) -> None: while time.time() - start_time < self.connection_timeout: if self._process is None: - raise TunnelConnectionError("frpc process not running") + raise TunnelNotRunningError(message="frpc process not running") return_code = self._process.poll() if return_code is not None: @@ -358,14 +449,7 @@ async def _wait_for_connection(self) -> None: remaining_output.extend(self._process.stderr.readlines()) self._output_lines.extend(line.strip() for line in remaining_output if line.strip()) - # Build detailed error message - output_text = ( - "\n".join(self._output_lines) if self._output_lines else "(no output captured)" - ) - raise TunnelConnectionError( - f"frpc exited with code {return_code}\n" - f"--- frpc output ---\n{output_text}\n-------------------" - ) + raise _classify_frpc_error(self._output_lines, self.tunnel_id) if os.name == "posix": # Set both pipes to non-blocking mode to drain them without deadlock @@ -394,11 +478,12 @@ async def _wait_for_connection(self) -> None: # Check for success/failure indicators if "start proxy success" in line.lower(): return - if "login failed" in line.lower(): - raise TunnelConnectionError(f"frpc login failed: {line}") - if "authorization failed" in line.lower(): - raise TunnelConnectionError( - f"frpc authorization failed: {line}" + if ( + "login failed" in line.lower() + or "authorization failed" in line.lower() + ): + raise _classify_frpc_error( + self._output_lines, self.tunnel_id ) except (BlockingIOError, IOError): pass # No more data available on this pipe diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 8102ebaf2..cab419e99 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -5,6 +5,11 @@ import typer from prime_tunnel import Tunnel from prime_tunnel.core.client import TunnelClient +from prime_tunnel.exceptions import ( + TunnelLimitReachedError, + TunnelNotRunningError, + TunnelTimeoutError, +) from rich.console import Console from rich.table import Table @@ -51,6 +56,19 @@ def signal_handler(): await shutdown_event.wait() + except TunnelNotRunningError as e: + header = f"[{e.error_type}]" if e.error_type else "[tunnel error]" + console.print(f"\n[red]{header}[/red] {e}", style="bold") + if e.tunnel_id: + console.print(f"[dim]Tunnel ID: {e.tunnel_id}[/dim]") + raise typer.Exit(1) + except TunnelLimitReachedError as e: + console.print(f"\n[red]Tunnel limit reached:[/red] {e}", style="bold") + console.print("[dim]Delete an existing tunnel before creating a new one.[/dim]") + raise typer.Exit(1) + except TunnelTimeoutError as e: + console.print(f"\n[red]Connection timed out:[/red] {e}", style="bold") + raise typer.Exit(1) except Exception as e: console.print(f"[red]Error:[/red] {e}", style="bold") raise typer.Exit(1) @@ -95,12 +113,20 @@ async def fetch_tunnels(): table = Table(title="Active Tunnels") table.add_column("Tunnel ID", style="cyan") table.add_column("URL", style="green") + table.add_column("Status") table.add_column("Expires At") for tunnel in tunnels: + status_display = tunnel.status or "unknown" + if status_display == "connected": + status_display = "[green]connected[/green]" + elif status_display == "pending": + status_display = "[yellow]pending[/yellow]" + table.add_row( tunnel.tunnel_id, tunnel.url, + status_display, str(tunnel.expires_at), ) @@ -133,6 +159,7 @@ async def fetch_status(): console.print(f"[bold]Tunnel ID:[/bold] {tunnel.tunnel_id}") console.print(f"[bold]URL:[/bold] {tunnel.url}") console.print(f"[bold]Hostname:[/bold] {tunnel.hostname}") + console.print(f"[bold]Status:[/bold] {tunnel.status or 'unknown'}") console.print(f"[bold]Expires At:[/bold] {tunnel.expires_at}") From 5d2f9af6f12a8aa0d1d4e411406ffa5205c841a2 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 14:10:38 -0700 Subject: [PATCH 04/25] connection err --- .../prime-tunnel/src/prime_tunnel/__init__.py | 4 +-- .../src/prime_tunnel/exceptions.py | 2 +- .../prime-tunnel/src/prime_tunnel/tunnel.py | 32 +++++++++---------- .../prime/src/prime_cli/commands/tunnel.py | 4 +-- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/__init__.py b/packages/prime-tunnel/src/prime_tunnel/__init__.py index baa63e24d..1a4f3718c 100644 --- a/packages/prime-tunnel/src/prime_tunnel/__init__.py +++ b/packages/prime-tunnel/src/prime_tunnel/__init__.py @@ -6,9 +6,9 @@ from prime_tunnel.exceptions import ( BinaryDownloadError, TunnelAuthError, + TunnelConnectionError, TunnelError, TunnelLimitReachedError, - TunnelNotRunningError, TunnelTimeoutError, ) from prime_tunnel.models import TunnelInfo @@ -28,6 +28,6 @@ "TunnelAuthError", "TunnelError", "TunnelLimitReachedError", - "TunnelNotRunningError", + "TunnelConnectionError", "TunnelTimeoutError", ] diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py index d194e4c7c..c90e8ce72 100644 --- a/packages/prime-tunnel/src/prime_tunnel/exceptions.py +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -4,7 +4,7 @@ class TunnelError(Exception): pass -class TunnelNotRunningError(TunnelError): +class TunnelConnectionError(TunnelError): """Structured error for tunnel failures with diagnostic fields.""" def __init__( diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 777636b34..bef719292 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -12,8 +12,8 @@ from prime_tunnel.binary import get_frpc_path from prime_tunnel.core.client import TunnelClient from prime_tunnel.exceptions import ( + TunnelConnectionError, TunnelError, - TunnelNotRunningError, TunnelTimeoutError, ) from prime_tunnel.models import TunnelInfo @@ -26,7 +26,7 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) # Auth failures if "login failed" in combined: if "tunnel not registered" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", message=( @@ -35,13 +35,13 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) ), ) if "invalid authentication token" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", message="Tunnel token is invalid or expired. Create a new tunnel.", ) if "invalid binding secret" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", message=( @@ -50,7 +50,7 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) ) if "token in login doesn't match" in combined or "authorization failed" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", message="Server rejected authorization. Tunnel may have expired or been deleted.", @@ -58,13 +58,13 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) # Connection lost if "heartbeat timeout" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="connection_lost", message="Lost connection to tunnel server (heartbeat timeout). Check your network.", ) if "pong message contains error" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="connection_lost", message="Tunnel server sent an error during keepalive.", @@ -72,19 +72,19 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) # Config errors (proxy type, custom domains, subdomain) if "unsupported proxy type" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="config_error", message="Only HTTP tunnels are supported.", ) if "custom_domains not allowed" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="config_error", message="Custom domains are not permitted.", ) if "subdomain does not match" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="config_error", message="Subdomain mismatch. Tunnel configuration error.", @@ -92,7 +92,7 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) # Network errors if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, error_type="connection_lost", message="Cannot reach tunnel server. Check internet and firewall.", @@ -100,7 +100,7 @@ def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) # Fallback output_text = "\n".join(output_lines) if output_lines else "(no output captured)" - return TunnelNotRunningError( + return TunnelConnectionError( tunnel_id=tunnel_id, message=f"frpc connection failed\n--- frpc output ---\n{output_text}\n-------------------", ) @@ -174,7 +174,7 @@ async def start(self) -> str: Raises: TunnelError: If tunnel registration fails - TunnelNotRunningError: If frpc fails to connect + TunnelConnectionError: If frpc fails to connect or tunnel is not running TunnelTimeoutError: If connection times out """ if self._started: @@ -217,7 +217,7 @@ async def start(self) -> str: await self._cleanup() if isinstance(e, asyncio.CancelledError): raise - raise TunnelNotRunningError(message=f"Failed to start frpc: {e}") from e + raise TunnelConnectionError(message=f"Failed to start frpc: {e}") from e # 5. Wait for connection try: @@ -233,7 +233,7 @@ async def start(self) -> str: await self._cleanup() if isinstance(e, asyncio.CancelledError): raise - raise TunnelNotRunningError(message=f"Failed to start pipe drain: {e}") from e + raise TunnelConnectionError(message=f"Failed to start pipe drain: {e}") from e self._started = True @@ -438,7 +438,7 @@ async def _wait_for_connection(self) -> None: while time.time() - start_time < self.connection_timeout: if self._process is None: - raise TunnelNotRunningError(message="frpc process not running") + raise TunnelConnectionError(message="frpc process not running") return_code = self._process.poll() if return_code is not None: diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index cab419e99..3b5c0f12b 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -6,8 +6,8 @@ from prime_tunnel import Tunnel from prime_tunnel.core.client import TunnelClient from prime_tunnel.exceptions import ( + TunnelConnectionError, TunnelLimitReachedError, - TunnelNotRunningError, TunnelTimeoutError, ) from rich.console import Console @@ -56,7 +56,7 @@ def signal_handler(): await shutdown_event.wait() - except TunnelNotRunningError as e: + except TunnelConnectionError as e: header = f"[{e.error_type}]" if e.error_type else "[tunnel error]" console.print(f"\n[red]{header}[/red] {e}", style="bold") if e.tunnel_id: From 11acfd52055b3411635c708a8c4c62f8d08c2a84 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 14:37:19 -0700 Subject: [PATCH 05/25] raise tunnel errors --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index bef719292..f6fe780d7 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -192,7 +192,7 @@ async def start(self) -> str: ) except BaseException as e: await self._cleanup() - if isinstance(e, asyncio.CancelledError): + if isinstance(e, (asyncio.CancelledError, TunnelError)): raise raise TunnelError(f"Failed to register tunnel: {e}") from e From 45c6343bc679bf03cbb2c5243522b8a2b722fe85 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 14:52:15 -0700 Subject: [PATCH 06/25] better parsing --- packages/prime-tunnel/src/prime_tunnel/core/client.py | 1 + packages/prime/src/prime_cli/commands/tunnel.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 07d4c38b4..32426330c 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -115,6 +115,7 @@ async def _handle_response(self, response: httpx.Response, operation: str) -> Di error_detail = response.json().get("detail", response.text) except Exception: error_detail = response.text + error_detail = str(error_detail) if "maximum number of" in error_detail.lower(): raise TunnelLimitReachedError(error_detail) raise TunnelError(f"Failed to {operation}: {error_detail}") diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 3b5c0f12b..36f8200fa 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -57,7 +57,7 @@ def signal_handler(): await shutdown_event.wait() except TunnelConnectionError as e: - header = f"[{e.error_type}]" if e.error_type else "[tunnel error]" + header = f"\\[{e.error_type}]" if e.error_type else "\\[tunnel error]" console.print(f"\n[red]{header}[/red] {e}", style="bold") if e.tunnel_id: console.print(f"[dim]Tunnel ID: {e.tunnel_id}[/dim]") From 0f91972e62d8ec7b25e779aa47c4b8fb931bc7f0 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 15:01:20 -0700 Subject: [PATCH 07/25] backwards compat --- packages/prime-tunnel/src/prime_tunnel/exceptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py index c90e8ce72..5aace8888 100644 --- a/packages/prime-tunnel/src/prime_tunnel/exceptions.py +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -9,9 +9,10 @@ class TunnelConnectionError(TunnelError): def __init__( self, + message: str | None = None, + *, # keyword-only below for backwards compat tunnel_id: str | None = None, error_type: str | None = None, - message: str | None = None, ): self.tunnel_id = tunnel_id self.error_type = error_type From 68dc1d2e58f673de19acfc77e08e95e0900ec0f8 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 15:17:41 -0700 Subject: [PATCH 08/25] minor changes --- packages/prime-tunnel/src/prime_tunnel/exceptions.py | 2 +- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py index 5aace8888..b46047307 100644 --- a/packages/prime-tunnel/src/prime_tunnel/exceptions.py +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -17,7 +17,7 @@ def __init__( self.tunnel_id = tunnel_id self.error_type = error_type - if message: + if message is not None: msg = message elif error_type: msg = f"Tunnel {tunnel_id} failed ({error_type})" diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index f6fe780d7..6f2b26d8c 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -19,7 +19,9 @@ from prime_tunnel.models import TunnelInfo -def _classify_frpc_error(output_lines: list[str], tunnel_id: str | None = None) -> TunnelError: +def _classify_frpc_error( + output_lines: list[str], tunnel_id: str | None = None +) -> TunnelConnectionError: """Classify frpc output into a specific tunnel exception.""" combined = "\n".join(output_lines).lower() From aece3bd612e188ec3cf0121882a8cc7ede1cf4a3 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 15:25:34 -0700 Subject: [PATCH 09/25] return code --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 6f2b26d8c..fe8404083 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -20,7 +20,9 @@ def _classify_frpc_error( - output_lines: list[str], tunnel_id: str | None = None + output_lines: list[str], + tunnel_id: str | None = None, + return_code: int | None = None, ) -> TunnelConnectionError: """Classify frpc output into a specific tunnel exception.""" combined = "\n".join(output_lines).lower() @@ -102,9 +104,10 @@ def _classify_frpc_error( # Fallback output_text = "\n".join(output_lines) if output_lines else "(no output captured)" + exit_info = f" (exit code {return_code})" if return_code is not None else "" return TunnelConnectionError( tunnel_id=tunnel_id, - message=f"frpc connection failed\n--- frpc output ---\n{output_text}\n-------------------", + message=f"frpc connection failed{exit_info}\n--- frpc output ---\n{output_text}", ) @@ -451,7 +454,7 @@ async def _wait_for_connection(self) -> None: remaining_output.extend(self._process.stderr.readlines()) self._output_lines.extend(line.strip() for line in remaining_output if line.strip()) - raise _classify_frpc_error(self._output_lines, self.tunnel_id) + raise _classify_frpc_error(self._output_lines, self.tunnel_id, return_code) if os.name == "posix": # Set both pipes to non-blocking mode to drain them without deadlock From c47e7f1e7d07037748a71a019e38351dacc5fba8 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 16:12:06 -0700 Subject: [PATCH 10/25] cli check if tunnel is running --- packages/prime/src/prime_cli/commands/tunnel.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 36f8200fa..c502ef289 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -10,6 +10,7 @@ TunnelLimitReachedError, TunnelTimeoutError, ) +from prime_tunnel.tunnel import _classify_frpc_error from rich.console import Console from rich.table import Table @@ -54,7 +55,15 @@ def signal_handler(): console.print(f"\n[dim]Forwarding to localhost:{port}[/dim]") console.print("[dim]Press Ctrl+C to stop the tunnel[/dim]\n") - await shutdown_event.wait() + # Monitor tunnel health while waiting for shutdown signal + while not shutdown_event.is_set(): + console.print("test") + if not tunnel.is_running: + raise _classify_frpc_error(tunnel.recent_output, tunnel.tunnel_id) + try: + await asyncio.wait_for(shutdown_event.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass except TunnelConnectionError as e: header = f"\\[{e.error_type}]" if e.error_type else "\\[tunnel error]" From 0fea5d39fe929f5fb4396cb9dd3c6d3cbdc1204f Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 16:22:23 -0700 Subject: [PATCH 11/25] remove test --- packages/prime/src/prime_cli/commands/tunnel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index c502ef289..78c27d5ad 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -57,7 +57,6 @@ def signal_handler(): # Monitor tunnel health while waiting for shutdown signal while not shutdown_event.is_set(): - console.print("test") if not tunnel.is_running: raise _classify_frpc_error(tunnel.recent_output, tunnel.tunnel_id) try: From 60b6bfa76358df14165c65ca21f91b7fc9f8e5f9 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 16:28:39 -0700 Subject: [PATCH 12/25] remove priv func from cli --- packages/prime/src/prime_cli/commands/tunnel.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 78c27d5ad..e090209e3 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -10,7 +10,6 @@ TunnelLimitReachedError, TunnelTimeoutError, ) -from prime_tunnel.tunnel import _classify_frpc_error from rich.console import Console from rich.table import Table @@ -58,7 +57,14 @@ def signal_handler(): # Monitor tunnel health while waiting for shutdown signal while not shutdown_event.is_set(): if not tunnel.is_running: - raise _classify_frpc_error(tunnel.recent_output, tunnel.tunnel_id) + output = "\n".join(tunnel.recent_output) or "(no output captured)" + raise TunnelConnectionError( + message=( + f"Tunnel process exited unexpectedly\n--- frpc output ---\n{output}" + ), + tunnel_id=tunnel.tunnel_id, + error_type="connection_lost", + ) try: await asyncio.wait_for(shutdown_event.wait(), timeout=2.0) except asyncio.TimeoutError: From a6b8c85c4421fb2c10851f5fcd3114376ef70ba2 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 17:26:48 -0700 Subject: [PATCH 13/25] ansi --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index fe8404083..05be12a27 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -1,6 +1,7 @@ import asyncio import fcntl import os +import re import subprocess import threading import time @@ -25,11 +26,11 @@ def _classify_frpc_error( return_code: int | None = None, ) -> TunnelConnectionError: """Classify frpc output into a specific tunnel exception.""" - combined = "\n".join(output_lines).lower() + combined = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(output_lines)).lower() # Auth failures - if "login failed" in combined: - if "tunnel not registered" in combined: + if "login" in combined and "failed" in combined: + if "not registered" in combined: return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", @@ -75,7 +76,7 @@ def _classify_frpc_error( ) # Config errors (proxy type, custom domains, subdomain) - if "unsupported proxy type" in combined: + if "unsupported proxy type" in combined or "proxy type not support" in combined: return TunnelConnectionError( tunnel_id=tunnel_id, error_type="config_error", From acb3c3e64d73c9053578c0cf586eced200b95e3a Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 17:34:09 -0700 Subject: [PATCH 14/25] login catch all --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 05be12a27..728109642 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -53,6 +53,11 @@ def _classify_frpc_error( "Binding secret mismatch. The tunnel may have been recreated. Create a new one." ), ) + return TunnelConnectionError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message="Login failed. Tunnel may have expired or been deleted.", + ) if "token in login doesn't match" in combined or "authorization failed" in combined: return TunnelConnectionError( From 2252e7263fcf2ee0045b2ba94924c869d7eb1b23 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 17:51:31 -0700 Subject: [PATCH 15/25] better pattern matching --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 728109642..7a8b82ad6 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -28,8 +28,7 @@ def _classify_frpc_error( """Classify frpc output into a specific tunnel exception.""" combined = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(output_lines)).lower() - # Auth failures - if "login" in combined and "failed" in combined: + if "login to the server failed" in combined or "connect to server error" in combined: if "not registered" in combined: return TunnelConnectionError( tunnel_id=tunnel_id, @@ -53,6 +52,12 @@ def _classify_frpc_error( "Binding secret mismatch. The tunnel may have been recreated. Create a new one." ), ) + if "send login request to plugin error" in combined: + return TunnelConnectionError( + tunnel_id=tunnel_id, + error_type="auth_failed", + message="Tunnel server plugin unreachable. Try again later.", + ) return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", From 415869f4a19a5a9e43a28626a6da6fc2fd308279 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 18:02:36 -0700 Subject: [PATCH 16/25] remove unnecessary error --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 7a8b82ad6..31ae89bdb 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -58,13 +58,19 @@ def _classify_frpc_error( error_type="auth_failed", message="Tunnel server plugin unreachable. Try again later.", ) + if "already online" in combined: + return TunnelConnectionError( + tunnel_id=tunnel_id, + error_type="already_online", + message="Another client is already connected with this tunnel. Stop the existing connection first.", + ) return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", message="Login failed. Tunnel may have expired or been deleted.", ) - if "token in login doesn't match" in combined or "authorization failed" in combined: + if "token in login doesn't match" in combined: return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", From f439274b1339970835af2c44d8f5aa479de6b2a2 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 18:06:26 -0700 Subject: [PATCH 17/25] move connection refused errors --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 31ae89bdb..dd4f0ef50 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -64,6 +64,12 @@ def _classify_frpc_error( error_type="already_online", message="Another client is already connected with this tunnel. Stop the existing connection first.", ) + if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: + return TunnelConnectionError( + tunnel_id=tunnel_id, + error_type="connection_lost", + message="Cannot reach tunnel server. Check internet and firewall.", + ) return TunnelConnectionError( tunnel_id=tunnel_id, error_type="auth_failed", @@ -111,14 +117,6 @@ def _classify_frpc_error( message="Subdomain mismatch. Tunnel configuration error.", ) - # Network errors - if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="connection_lost", - message="Cannot reach tunnel server. Check internet and firewall.", - ) - # Fallback output_text = "\n".join(output_lines) if output_lines else "(no output captured)" exit_info = f" (exit code {return_code})" if return_code is not None else "" From 1705aa6531018d27e3e55d72c93f786c72efde8c Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 18:07:50 -0700 Subject: [PATCH 18/25] lint --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index dd4f0ef50..450fc50a0 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -62,7 +62,8 @@ def _classify_frpc_error( return TunnelConnectionError( tunnel_id=tunnel_id, error_type="already_online", - message="Another client is already connected with this tunnel. Stop the existing connection first.", + message="Another client is already connected with this tunnel." + " Stop the existing connection first.", ) if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: return TunnelConnectionError( From 0181ed50755b04c5a0c10881a32019285d13d776 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 18:19:42 -0700 Subject: [PATCH 19/25] fix inline errs --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 450fc50a0..20bed4800 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -500,8 +500,8 @@ async def _wait_for_connection(self) -> None: if "start proxy success" in line.lower(): return if ( - "login failed" in line.lower() - or "authorization failed" in line.lower() + "login to the server failed" in line.lower() + or "connect to server error" in line.lower() ): raise _classify_frpc_error( self._output_lines, self.tunnel_id From dc6357ff19717d313fd63f6ec33c25f3177bfaa8 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 21:03:12 -0700 Subject: [PATCH 20/25] simplify --- .../src/prime_tunnel/exceptions.py | 6 +- .../prime-tunnel/src/prime_tunnel/tunnel.py | 129 ++++-------------- .../prime/src/prime_cli/commands/tunnel.py | 4 +- 3 files changed, 31 insertions(+), 108 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/exceptions.py b/packages/prime-tunnel/src/prime_tunnel/exceptions.py index b46047307..3c034d6ab 100644 --- a/packages/prime-tunnel/src/prime_tunnel/exceptions.py +++ b/packages/prime-tunnel/src/prime_tunnel/exceptions.py @@ -5,22 +5,18 @@ class TunnelError(Exception): class TunnelConnectionError(TunnelError): - """Structured error for tunnel failures with diagnostic fields.""" + """Tunnel connection failure with optional tunnel ID for diagnostics.""" def __init__( self, message: str | None = None, *, # keyword-only below for backwards compat tunnel_id: str | None = None, - error_type: str | None = None, ): self.tunnel_id = tunnel_id - self.error_type = error_type if message is not None: msg = message - elif error_type: - msg = f"Tunnel {tunnel_id} failed ({error_type})" elif tunnel_id: msg = f"Tunnel {tunnel_id} is not running" else: diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 20bed4800..f9845fd1b 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -20,111 +20,40 @@ from prime_tunnel.models import TunnelInfo -def _classify_frpc_error( +def _parse_frpc_error( output_lines: list[str], tunnel_id: str | None = None, return_code: int | None = None, ) -> TunnelConnectionError: - """Classify frpc output into a specific tunnel exception.""" - combined = re.sub(r"\x1b\[[0-9;]*m", "", "\n".join(output_lines)).lower() - - if "login to the server failed" in combined or "connect to server error" in combined: - if "not registered" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message=( - "Tunnel no longer registered. It may have expired or been " - "deleted. Create a new one." - ), - ) - if "invalid authentication token" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message="Tunnel token is invalid or expired. Create a new tunnel.", - ) - if "invalid binding secret" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message=( - "Binding secret mismatch. The tunnel may have been recreated. Create a new one." - ), - ) - if "send login request to plugin error" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message="Tunnel server plugin unreachable. Try again later.", - ) - if "already online" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="already_online", - message="Another client is already connected with this tunnel." - " Stop the existing connection first.", - ) - if "connection refused" in combined or "no such host" in combined or "dial tcp" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="connection_lost", - message="Cannot reach tunnel server. Check internet and firewall.", - ) - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message="Login failed. Tunnel may have expired or been deleted.", - ) - - if "token in login doesn't match" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="auth_failed", - message="Server rejected authorization. Tunnel may have expired or been deleted.", - ) + """Parse frpc log output into a structured tunnel exception.""" + # timestamp + level + caller prefix + message + _LOG_RE = re.compile( + r"\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}\s" + r"\[([EWIDT])\]\s" + r"\[.*?\]\s" + r"(?:\[.*?\]\s)*" + r"(.+)" + ) + _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - # Connection lost - if "heartbeat timeout" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="connection_lost", - message="Lost connection to tunnel server (heartbeat timeout). Check your network.", - ) - if "pong message contains error" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="connection_lost", - message="Tunnel server sent an error during keepalive.", - ) + error_messages: list[str] = [] + for raw_line in output_lines: + line = _ANSI_RE.sub("", raw_line) + m = _LOG_RE.match(line) + if not m: + continue + level, msg = m.group(1), m.group(2) + if level in ("E", "W"): + error_messages.append(msg) - # Config errors (proxy type, custom domains, subdomain) - if "unsupported proxy type" in combined or "proxy type not support" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="config_error", - message="Only HTTP tunnels are supported.", - ) - if "custom_domains not allowed" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="config_error", - message="Custom domains are not permitted.", - ) - if "subdomain does not match" in combined: - return TunnelConnectionError( - tunnel_id=tunnel_id, - error_type="config_error", - message="Subdomain mismatch. Tunnel configuration error.", - ) + if error_messages: + message = error_messages[-1] + else: + output_text = "\n".join(output_lines) if output_lines else "(no output captured)" + exit_info = f" (exit code {return_code})" if return_code is not None else "" + message = f"frpc process failed{exit_info}: {output_text}" - # Fallback - output_text = "\n".join(output_lines) if output_lines else "(no output captured)" - exit_info = f" (exit code {return_code})" if return_code is not None else "" - return TunnelConnectionError( - tunnel_id=tunnel_id, - message=f"frpc connection failed{exit_info}\n--- frpc output ---\n{output_text}", - ) + return TunnelConnectionError(tunnel_id=tunnel_id, message=message) class Tunnel: @@ -470,7 +399,7 @@ async def _wait_for_connection(self) -> None: remaining_output.extend(self._process.stderr.readlines()) self._output_lines.extend(line.strip() for line in remaining_output if line.strip()) - raise _classify_frpc_error(self._output_lines, self.tunnel_id, return_code) + raise _parse_frpc_error(self._output_lines, self.tunnel_id, return_code) if os.name == "posix": # Set both pipes to non-blocking mode to drain them without deadlock @@ -503,7 +432,7 @@ async def _wait_for_connection(self) -> None: "login to the server failed" in line.lower() or "connect to server error" in line.lower() ): - raise _classify_frpc_error( + raise _parse_frpc_error( self._output_lines, self.tunnel_id ) except (BlockingIOError, IOError): diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index e090209e3..aeaa7c8d1 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -63,7 +63,6 @@ def signal_handler(): f"Tunnel process exited unexpectedly\n--- frpc output ---\n{output}" ), tunnel_id=tunnel.tunnel_id, - error_type="connection_lost", ) try: await asyncio.wait_for(shutdown_event.wait(), timeout=2.0) @@ -71,8 +70,7 @@ def signal_handler(): pass except TunnelConnectionError as e: - header = f"\\[{e.error_type}]" if e.error_type else "\\[tunnel error]" - console.print(f"\n[red]{header}[/red] {e}", style="bold") + console.print(f"\n[red]Tunnel error:[/red] {e}", style="bold") if e.tunnel_id: console.print(f"[dim]Tunnel ID: {e.tunnel_id}[/dim]") raise typer.Exit(1) From 6a7104845ae17b0aa1d0a1b74d8a726c83854608 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Wed, 11 Mar 2026 21:04:40 -0700 Subject: [PATCH 21/25] lint --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index f9845fd1b..fb29777bb 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -432,9 +432,7 @@ async def _wait_for_connection(self) -> None: "login to the server failed" in line.lower() or "connect to server error" in line.lower() ): - raise _parse_frpc_error( - self._output_lines, self.tunnel_id - ) + raise _parse_frpc_error(self._output_lines, self.tunnel_id) except (BlockingIOError, IOError): pass # No more data available on this pipe finally: From 5abea203e1bf00891739b6c479d718ac4f7c1f32 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 12 Mar 2026 11:36:53 -0700 Subject: [PATCH 22/25] compile re at import time --- .../prime-tunnel/src/prime_tunnel/tunnel.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index fb29777bb..0c3c050d9 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -19,6 +19,15 @@ ) from prime_tunnel.models import TunnelInfo +# timestamp + level + caller prefix + message +_LOG_RE = re.compile( + r"\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}\s" + r"\[([EWIDT])\]\s" + r"\[.*?\]\s" + r"(?:\[.*?\]\s)*" + r"(.+)" +) +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def _parse_frpc_error( output_lines: list[str], @@ -26,16 +35,6 @@ def _parse_frpc_error( return_code: int | None = None, ) -> TunnelConnectionError: """Parse frpc log output into a structured tunnel exception.""" - # timestamp + level + caller prefix + message - _LOG_RE = re.compile( - r"\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}\s" - r"\[([EWIDT])\]\s" - r"\[.*?\]\s" - r"(?:\[.*?\]\s)*" - r"(.+)" - ) - _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - error_messages: list[str] = [] for raw_line in output_lines: line = _ANSI_RE.sub("", raw_line) From f12baeda08dda495fd0801f1b8d0838252e84e5f Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 12 Mar 2026 11:37:17 -0700 Subject: [PATCH 23/25] lint --- packages/prime-tunnel/src/prime_tunnel/tunnel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/prime-tunnel/src/prime_tunnel/tunnel.py b/packages/prime-tunnel/src/prime_tunnel/tunnel.py index 0c3c050d9..dd00239eb 100644 --- a/packages/prime-tunnel/src/prime_tunnel/tunnel.py +++ b/packages/prime-tunnel/src/prime_tunnel/tunnel.py @@ -29,6 +29,7 @@ ) _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + def _parse_frpc_error( output_lines: list[str], tunnel_id: str | None = None, From 135de915b79e7a88757ee4b7f7f63de60c316148 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 12 Mar 2026 13:11:08 -0700 Subject: [PATCH 24/25] catch built in timeout error --- packages/prime-tunnel/src/prime_tunnel/core/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/prime-tunnel/src/prime_tunnel/core/client.py b/packages/prime-tunnel/src/prime_tunnel/core/client.py index 32426330c..4dbcb25f9 100644 --- a/packages/prime-tunnel/src/prime_tunnel/core/client.py +++ b/packages/prime-tunnel/src/prime_tunnel/core/client.py @@ -168,6 +168,8 @@ async def create_tunnel( response = await self._request_with_retry("POST", url, json=payload) except httpx.TimeoutException as e: raise TunnelTimeoutError(f"Request timed out: {e}") from e + except TimeoutError as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e except httpx.RequestError as e: raise TunnelError(f"Failed to connect to API: {e}") from e @@ -195,6 +197,8 @@ async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]: response = await self._request_with_retry("GET", url) except httpx.TimeoutException as e: raise TunnelTimeoutError(f"Request timed out: {e}") from e + except TimeoutError as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e except httpx.RequestError as e: raise TunnelError(f"Failed to connect to API: {e}") from e @@ -232,6 +236,8 @@ async def delete_tunnel(self, tunnel_id: str) -> bool: response = await self._request_with_retry("DELETE", url) except httpx.TimeoutException as e: raise TunnelTimeoutError(f"Request timed out: {e}") from e + except TimeoutError as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e except httpx.RequestError as e: raise TunnelError(f"Failed to connect to API: {e}") from e @@ -252,6 +258,8 @@ async def bulk_delete_tunnels(self, tunnel_ids: list[str]) -> dict: response = await self._request_with_retry("DELETE", url, json=payload) except httpx.TimeoutException as e: raise TunnelTimeoutError(f"Request timed out: {e}") from e + except TimeoutError as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e except httpx.RequestError as e: raise TunnelError(f"Failed to connect to API: {e}") from e @@ -279,6 +287,8 @@ async def list_tunnels(self, team_id: Optional[str] = None) -> list[TunnelInfo]: response = await self._request_with_retry("GET", url, params=params) except httpx.TimeoutException as e: raise TunnelTimeoutError(f"Request timed out: {e}") from e + except TimeoutError as e: + raise TunnelTimeoutError(f"Request timed out: {e}") from e except httpx.RequestError as e: raise TunnelError(f"Failed to connect to API: {e}") from e From deb0c8b83a8dd53636c7683e73dd88594cced82f Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 12 Mar 2026 16:26:13 -0700 Subject: [PATCH 25/25] uppercase status --- packages/prime/src/prime_cli/commands/tunnel.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/prime/src/prime_cli/commands/tunnel.py b/packages/prime/src/prime_cli/commands/tunnel.py index 3bf834b83..f99824e54 100644 --- a/packages/prime/src/prime_cli/commands/tunnel.py +++ b/packages/prime/src/prime_cli/commands/tunnel.py @@ -130,10 +130,14 @@ async def fetch_tunnels(): for tunnel in tunnels: status_display = tunnel.status or "unknown" - if status_display == "connected": + if status_display == "CONNECTED": status_display = "[green]connected[/green]" - elif status_display == "pending": + elif status_display == "PENDING": status_display = "[yellow]pending[/yellow]" + elif status_display == "DISCONNECTED": + status_display = "[red]disconnected[/red]" + elif status_display == "EXPIRED": + status_display = "[dim]expired[/dim]" table.add_row( tunnel.tunnel_id,