ptn 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +381 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +7 -0
  6. porterminal/application/ports/connection_port.py +34 -0
  7. porterminal/application/services/__init__.py +13 -0
  8. porterminal/application/services/management_service.py +279 -0
  9. porterminal/application/services/session_service.py +249 -0
  10. porterminal/application/services/tab_service.py +286 -0
  11. porterminal/application/services/terminal_service.py +426 -0
  12. porterminal/asgi.py +38 -0
  13. porterminal/cli/__init__.py +19 -0
  14. porterminal/cli/args.py +91 -0
  15. porterminal/cli/display.py +157 -0
  16. porterminal/composition.py +208 -0
  17. porterminal/config.py +195 -0
  18. porterminal/container.py +65 -0
  19. porterminal/domain/__init__.py +91 -0
  20. porterminal/domain/entities/__init__.py +16 -0
  21. porterminal/domain/entities/output_buffer.py +73 -0
  22. porterminal/domain/entities/session.py +86 -0
  23. porterminal/domain/entities/tab.py +71 -0
  24. porterminal/domain/ports/__init__.py +12 -0
  25. porterminal/domain/ports/pty_port.py +80 -0
  26. porterminal/domain/ports/session_repository.py +58 -0
  27. porterminal/domain/ports/tab_repository.py +75 -0
  28. porterminal/domain/services/__init__.py +18 -0
  29. porterminal/domain/services/environment_sanitizer.py +61 -0
  30. porterminal/domain/services/rate_limiter.py +63 -0
  31. porterminal/domain/services/session_limits.py +104 -0
  32. porterminal/domain/services/tab_limits.py +54 -0
  33. porterminal/domain/values/__init__.py +25 -0
  34. porterminal/domain/values/environment_rules.py +156 -0
  35. porterminal/domain/values/rate_limit_config.py +21 -0
  36. porterminal/domain/values/session_id.py +20 -0
  37. porterminal/domain/values/shell_command.py +37 -0
  38. porterminal/domain/values/tab_id.py +24 -0
  39. porterminal/domain/values/terminal_dimensions.py +45 -0
  40. porterminal/domain/values/user_id.py +25 -0
  41. porterminal/infrastructure/__init__.py +20 -0
  42. porterminal/infrastructure/cloudflared.py +295 -0
  43. porterminal/infrastructure/config/__init__.py +9 -0
  44. porterminal/infrastructure/config/shell_detector.py +84 -0
  45. porterminal/infrastructure/config/yaml_loader.py +34 -0
  46. porterminal/infrastructure/network.py +43 -0
  47. porterminal/infrastructure/registry/__init__.py +5 -0
  48. porterminal/infrastructure/registry/user_connection_registry.py +104 -0
  49. porterminal/infrastructure/repositories/__init__.py +9 -0
  50. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  51. porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
  52. porterminal/infrastructure/server.py +161 -0
  53. porterminal/infrastructure/web/__init__.py +7 -0
  54. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  55. porterminal/logging_setup.py +48 -0
  56. porterminal/pty/__init__.py +46 -0
  57. porterminal/pty/env.py +97 -0
  58. porterminal/pty/manager.py +163 -0
  59. porterminal/pty/protocol.py +84 -0
  60. porterminal/pty/unix.py +162 -0
  61. porterminal/pty/windows.py +131 -0
  62. porterminal/static/assets/app-BQiuUo6Q.css +32 -0
  63. porterminal/static/assets/app-YNN_jEhv.js +71 -0
  64. porterminal/static/icon.svg +34 -0
  65. porterminal/static/index.html +139 -0
  66. porterminal/static/manifest.json +31 -0
  67. porterminal/static/sw.js +66 -0
  68. porterminal/updater.py +257 -0
  69. ptn-0.1.4.dist-info/METADATA +191 -0
  70. ptn-0.1.4.dist-info/RECORD +73 -0
  71. ptn-0.1.4.dist-info/WHEEL +4 -0
  72. ptn-0.1.4.dist-info/entry_points.txt +2 -0
  73. ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,286 @@
1
+ """Tab service - tab lifecycle management and synchronization."""
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+
7
+ from porterminal.domain import (
8
+ SessionId,
9
+ Tab,
10
+ TabId,
11
+ TabLimitChecker,
12
+ UserId,
13
+ )
14
+ from porterminal.domain.ports import TabRepository
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TabService:
20
+ """Service for managing terminal tabs.
21
+
22
+ Handles tab creation, removal, and synchronization.
23
+ Coordinates with SessionService for session management.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ repository: TabRepository,
29
+ limit_checker: TabLimitChecker | None = None,
30
+ ) -> None:
31
+ self._repository = repository
32
+ self._limit_checker = limit_checker or TabLimitChecker()
33
+
34
+ def create_tab(
35
+ self,
36
+ user_id: UserId,
37
+ session_id: SessionId,
38
+ shell_id: str,
39
+ name: str | None = None,
40
+ ) -> Tab:
41
+ """Create a new tab for a session.
42
+
43
+ Args:
44
+ user_id: User creating the tab.
45
+ session_id: Session this tab references.
46
+ shell_id: Shell type identifier.
47
+ name: Optional tab name (generated if not provided).
48
+
49
+ Returns:
50
+ Created tab.
51
+
52
+ Raises:
53
+ ValueError: If tab limits exceeded or invalid input.
54
+ """
55
+ # Check limits
56
+ user_tab_count = self._repository.count_for_user(user_id)
57
+ limit_result = self._limit_checker.can_create_tab(user_id, user_tab_count)
58
+ if not limit_result.allowed:
59
+ raise ValueError(limit_result.reason)
60
+
61
+ # Generate name if not provided
62
+ if not name:
63
+ name = shell_id.capitalize()
64
+
65
+ # Create tab
66
+ now = datetime.now(UTC)
67
+ tab = Tab(
68
+ id=TabId(str(uuid.uuid4())),
69
+ user_id=user_id,
70
+ session_id=session_id,
71
+ shell_id=shell_id,
72
+ name=name,
73
+ created_at=now,
74
+ last_accessed=now,
75
+ )
76
+
77
+ self._repository.add(tab)
78
+ logger.info(
79
+ "Tab created user_id=%s tab_id=%s session_id=%s",
80
+ user_id,
81
+ tab.id,
82
+ session_id,
83
+ )
84
+ return tab
85
+
86
+ def get_tab(self, tab_id: str) -> Tab | None:
87
+ """Get a tab by ID string."""
88
+ return self._repository.get_by_id_str(tab_id)
89
+
90
+ def get_user_tabs(self, user_id: UserId) -> list[Tab]:
91
+ """Get all tabs for a user.
92
+
93
+ Returns:
94
+ List of tabs ordered by created_at ASC.
95
+ """
96
+ return self._repository.get_by_user(user_id)
97
+
98
+ def get_tabs_for_session(self, session_id: SessionId) -> list[Tab]:
99
+ """Get all tabs referencing a session."""
100
+ return self._repository.get_by_session(session_id)
101
+
102
+ def touch_tab(self, tab_id: str, user_id: UserId) -> Tab | None:
103
+ """Update tab's last accessed time.
104
+
105
+ Args:
106
+ tab_id: Tab to touch.
107
+ user_id: Requesting user (for authorization).
108
+
109
+ Returns:
110
+ Updated tab or None if not found/unauthorized.
111
+ """
112
+ tab = self._repository.get_by_id_str(tab_id)
113
+ if not tab:
114
+ return None
115
+
116
+ # Check ownership
117
+ limit_result = self._limit_checker.can_access_tab(tab, user_id)
118
+ if not limit_result.allowed:
119
+ logger.warning(
120
+ "Tab access denied tab_id=%s user_id=%s reason=%s",
121
+ tab_id,
122
+ user_id,
123
+ limit_result.reason,
124
+ )
125
+ return None
126
+
127
+ tab.touch(datetime.now(UTC))
128
+ self._repository.update(tab)
129
+ return tab
130
+
131
+ def rename_tab(self, tab_id: str, user_id: UserId, new_name: str) -> Tab | None:
132
+ """Rename a tab.
133
+
134
+ Args:
135
+ tab_id: Tab to rename.
136
+ user_id: Requesting user (for authorization).
137
+ new_name: New name for the tab.
138
+
139
+ Returns:
140
+ Updated tab or None if not found/unauthorized.
141
+ """
142
+ tab = self._repository.get_by_id_str(tab_id)
143
+ if not tab:
144
+ return None
145
+
146
+ # Check ownership
147
+ limit_result = self._limit_checker.can_access_tab(tab, user_id)
148
+ if not limit_result.allowed:
149
+ return None
150
+
151
+ try:
152
+ tab.rename(new_name)
153
+ self._repository.update(tab)
154
+ logger.info("Tab renamed tab_id=%s new_name=%s", tab_id, new_name)
155
+ return tab
156
+ except ValueError as e:
157
+ logger.warning("Tab rename failed tab_id=%s error=%s", tab_id, e)
158
+ return None
159
+
160
+ def close_tab(self, tab_id: str, user_id: UserId) -> Tab | None:
161
+ """Close a tab.
162
+
163
+ Args:
164
+ tab_id: Tab to close.
165
+ user_id: Requesting user (for authorization).
166
+
167
+ Returns:
168
+ Removed tab or None if not found/unauthorized.
169
+ """
170
+ tab = self._repository.get_by_id_str(tab_id)
171
+ if not tab:
172
+ return None
173
+
174
+ # Check ownership
175
+ limit_result = self._limit_checker.can_access_tab(tab, user_id)
176
+ if not limit_result.allowed:
177
+ return None
178
+
179
+ removed = self._repository.remove(tab.id)
180
+ if removed:
181
+ logger.info(
182
+ "Tab closed user_id=%s tab_id=%s session_id=%s",
183
+ user_id,
184
+ tab_id,
185
+ removed.session_id,
186
+ )
187
+ return removed
188
+
189
+ def close_tabs_for_session(self, session_id: SessionId) -> list[Tab]:
190
+ """Close all tabs referencing a session (cascade).
191
+
192
+ Called when a session is destroyed.
193
+
194
+ Returns:
195
+ List of removed tabs.
196
+ """
197
+ removed = self._repository.remove_by_session(session_id)
198
+ if removed:
199
+ logger.info(
200
+ "Tabs closed for session session_id=%s count=%d",
201
+ session_id,
202
+ len(removed),
203
+ )
204
+ return removed
205
+
206
+ def tab_count(self, user_id: UserId | None = None) -> int:
207
+ """Get tab count.
208
+
209
+ Args:
210
+ user_id: If provided, count for this user only.
211
+
212
+ Returns:
213
+ Tab count.
214
+ """
215
+ if user_id:
216
+ return self._repository.count_for_user(user_id)
217
+ return self._repository.count()
218
+
219
+ def build_tab_list_message(self, user_id: UserId) -> dict:
220
+ """Build a tab_list message for a user.
221
+
222
+ Returns:
223
+ Message dict ready for WebSocket send.
224
+ """
225
+ tabs = self.get_user_tabs(user_id)
226
+ return {
227
+ "type": "tab_list",
228
+ "tabs": [tab.to_dict() for tab in tabs],
229
+ "timestamp": datetime.now(UTC).isoformat(),
230
+ }
231
+
232
+ def build_tab_created_message(self, tab: Tab) -> dict:
233
+ """Build a tab_created message for broadcasting.
234
+
235
+ Returns:
236
+ Message dict ready for WebSocket send.
237
+ """
238
+ return {
239
+ "type": "tab_created",
240
+ "tab": tab.to_dict(),
241
+ }
242
+
243
+ def build_tab_closed_message(self, tab_id: str, reason: str = "user") -> dict:
244
+ """Build a tab_closed message for broadcasting.
245
+
246
+ Returns:
247
+ Message dict ready for WebSocket send.
248
+ """
249
+ return {
250
+ "type": "tab_closed",
251
+ "tab_id": tab_id,
252
+ "reason": reason,
253
+ }
254
+
255
+ def build_tab_state_sync(self, user_id: UserId) -> dict:
256
+ """Build full state sync message for a user.
257
+
258
+ Returns:
259
+ Message dict with all tabs for the user.
260
+ """
261
+ tabs = self.get_user_tabs(user_id)
262
+ return {
263
+ "type": "tab_state_sync",
264
+ "tabs": [tab.to_dict() for tab in tabs],
265
+ }
266
+
267
+ def build_tab_state_update(self, action: str, tab: Tab, reason: str | None = None) -> dict:
268
+ """Build incremental state update message.
269
+
270
+ Args:
271
+ action: One of 'add', 'remove', 'update'.
272
+ tab: Tab that changed.
273
+ reason: Optional reason (for 'remove' action).
274
+
275
+ Returns:
276
+ Message dict with the state change.
277
+ """
278
+ change: dict = {"action": action, "tab_id": tab.tab_id}
279
+ if action in ("add", "update"):
280
+ change["tab"] = tab.to_dict()
281
+ if reason:
282
+ change["reason"] = reason
283
+ return {
284
+ "type": "tab_state_update",
285
+ "changes": [change],
286
+ }
@@ -0,0 +1,426 @@
1
+ """Terminal service - terminal I/O coordination."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from contextlib import suppress
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from porterminal.domain import (
11
+ PTYPort,
12
+ RateLimitConfig,
13
+ Session,
14
+ TerminalDimensions,
15
+ TokenBucketRateLimiter,
16
+ )
17
+
18
+ from ..ports.connection_port import ConnectionPort
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Terminal response sequences that should NOT be written to PTY.
23
+ # These are responses from the terminal emulator to queries from applications.
24
+ # If written to PTY, they get echoed back and displayed as garbage.
25
+ #
26
+ # Patterns:
27
+ # \x1b[?...c - Device Attributes (DA) response
28
+ # \x1b[...R - Cursor Position Report (CPR) response
29
+ TERMINAL_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]*c|\x1b\[[\d;]*R")
30
+
31
+ # Constants
32
+ HEARTBEAT_INTERVAL = 30 # seconds
33
+ HEARTBEAT_TIMEOUT = 300 # 5 minutes
34
+ PTY_READ_INTERVAL = 0.008 # ~120Hz polling
35
+ OUTPUT_BATCH_INTERVAL = 0.016 # ~60Hz output (batch writes for smoother rendering)
36
+ OUTPUT_BATCH_MAX_SIZE = 16384 # Flush if batch exceeds 16KB
37
+ INTERACTIVE_THRESHOLD = 64 # Bytes - flush immediately for small interactive data
38
+ MAX_INPUT_SIZE = 4096
39
+
40
+
41
+ class AsyncioClock:
42
+ """Clock implementation using asyncio event loop time."""
43
+
44
+ def now(self) -> float:
45
+ return asyncio.get_running_loop().time()
46
+
47
+
48
+ class TerminalService:
49
+ """Service for handling terminal I/O.
50
+
51
+ Coordinates PTY reads, WebSocket writes, and message handling.
52
+ Supports multiple clients connected to the same session.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ rate_limit_config: RateLimitConfig | None = None,
58
+ max_input_size: int = MAX_INPUT_SIZE,
59
+ ) -> None:
60
+ self._rate_limit_config = rate_limit_config or RateLimitConfig()
61
+ self._max_input_size = max_input_size
62
+
63
+ # Multi-client support: track connections and read loops per session
64
+ self._session_connections: dict[str, set[ConnectionPort]] = {}
65
+ self._session_read_tasks: dict[str, asyncio.Task[None]] = {}
66
+
67
+ # -------------------------------------------------------------------------
68
+ # Multi-client connection tracking
69
+ # -------------------------------------------------------------------------
70
+
71
+ def _register_connection(self, session_id: str, connection: ConnectionPort) -> int:
72
+ """Register a connection for a session. Returns connection count."""
73
+ if session_id not in self._session_connections:
74
+ self._session_connections[session_id] = set()
75
+ self._session_connections[session_id].add(connection)
76
+ return len(self._session_connections[session_id])
77
+
78
+ def _unregister_connection(self, session_id: str, connection: ConnectionPort) -> int:
79
+ """Unregister a connection. Returns remaining count."""
80
+ if session_id not in self._session_connections:
81
+ return 0
82
+ self._session_connections[session_id].discard(connection)
83
+ count = len(self._session_connections[session_id])
84
+ if count == 0:
85
+ del self._session_connections[session_id]
86
+ return count
87
+
88
+ async def _broadcast_output(self, session_id: str, data: bytes) -> None:
89
+ """Broadcast PTY output to all connections for a session."""
90
+ connections = self._session_connections.get(session_id, set())
91
+ dead: list[ConnectionPort] = []
92
+ for conn in list(connections): # Copy to avoid mutation during iteration
93
+ try:
94
+ await conn.send_output(data)
95
+ except Exception:
96
+ dead.append(conn)
97
+ for conn in dead:
98
+ connections.discard(conn)
99
+
100
+ async def _broadcast_message(self, session_id: str, message: dict[str, Any]) -> None:
101
+ """Broadcast JSON message to all connections for a session."""
102
+ connections = self._session_connections.get(session_id, set())
103
+ dead: list[ConnectionPort] = []
104
+ for conn in list(connections):
105
+ try:
106
+ await conn.send_message(message)
107
+ except Exception:
108
+ dead.append(conn)
109
+ for conn in dead:
110
+ connections.discard(conn)
111
+
112
+ async def handle_session(
113
+ self,
114
+ session: Session[PTYPort],
115
+ connection: ConnectionPort,
116
+ skip_buffer: bool = False,
117
+ ) -> None:
118
+ """Handle terminal session I/O with multi-client support.
119
+
120
+ Multiple clients can connect to the same session simultaneously.
121
+ The first client starts the PTY read loop; the last client stops it.
122
+
123
+ Args:
124
+ session: Terminal session to handle.
125
+ connection: Network connection to client.
126
+ skip_buffer: Whether to skip sending buffered output.
127
+ """
128
+ session_id = str(session.id)
129
+ clock = AsyncioClock()
130
+ rate_limiter = TokenBucketRateLimiter(self._rate_limit_config, clock)
131
+
132
+ # Register this connection
133
+ connection_count = self._register_connection(session_id, connection)
134
+ is_first_client = connection_count == 1
135
+
136
+ logger.info(
137
+ "Client connected session_id=%s connection_count=%d",
138
+ session_id,
139
+ connection_count,
140
+ )
141
+
142
+ try:
143
+ # First client starts the shared PTY read loop
144
+ if is_first_client:
145
+ self._start_broadcast_read_loop(session, session_id)
146
+
147
+ # Note: session_info is sent by the caller (app.py) to include tab_id
148
+
149
+ # Replay buffered output to THIS connection only (not broadcast)
150
+ if not skip_buffer and not session.output_buffer.is_empty:
151
+ buffered = session.get_buffered_output()
152
+ # Don't clear buffer - other clients may need it too
153
+ if buffered:
154
+ await connection.send_output(buffered)
155
+
156
+ # Start heartbeat for this connection
157
+ heartbeat_task = asyncio.create_task(self._heartbeat_loop(connection))
158
+
159
+ try:
160
+ await self._handle_input_loop(session, connection, rate_limiter)
161
+ finally:
162
+ heartbeat_task.cancel()
163
+ with suppress(asyncio.CancelledError):
164
+ await heartbeat_task
165
+
166
+ finally:
167
+ # Unregister this connection
168
+ remaining = self._unregister_connection(session_id, connection)
169
+
170
+ logger.info(
171
+ "Client disconnected session_id=%s remaining_connections=%d",
172
+ session_id,
173
+ remaining,
174
+ )
175
+
176
+ # Last client: stop the read loop
177
+ if remaining == 0:
178
+ await self._stop_broadcast_read_loop(session_id)
179
+
180
+ def _start_broadcast_read_loop(
181
+ self,
182
+ session: Session[PTYPort],
183
+ session_id: str,
184
+ ) -> None:
185
+ """Start the PTY read loop that broadcasts to all clients."""
186
+ if session_id in self._session_read_tasks:
187
+ return # Already running
188
+
189
+ task = asyncio.create_task(self._read_pty_broadcast_loop(session, session_id))
190
+ self._session_read_tasks[session_id] = task
191
+ logger.debug("Started broadcast read loop session_id=%s", session_id)
192
+
193
+ async def _stop_broadcast_read_loop(self, session_id: str) -> None:
194
+ """Stop the PTY read loop for a session."""
195
+ task = self._session_read_tasks.pop(session_id, None)
196
+ if task and not task.done():
197
+ task.cancel()
198
+ with suppress(asyncio.CancelledError):
199
+ await task
200
+ logger.debug("Stopped broadcast read loop session_id=%s", session_id)
201
+
202
+ async def _read_pty_broadcast_loop(
203
+ self,
204
+ session: Session[PTYPort],
205
+ session_id: str,
206
+ ) -> None:
207
+ """Read from PTY and broadcast to all connected clients.
208
+
209
+ Single loop per session, regardless of client count.
210
+
211
+ Batching strategy:
212
+ - Small data (<64 bytes): flush immediately for interactive responsiveness
213
+ - Large data: batch for ~16ms to reduce WebSocket message frequency
214
+ - Flush if batch exceeds 16KB to prevent memory buildup
215
+ """
216
+ # Check if PTY is alive at start
217
+ if not session.pty_handle.is_alive():
218
+ logger.error("PTY not alive at start session_id=%s", session.id)
219
+ await self._broadcast_output(session_id, b"\r\n[PTY failed to start]\r\n")
220
+ return
221
+
222
+ batch_buffer: list[bytes] = []
223
+ batch_size = 0
224
+ last_flush_time = asyncio.get_running_loop().time()
225
+
226
+ async def flush_batch() -> None:
227
+ nonlocal batch_buffer, batch_size, last_flush_time
228
+ if batch_buffer:
229
+ combined = b"".join(batch_buffer)
230
+ batch_buffer = []
231
+ batch_size = 0
232
+ last_flush_time = asyncio.get_running_loop().time()
233
+ await self._broadcast_output(session_id, combined)
234
+
235
+ def has_connections() -> bool:
236
+ return (
237
+ session_id in self._session_connections
238
+ and len(self._session_connections[session_id]) > 0
239
+ )
240
+
241
+ while has_connections() and session.pty_handle.is_alive():
242
+ try:
243
+ data = session.pty_handle.read(4096)
244
+ if data:
245
+ session.add_output(data)
246
+ session.touch(datetime.now(UTC))
247
+
248
+ # Small data (interactive): flush immediately for responsiveness
249
+ if len(data) < INTERACTIVE_THRESHOLD and not batch_buffer:
250
+ await self._broadcast_output(session_id, data)
251
+ else:
252
+ # Batch larger data
253
+ batch_buffer.append(data)
254
+ batch_size += len(data)
255
+
256
+ # Flush if batch is large enough
257
+ if batch_size >= OUTPUT_BATCH_MAX_SIZE:
258
+ await flush_batch()
259
+
260
+ except Exception as e:
261
+ logger.error("PTY read error session_id=%s: %s", session.id, e)
262
+ await flush_batch() # Flush any pending data
263
+ await self._broadcast_output(session_id, f"\r\n[PTY error: {e}]\r\n".encode())
264
+ break
265
+
266
+ # Check if we should flush based on time
267
+ current_time = asyncio.get_running_loop().time()
268
+ if batch_buffer and (current_time - last_flush_time) >= OUTPUT_BATCH_INTERVAL:
269
+ await flush_batch()
270
+
271
+ await asyncio.sleep(PTY_READ_INTERVAL)
272
+
273
+ # Flush any remaining data
274
+ await flush_batch()
275
+
276
+ # Notify all clients if PTY died
277
+ if has_connections() and not session.pty_handle.is_alive():
278
+ await self._broadcast_output(session_id, b"\r\n[Shell exited]\r\n")
279
+
280
+ async def _heartbeat_loop(self, connection: ConnectionPort) -> None:
281
+ """Send periodic heartbeat pings."""
282
+ while connection.is_connected():
283
+ await asyncio.sleep(HEARTBEAT_INTERVAL)
284
+ try:
285
+ await connection.send_message({"type": "ping"})
286
+ except Exception:
287
+ break
288
+
289
+ async def _handle_input_loop(
290
+ self,
291
+ session: Session[PTYPort],
292
+ connection: ConnectionPort,
293
+ rate_limiter: TokenBucketRateLimiter,
294
+ ) -> None:
295
+ """Handle input from client."""
296
+ while connection.is_connected():
297
+ try:
298
+ message = await connection.receive()
299
+ except Exception:
300
+ break
301
+
302
+ if isinstance(message, bytes):
303
+ await self._handle_binary_input(session, message, rate_limiter, connection)
304
+ elif isinstance(message, dict):
305
+ await self._handle_json_message(session, message, rate_limiter, connection)
306
+
307
+ async def _handle_binary_input(
308
+ self,
309
+ session: Session[PTYPort],
310
+ data: bytes,
311
+ rate_limiter: TokenBucketRateLimiter,
312
+ connection: ConnectionPort,
313
+ ) -> None:
314
+ """Handle binary terminal input."""
315
+ if len(data) > self._max_input_size:
316
+ await connection.send_message(
317
+ {
318
+ "type": "error",
319
+ "message": "Input too large",
320
+ }
321
+ )
322
+ return
323
+
324
+ # Filter terminal response sequences before writing to PTY.
325
+ # xterm.js generates these in response to DA/CPR queries.
326
+ # If written back to PTY, they get echoed and displayed as garbage.
327
+ filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", data)
328
+ if not filtered:
329
+ return
330
+
331
+ if rate_limiter.try_acquire(len(filtered)):
332
+ session.pty_handle.write(filtered)
333
+ session.touch(datetime.now(UTC))
334
+ else:
335
+ await connection.send_message(
336
+ {
337
+ "type": "error",
338
+ "message": "Rate limit exceeded",
339
+ }
340
+ )
341
+ logger.warning("Rate limit exceeded session_id=%s", session.id)
342
+
343
+ async def _handle_json_message(
344
+ self,
345
+ session: Session[PTYPort],
346
+ message: dict[str, Any],
347
+ rate_limiter: TokenBucketRateLimiter,
348
+ connection: ConnectionPort,
349
+ ) -> None:
350
+ """Handle JSON control message."""
351
+ msg_type = message.get("type")
352
+
353
+ if msg_type == "resize":
354
+ await self._handle_resize(session, message)
355
+ elif msg_type == "input":
356
+ await self._handle_json_input(session, message, rate_limiter, connection)
357
+ elif msg_type == "ping":
358
+ await connection.send_message({"type": "pong"})
359
+ session.touch(datetime.now(UTC))
360
+ elif msg_type == "pong":
361
+ session.touch(datetime.now(UTC))
362
+ else:
363
+ logger.warning("Unknown message type session_id=%s type=%s", session.id, msg_type)
364
+
365
+ async def _handle_resize(
366
+ self,
367
+ session: Session[PTYPort],
368
+ message: dict[str, Any],
369
+ ) -> None:
370
+ """Handle terminal resize message."""
371
+ cols = int(message.get("cols", 120))
372
+ rows = int(message.get("rows", 30))
373
+
374
+ new_dims = TerminalDimensions.clamped(cols, rows)
375
+
376
+ # Skip if same as current
377
+ if session.dimensions == new_dims:
378
+ return
379
+
380
+ session.update_dimensions(new_dims)
381
+ session.pty_handle.resize(new_dims)
382
+ session.touch(datetime.now(UTC))
383
+
384
+ logger.info(
385
+ "Terminal resized session_id=%s cols=%d rows=%d",
386
+ session.id,
387
+ new_dims.cols,
388
+ new_dims.rows,
389
+ )
390
+
391
+ async def _handle_json_input(
392
+ self,
393
+ session: Session[PTYPort],
394
+ message: dict[str, Any],
395
+ rate_limiter: TokenBucketRateLimiter,
396
+ connection: ConnectionPort,
397
+ ) -> None:
398
+ """Handle JSON-encoded terminal input."""
399
+ data = message.get("data", "")
400
+
401
+ if len(data) > self._max_input_size:
402
+ await connection.send_message(
403
+ {
404
+ "type": "error",
405
+ "message": "Input too large",
406
+ }
407
+ )
408
+ return
409
+
410
+ if data:
411
+ input_bytes = data.encode("utf-8")
412
+ # Filter terminal response sequences
413
+ filtered = TERMINAL_RESPONSE_PATTERN.sub(b"", input_bytes)
414
+ if not filtered:
415
+ return
416
+
417
+ if rate_limiter.try_acquire(len(filtered)):
418
+ session.pty_handle.write(filtered)
419
+ session.touch(datetime.now(UTC))
420
+ else:
421
+ await connection.send_message(
422
+ {
423
+ "type": "error",
424
+ "message": "Rate limit exceeded",
425
+ }
426
+ )