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.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|