Files
2026-03-13 00:39:46 +08:00

741 lines
31 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import shlex
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
import asyncssh
from fastapi import WebSocket
from app.core.db import utc_now
from app.models.connection import Connection
from app.services.session_updates import SessionBroadcaster
from app.services.types import PtyInfo, SessionBuffer
LOGGER = logging.getLogger(__name__)
TARGET_PROBE_RTT_WINDOW = 8
TARGET_PROBE_TIMEOUT_SECONDS = 5.0
TARGET_PROBE_COMMAND = "printf '__wsg_probe__'"
@dataclass
class ManagedSession:
session_id: str
connection_id: int
user_id: int
client: asyncssh.SSHClientConnection
channel: asyncssh.SSHClientChannel | None
buffer: SessionBuffer
last_activity: datetime
websockets: set[WebSocket] = field(default_factory=set)
status: str = "active"
enhanced_enabled: bool = False
enhanced_fingerprint: str | None = None
tmux_binary_path: str | None = None
resize_seq: int = 0
target_profile: str = "unknown"
target_rtt_ms: int | None = None
target_avg_rtt_ms: int | None = None
target_jitter_ms: int = 0
target_probe_error_streak: int = 0
target_measured_at: datetime | None = None
target_rtt_samples: list[int] = field(default_factory=list)
def status_payload(self) -> str:
return json.dumps(
{
"id": self.session_id,
"status": self.status,
"last_activity": self.last_activity.isoformat(),
"target_profile": self.target_profile,
"target_rtt_ms": self.target_rtt_ms,
"target_avg_rtt_ms": self.target_avg_rtt_ms,
"target_jitter_ms": self.target_jitter_ms,
"target_probe_error_streak": self.target_probe_error_streak,
"target_measured_at": self.target_measured_at.isoformat() if self.target_measured_at else None,
}
)
async def send(self, data: str) -> None:
if not self.channel:
return
try:
self.channel.write(data)
except Exception:
self.status = "disconnected"
self.last_activity = utc_now()
raise
self.last_activity = utc_now()
async def _collect_tmux_metrics(self, phase: str, expected_rows: int, expected_cols: int) -> None:
if not (self.enhanced_enabled and self.enhanced_fingerprint and self.tmux_binary_path):
return
quoted_remote_binary = shlex.quote(self.tmux_binary_path)
quoted_fingerprint = shlex.quote(self.enhanced_fingerprint)
target_window = shlex.quote(f"{self.enhanced_fingerprint}:0")
target_pane = shlex.quote(f"{self.enhanced_fingerprint}:0.0")
try:
pane_res = await self.client.run(
f'{quoted_remote_binary} display-message -p -t {target_pane} "pane=#{{pane_height}}x#{{pane_width}} window=#{{window_height}}x#{{window_width}}"',
check=False,
)
window_mode_res = await self.client.run(
f"{quoted_remote_binary} show-window-options -t {target_window} -v window-size",
check=False,
)
clients_res = await self.client.run(
f'{quoted_remote_binary} list-clients -t {quoted_fingerprint} -F "#{{client_tty}} client=#{{client_height}}x#{{client_width}}"',
check=False,
)
LOGGER.info(
"tmux-metrics phase=%s session_id=%s fingerprint=%s expected=%sx%s pane='%s' window_mode='%s' clients='%s' exits(pane=%s,window=%s,clients=%s)",
phase,
self.session_id,
self.enhanced_fingerprint,
expected_rows,
expected_cols,
(pane_res.stdout or "").strip(),
(window_mode_res.stdout or "").strip(),
(clients_res.stdout or "").strip().replace("\n", " | "),
pane_res.exit_status,
window_mode_res.exit_status,
clients_res.exit_status,
)
except Exception as exc:
LOGGER.warning(
"tmux-metrics-failed phase=%s session_id=%s fingerprint=%s error=%s",
phase,
self.session_id,
self.enhanced_fingerprint,
exc,
)
async def resize(self, rows: int, cols: int) -> None:
if not self.channel:
return
self.resize_seq += 1
seq = self.resize_seq
pty_resize_ok = False
LOGGER.info(
"resize-recv session_id=%s seq=%s rows=%s cols=%s enhanced=%s",
self.session_id,
seq,
rows,
cols,
self.enhanced_enabled,
)
try:
# AsyncSSH expects width/height, not cols/rows keyword arguments.
self.channel.change_terminal_size(cols, rows)
pty_resize_ok = True
LOGGER.info(
"resize-pty-ok session_id=%s seq=%s rows=%s cols=%s",
self.session_id,
seq,
rows,
cols,
)
except Exception as exc:
LOGGER.warning(
"resize-pty-failed session_id=%s seq=%s rows=%s cols=%s error=%s",
self.session_id,
seq,
rows,
cols,
exc,
)
if self.enhanced_enabled and self.enhanced_fingerprint and self.tmux_binary_path:
quoted_remote_binary = shlex.quote(self.tmux_binary_path)
quoted_fingerprint = shlex.quote(self.enhanced_fingerprint)
target_window = shlex.quote(f"{self.enhanced_fingerprint}:0")
target_pane = shlex.quote(f"{self.enhanced_fingerprint}:0.0")
try:
res_mode = await self.client.run(
f"{quoted_remote_binary} set-window-option -t {target_window} window-size manual",
check=False,
)
res_window = await self.client.run(
f"{quoted_remote_binary} resize-window -t {target_window} -x {cols} -y {rows}",
check=False,
)
res_pane = await self.client.run(
f"{quoted_remote_binary} resize-pane -t {target_pane} -x {cols} -y {rows}",
check=False,
)
clients = await self.client.run(
f"{quoted_remote_binary} list-clients -t {quoted_fingerprint} -F '#{{client_tty}}\t#{{client_control_mode}}'",
check=False,
)
refreshed = 0
control_clients = 0
client_count = 0
if clients.exit_status == 0:
for line in (clients.stdout or "").splitlines():
raw_tty, _, raw_control_mode = line.partition("\t")
tty = raw_tty.strip()
if not tty:
continue
client_count += 1
control_mode = raw_control_mode.strip().lower()
if control_mode not in {"1", "on", "yes", "true"}:
continue
control_clients += 1
refresh_res = await self.client.run(
f"{quoted_remote_binary} refresh-client -t {shlex.quote(tty)} -C {cols}x{rows}",
check=False,
)
if refresh_res.exit_status == 0:
refreshed += 1
LOGGER.info(
"resize-tmux-ok session_id=%s seq=%s rows=%s cols=%s pty_resize_ok=%s exits(mode=%s,window=%s,pane=%s,clients=%s) clients(total=%s,control=%s,refreshed=%s)",
self.session_id,
seq,
rows,
cols,
pty_resize_ok,
res_mode.exit_status,
res_window.exit_status,
res_pane.exit_status,
clients.exit_status,
client_count,
control_clients,
refreshed,
)
except Exception as exc:
# 弱网或链路抖动时不向用户终端注入 tmux 控制命令,避免命令串污染可见输出。
LOGGER.warning(
"resize-tmux-fallback-skipped session_id=%s seq=%s rows=%s cols=%s error=%s",
self.session_id,
seq,
rows,
cols,
exc,
)
await self._collect_tmux_metrics(f"resize_seq_{seq}", rows, cols)
self.last_activity = utc_now()
async def close(self) -> None:
if self.channel:
self.channel.close()
self.client.close()
class SessionManager:
def __init__(
self,
keepalive_interval: int,
broadcaster: SessionBroadcaster | None = None,
known_hosts: str | None = None,
allow_unknown_hosts: bool = False,
auto_add_known_hosts: bool = True,
on_session_disconnect: Callable[[str], None] | None = None,
keepalive_binary_dir: str | None = None,
) -> None:
self._sessions: dict[str, ManagedSession] = {}
self._network_probe_tasks: dict[str, asyncio.Task[None]] = {}
self._keepalive_interval = keepalive_interval
self._known_hosts = known_hosts
self._allow_unknown_hosts = allow_unknown_hosts
self._auto_add_known_hosts = auto_add_known_hosts
self._known_hosts_lock = asyncio.Lock()
self._logger = logging.getLogger(__name__)
self._broadcaster = broadcaster
self._on_session_disconnect = on_session_disconnect
self._keepalive_binary_dir = Path(keepalive_binary_dir).resolve() if keepalive_binary_dir else None
def _schedule_tmux_probe(self, session: ManagedSession, rows: int, cols: int) -> None:
async def _probe() -> None:
for delay in (0.2, 1.0, 3.0, 8.0):
await asyncio.sleep(delay)
await session._collect_tmux_metrics(f"create_probe_{delay:.1f}s", rows, cols)
asyncio.create_task(_probe())
def _target_profile_interval_seconds(self, profile: str) -> float:
if profile == "poor":
return 12.0
if profile == "degraded":
return 7.0
return 4.0
def _compute_target_profile(self, avg_rtt_ms: int | None, jitter_ms: int, error_streak: int) -> str:
if error_streak >= 2:
return "poor"
if avg_rtt_ms is None:
if error_streak >= 1:
return "degraded"
return "unknown"
if avg_rtt_ms >= 400 or jitter_ms >= 200:
return "poor"
if error_streak >= 1 or avg_rtt_ms >= 150 or jitter_ms >= 80:
return "degraded"
return "good"
def _target_snapshot(self, session: ManagedSession) -> tuple[str, int | None, int | None, int, int]:
return (
session.target_profile,
session.target_rtt_ms,
session.target_avg_rtt_ms,
session.target_jitter_ms,
session.target_probe_error_streak,
)
def _apply_probe_success(self, session: ManagedSession, rtt_ms: int) -> bool:
before = self._target_snapshot(session)
session.target_rtt_samples = [*session.target_rtt_samples[-(TARGET_PROBE_RTT_WINDOW - 1) :], rtt_ms]
session.target_rtt_ms = rtt_ms
session.target_avg_rtt_ms = round(sum(session.target_rtt_samples) / len(session.target_rtt_samples))
session.target_jitter_ms = (
max(session.target_rtt_samples) - min(session.target_rtt_samples) if len(session.target_rtt_samples) >= 2 else 0
)
session.target_probe_error_streak = 0
session.target_measured_at = utc_now()
session.target_profile = self._compute_target_profile(
session.target_avg_rtt_ms,
session.target_jitter_ms,
session.target_probe_error_streak,
)
return self._target_snapshot(session) != before
def _apply_probe_failure(self, session: ManagedSession) -> bool:
before = self._target_snapshot(session)
session.target_rtt_ms = None
session.target_probe_error_streak = min(session.target_probe_error_streak + 1, 10)
session.target_measured_at = utc_now()
session.target_profile = self._compute_target_profile(
session.target_avg_rtt_ms,
session.target_jitter_ms,
session.target_probe_error_streak,
)
return self._target_snapshot(session) != before
async def _broadcast_session_status(self, session: ManagedSession) -> None:
if not self._broadcaster:
return
await self._broadcaster.broadcast(session.user_id, session.status_payload())
async def _probe_target_network_once(self, session: ManagedSession) -> None:
start = time.monotonic()
changed = False
try:
result = await asyncio.wait_for(
session.client.run(TARGET_PROBE_COMMAND, check=False),
timeout=TARGET_PROBE_TIMEOUT_SECONDS,
)
if result.exit_status != 0:
raise RuntimeError(f"probe exit status {result.exit_status}")
elapsed_ms = max(1, round((time.monotonic() - start) * 1000))
changed = self._apply_probe_success(session, elapsed_ms)
except Exception as exc:
changed = self._apply_probe_failure(session)
self._logger.debug(
"target network probe failed session_id=%s error=%s",
session.session_id,
exc,
)
if changed:
await self._broadcast_session_status(session)
async def _run_target_network_probe_loop(self, session_id: str) -> None:
try:
while True:
session = self._sessions.get(session_id)
if not session or session.status != "active" or not session.channel:
return
await self._probe_target_network_once(session)
await asyncio.sleep(self._target_profile_interval_seconds(session.target_profile))
except asyncio.CancelledError:
return
def _cancel_target_network_probe(self, session_id: str) -> None:
task = self._network_probe_tasks.pop(session_id, None)
if task and not task.done():
task.cancel()
def _ensure_target_network_probe(self, session: ManagedSession) -> None:
self._cancel_target_network_probe(session.session_id)
self._network_probe_tasks[session.session_id] = asyncio.create_task(
self._run_target_network_probe_loop(session.session_id)
)
def list_sessions(self, user_id: int) -> list[ManagedSession]:
return [session for session in self._sessions.values() if session.user_id == user_id]
def get_session(self, session_id: str) -> ManagedSession | None:
return self._sessions.get(session_id)
def _resolve_known_hosts_path(self) -> str:
known_hosts = self._known_hosts or "~/.ssh/known_hosts"
return str(Path(known_hosts).expanduser())
def _ensure_known_hosts_path(self, known_hosts_path: str, create_file: bool) -> None:
path = Path(known_hosts_path)
path.parent.mkdir(parents=True, exist_ok=True)
if create_file and not path.exists():
path.touch(mode=0o600, exist_ok=True)
def _build_known_hosts_entry(self, host: str, port: int, host_key: asyncssh.SSHKey) -> str:
label = host if port == 22 else f"[{host}]:{port}"
key_data = host_key.export_public_key("openssh").decode("utf-8").strip()
return f"{label} {key_data}\n"
async def _fetch_host_key(self, connection: Connection) -> asyncssh.SSHKey:
host_key = await asyncssh.get_server_host_key(connection.host, port=connection.port)
if host_key is None:
raise ValueError("Host key not provided by server")
return host_key
async def _open_client(
self,
connection: Connection,
auth_payload: dict[str, Any],
known_hosts: str | None,
) -> asyncssh.SSHClientConnection:
client_keys = None
if auth_payload.get("private_key"):
try:
passphrase = auth_payload.get("key_passphrase")
key = asyncssh.import_private_key(auth_payload["private_key"], passphrase=passphrase)
client_keys = [key]
except Exception:
raise ValueError("无效的私钥格式或密码错误")
return await asyncssh.connect(
connection.host,
port=connection.port,
username=connection.username,
password=auth_payload.get("password"),
client_keys=client_keys,
known_hosts=known_hosts,
keepalive_interval=self._keepalive_interval,
)
async def _connect_with_known_hosts(
self,
connection: Connection,
auth_payload: dict[str, Any],
) -> asyncssh.SSHClientConnection:
known_hosts_path = self._resolve_known_hosts_path()
self._ensure_known_hosts_path(known_hosts_path, create_file=self._auto_add_known_hosts)
return await self._open_client(connection, auth_payload, known_hosts_path)
async def _append_known_host(self, known_hosts_path: str, entry: str) -> None:
async with self._known_hosts_lock:
self._ensure_known_hosts_path(known_hosts_path, create_file=self._auto_add_known_hosts)
with open(known_hosts_path, "a", encoding="utf-8") as handle:
handle.write(entry)
async def _try_auto_add_known_host(self, connection: Connection) -> None:
known_hosts_path = self._resolve_known_hosts_path()
host_key = await self._fetch_host_key(connection)
entry = self._build_known_hosts_entry(connection.host, connection.port, host_key)
await self._append_known_host(known_hosts_path, entry)
fingerprint = host_key.get_fingerprint("sha256")
self._logger.info(
"Added SSH host key to known_hosts (host=%s port=%s fingerprint=%s)",
connection.host,
connection.port,
fingerprint,
)
async def connect_client(self, connection: Connection, auth_payload: dict[str, Any]) -> asyncssh.SSHClientConnection:
if self._allow_unknown_hosts:
return await self._open_client(connection, auth_payload, None)
try:
return await self._connect_with_known_hosts(connection, auth_payload)
except asyncssh.HostKeyNotVerifiable:
if not self._auto_add_known_hosts:
raise
await self._try_auto_add_known_host(connection)
return await self._connect_with_known_hosts(connection, auth_payload)
async def detect_remote_platform(self, connection: Connection, auth_payload: dict[str, Any]) -> tuple[str, str]:
client = await self.connect_client(connection, auth_payload)
try:
arch_result = await client.run("uname -m", check=False)
os_result = await client.run("uname -s", check=False)
arch = (arch_result.stdout or "").strip()
remote_os = (os_result.stdout or "").strip()
return arch, remote_os
finally:
client.close()
def resolve_keepalive_binary(self, arch: str, remote_os: str) -> tuple[Path, str] | None:
if not self._keepalive_binary_dir:
return None
normalized_arch = arch.strip().lower()
normalized_os = remote_os.strip().lower()
arch_alias = {
"arm64": "aarch64",
"x64": "x86_64",
"amd64": "x86_64",
"aarch64": "aarch64",
"x86_64": "x86_64",
}.get(normalized_arch, normalized_arch)
if normalized_os == "darwin":
filename = "keeplive.macos-arm64" if arch_alias == "aarch64" else "keeplive.macos-x86_64" if arch_alias == "x86_64" else None
elif normalized_os == "linux":
filename = "keeplive.aarch64" if arch_alias == "aarch64" else "keeplive.x86_64" if arch_alias == "x86_64" else None
else:
filename = None
if not filename:
self._logger.warning(
"resolve_keepalive_binary unsupported arch/os arch=%s os=%s normalized_arch=%s normalized_os=%s",
arch,
remote_os,
arch_alias,
normalized_os,
)
return None
binary_path = self._keepalive_binary_dir / filename
if not binary_path.exists() or not binary_path.is_file():
self._logger.warning(
"resolve_keepalive_binary missing file path=%s arch=%s os=%s",
binary_path,
arch,
remote_os,
)
return None
remote_path = f"/tmp/tmux.{arch_alias}"
self._logger.info(
"resolve_keepalive_binary matched local=%s remote=%s arch=%s os=%s",
binary_path,
remote_path,
arch,
remote_os,
)
return binary_path, remote_path
async def _upload_file_to_remote(self, client: asyncssh.SSHClientConnection, local_path: Path, remote_path: str) -> None:
async with client.start_sftp_client() as sftp:
await sftp.put(str(local_path), remote_path)
async def _ensure_remote_keepalive_binary(
self,
client: asyncssh.SSHClientConnection,
local_path: Path,
remote_path: str,
) -> None:
quoted_remote = shlex.quote(remote_path)
check_result = await client.run(f"test -x {quoted_remote}", check=False)
if check_result.exit_status == 0:
self._logger.info("reuse remote keepalive binary: %s", remote_path)
return
self._logger.info("upload remote keepalive binary local=%s remote=%s", local_path, remote_path)
await self._upload_file_to_remote(client, local_path, remote_path)
await client.run(f"chmod +x {quoted_remote}", check=False)
async def create_session(
self,
connection: Connection,
auth_payload: dict[str, Any],
pty: PtyInfo,
enhanced_enabled: bool = False,
enhanced_fingerprint: str | None = None,
tmux_binary_path: str | None = None,
session_id: str | None = None,
) -> ManagedSession:
client = await self.connect_client(connection, auth_payload)
resolved_session_id = session_id or uuid.uuid4().hex
session = ManagedSession(
session_id=resolved_session_id,
connection_id=connection.id,
user_id=connection.user_id,
client=client,
channel=None,
buffer=SessionBuffer(),
last_activity=utc_now(),
enhanced_enabled=enhanced_enabled,
enhanced_fingerprint=enhanced_fingerprint,
tmux_binary_path=tmux_binary_path,
)
def _start_channel() -> asyncssh.SSHClientSession:
manager = self
class TerminalSession(asyncssh.SSHClientSession):
def data_received(self, data: str, datatype: asyncssh.DataType | None = None) -> None:
session.buffer.append(data)
session.last_activity = utc_now()
if session.websockets:
for websocket in list(session.websockets):
asyncio.create_task(websocket.send_text(data))
def connection_lost(self, exc: Exception | None) -> None:
current = manager._sessions.get(session.session_id)
# Ignore stale callbacks from replaced/explicitly-closed sessions.
if current is not session:
LOGGER.info(
"connection_lost ignored for stale session session_id=%s",
session.session_id,
)
return
session.status = "disconnected"
session.last_activity = utc_now()
manager._sessions.pop(session.session_id, None)
manager._cancel_target_network_probe(session.session_id)
if manager._broadcaster:
asyncio.create_task(manager._broadcaster.broadcast(session.user_id, session.status_payload()))
if manager._on_session_disconnect:
manager._on_session_disconnect(session.session_id)
return TerminalSession()
channel, _ = await client.create_session(
session_factory=_start_channel,
term_type=pty.term,
term_size=(pty.cols, pty.rows),
request_pty=True,
)
session.channel = channel
if enhanced_enabled and enhanced_fingerprint and tmux_binary_path:
keepalive = self.resolve_keepalive_binary(connection.remote_arch or "", connection.remote_os or "")
if keepalive:
local_binary, _ = keepalive
await self._ensure_remote_keepalive_binary(client, local_binary, tmux_binary_path)
quoted_remote_binary = shlex.quote(tmux_binary_path)
quoted_fingerprint = shlex.quote(enhanced_fingerprint)
target_window = shlex.quote(f"{enhanced_fingerprint}:0")
target_pane = shlex.quote(f"{enhanced_fingerprint}:0.0")
res_new = await client.run(
f"{quoted_remote_binary} new-session -Ad -s {quoted_fingerprint} -x {pty.cols} -y {pty.rows}",
check=False,
)
res_status = await client.run(f"{quoted_remote_binary} set-option -t {quoted_fingerprint} status off", check=False)
res_destroy_unattached = await client.run(
f"{quoted_remote_binary} set-option -t {quoted_fingerprint} destroy-unattached off",
check=False,
)
res_mouse = await client.run(
f"{quoted_remote_binary} set-option -t {quoted_fingerprint} mouse on",
check=False,
)
res_border = await client.run(f"{quoted_remote_binary} set-window-option -t {target_window} pane-border-status off", check=False)
res_aggressive = await client.run(f"{quoted_remote_binary} set-window-option -t {target_window} aggressive-resize on", check=False)
res_mode = await client.run(f"{quoted_remote_binary} set-window-option -t {target_window} window-size manual", check=False)
res_window = await client.run(
f"{quoted_remote_binary} resize-window -t {target_window} -x {pty.cols} -y {pty.rows}",
check=False,
)
res_pane = await client.run(
f"{quoted_remote_binary} resize-pane -t {target_pane} -x {pty.cols} -y {pty.rows}",
check=False,
)
res_has = await client.run(
f"{quoted_remote_binary} has-session -t {quoted_fingerprint}",
check=False,
)
self._logger.info(
"tmux-create session_id=%s fingerprint=%s requested=%sx%s exits(new=%s,status=%s,destroy_unattached=%s,mouse=%s,border=%s,aggressive=%s,mode=%s,window=%s,pane=%s,has=%s)",
resolved_session_id,
enhanced_fingerprint,
pty.rows,
pty.cols,
res_new.exit_status,
res_status.exit_status,
res_destroy_unattached.exit_status,
res_mouse.exit_status,
res_border.exit_status,
res_aggressive.exit_status,
res_mode.exit_status,
res_window.exit_status,
res_pane.exit_status,
res_has.exit_status,
)
if res_has.exit_status != 0:
raise RuntimeError(
f"tmux session not available after create (fingerprint={enhanced_fingerprint}, has_exit={res_has.exit_status})"
)
await session._collect_tmux_metrics("create_before_attach", pty.rows, pty.cols)
# Use exec to avoid silently falling back to an interactive shell
# when attach fails.
channel.write(f"exec {quoted_remote_binary} attach -t {quoted_fingerprint}\n")
self._schedule_tmux_probe(session, pty.rows, pty.cols)
session.status = "active"
replaced_session = self._sessions.get(resolved_session_id)
self._sessions[resolved_session_id] = session
self._ensure_target_network_probe(session)
if replaced_session and replaced_session is not session:
if replaced_session.websockets:
session.websockets.update(replaced_session.websockets)
replaced_session.websockets.clear()
try:
await replaced_session.close()
except Exception as exc:
self._logger.warning(
"failed to close replaced session session_id=%s error=%s",
resolved_session_id,
exc,
)
return session
async def _terminate_enhanced_tmux(self, session: ManagedSession) -> None:
if not session.enhanced_enabled or not session.enhanced_fingerprint or not session.tmux_binary_path:
return
quoted_remote_binary = shlex.quote(session.tmux_binary_path)
quoted_fingerprint = shlex.quote(session.enhanced_fingerprint)
try:
await session.client.run(
f"{quoted_remote_binary} kill-session -t {quoted_fingerprint}",
check=False,
)
except Exception as exc:
self._logger.warning(
"failed to terminate enhanced tmux session session_id=%s fingerprint=%s error=%s",
session.session_id,
session.enhanced_fingerprint,
exc,
)
async def close_session(self, session_id: str, terminate_enhanced: bool = False) -> None:
self._cancel_target_network_probe(session_id)
session = self._sessions.pop(session_id, None)
if session:
if terminate_enhanced:
await self._terminate_enhanced_tmux(session)
await session.close()
async def open_enhanced_attach(
self,
connection: Connection,
auth_payload: dict[str, Any],
pty: PtyInfo,
tmux_binary_path: str,
fingerprint: str,
) -> ManagedSession:
return await self.create_session(
connection=connection,
auth_payload=auth_payload,
pty=pty,
enhanced_enabled=True,
enhanced_fingerprint=fingerprint,
tmux_binary_path=tmux_binary_path,
)
def serialize_pty(self, pty: PtyInfo) -> str:
return json.dumps({"term": pty.term, "rows": pty.rows, "cols": pty.cols})
def deserialize_pty(self, payload: str) -> PtyInfo:
data: dict[str, Any] = json.loads(payload)
return PtyInfo(term=data.get("term", "xterm-256color"), rows=int(data.get("rows", 24)), cols=int(data.get("cols", 80)))