arkaos 3.70.10 → 3.71.1

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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.70.10
1
+ 3.71.1
@@ -16,6 +16,7 @@ from core.terminal.session import (
16
16
  )
17
17
  from core.terminal.audit import log_end, log_start
18
18
  from core.terminal.token import current_token
19
+ from core.terminal.connections import ConnectionRegistry
19
20
 
20
21
  __all__ = [
21
22
  "TerminalSession",
@@ -24,4 +25,5 @@ __all__ = [
24
25
  "log_start",
25
26
  "log_end",
26
27
  "current_token",
28
+ "ConnectionRegistry",
27
29
  ]
@@ -0,0 +1,46 @@
1
+ """Single active WebSocket connection per terminal session (v3.71.1).
2
+
3
+ asyncio allows only one reader per fd, so two concurrent WebSockets on the
4
+ same session would fight over the PTY master fd and the scrollback replay
5
+ could duplicate output. This registry enforces "latest wins": a new
6
+ connection supersedes the previous one (which the endpoint then closes),
7
+ and release is guarded so a superseded connection's teardown cannot evict
8
+ its replacement.
9
+
10
+ Pure bookkeeping — no FastAPI import, so it's unit-testable standalone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Optional
16
+
17
+
18
+ class ConnectionRegistry:
19
+ """Tracks the one live connection per session id."""
20
+
21
+ def __init__(self) -> None:
22
+ self._active: dict[str, Any] = {}
23
+
24
+ def acquire(self, session_id: str, conn: Any) -> Optional[Any]:
25
+ """Make ``conn`` the active connection for ``session_id``.
26
+
27
+ Returns the connection it superseded (for the caller to close), or
28
+ ``None`` when there was none / it was already active.
29
+ """
30
+ old = self._active.get(session_id)
31
+ self._active[session_id] = conn
32
+ return old if old is not conn else None
33
+
34
+ def release(self, session_id: str, conn: Any) -> bool:
35
+ """Drop ``conn`` iff it is still the active connection.
36
+
37
+ Returns whether it was — the caller should only tear down shared
38
+ resources (the fd reader) when this is ``True``.
39
+ """
40
+ if self._active.get(session_id) is conn:
41
+ del self._active[session_id]
42
+ return True
43
+ return False
44
+
45
+ def is_active(self, session_id: str, conn: Any) -> bool:
46
+ return self._active.get(session_id) is conn
@@ -46,6 +46,13 @@ def _default_cwd() -> str:
46
46
  return os.path.expanduser("~")
47
47
 
48
48
 
49
+ # v3.71.0 — in-memory scrollback so a reconnecting client (after the
50
+ # operator navigates away or reloads the dashboard) can replay recent
51
+ # output and find its session as it left it. Bounded, RAM-only, cleared
52
+ # on close, never written to disk and never sent to the audit log.
53
+ DEFAULT_SCROLLBACK_BYTES = 512 * 1024
54
+
55
+
49
56
  class TerminalSession:
50
57
  """A single forked PTY + the bookkeeping needed to drive it.
51
58
 
@@ -60,6 +67,7 @@ class TerminalSession:
60
67
  cwd: str,
61
68
  cols: int = 120,
62
69
  rows: int = 32,
70
+ scrollback_bytes: int = DEFAULT_SCROLLBACK_BYTES,
63
71
  ) -> None:
64
72
  self.session_id = session_id
65
73
  self.shell = shell
@@ -68,6 +76,8 @@ class TerminalSession:
68
76
  self.last_activity = time.monotonic()
69
77
  self.exit_code: Optional[int] = None
70
78
  self.title: str = ""
79
+ self.scrollback_max = max(0, int(scrollback_bytes))
80
+ self._scrollback = bytearray()
71
81
  self._closed = False
72
82
  self.pid, self.master_fd = pty.fork()
73
83
  if self.pid == 0:
@@ -104,8 +114,21 @@ class TerminalSession:
104
114
  raise
105
115
  if data:
106
116
  self.last_activity = time.monotonic()
117
+ self._record(data)
107
118
  return data
108
119
 
120
+ def _record(self, data: bytes) -> None:
121
+ """Append output to the bounded scrollback, evicting the oldest."""
122
+ if self.scrollback_max <= 0:
123
+ return
124
+ self._scrollback += data
125
+ if len(self._scrollback) > self.scrollback_max:
126
+ del self._scrollback[: -self.scrollback_max]
127
+
128
+ def scrollback(self) -> bytes:
129
+ """Snapshot of recent output, for replay on (re)connect."""
130
+ return bytes(self._scrollback)
131
+
109
132
  def write(self, data: bytes) -> int:
110
133
  if self._closed or self.master_fd < 0 or not data:
111
134
  return 0
@@ -172,6 +195,7 @@ class TerminalSession:
172
195
  except OSError:
173
196
  pass
174
197
  self.master_fd = -1
198
+ self._scrollback.clear()
175
199
  self._closed = True
176
200
 
177
201
  def to_dict(self) -> dict[str, Any]:
@@ -207,6 +231,9 @@ class TerminalSessionManager:
207
231
  self.idle_timeout_s = int(
208
232
  os.environ.get("ARKAOS_TERMINAL_IDLE_S", idle_default)
209
233
  )
234
+ self.scrollback_bytes = int(
235
+ os.environ.get("ARKAOS_TERMINAL_SCROLLBACK_BYTES", DEFAULT_SCROLLBACK_BYTES)
236
+ )
210
237
  self._sessions: dict[str, TerminalSession] = {}
211
238
 
212
239
  def create(
@@ -230,6 +257,7 @@ class TerminalSessionManager:
230
257
  cwd=chosen_cwd,
231
258
  cols=cols,
232
259
  rows=rows,
260
+ scrollback_bytes=self.scrollback_bytes,
233
261
  )
234
262
  self._sessions[sid] = session
235
263
  audit.log_start(sid, chosen_shell, chosen_cwd)