Skip to content

Commit 774075d

Browse files
committed
feat: add serial console observe mode with fan-out streaming
Allow multiple clients sharing a lease to access the serial console simultaneously. One client holds the exclusive write token, others observe the output read-only with scrollback replay. - Add StreamFanOut state machine and FanOutStreamMixin for drivers with exclusive physical streams (jumpstarter/streams/fanout.py) - CLI: `j serial start-console --observe` for read-only console - CLI: `j serial pipe --observe` for read-only pipe - CLI: `j serial release-console` to force-release write token - CLI: `j serial console-status` to show session info - Byte-bounded ClientBuffer with drop-oldest overflow policy - 64KB scrollback ring replayed atomically on observer attach Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com> Assisted-by: claude-opus-4.6
1 parent 1457e65 commit 774075d

15 files changed

Lines changed: 1697 additions & 143 deletions

File tree

python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ def _append_details(base_message: str, details: str) -> str:
2020
return f"{base_message} Details: {details}" if details else base_message
2121

2222

23+
def _extract_console_in_use_message(text: str) -> str | None:
24+
"""Pull the user-facing exclusive-console message out of a wrapped error string."""
25+
marker = "Console in use"
26+
idx = text.find(marker)
27+
if idx == -1:
28+
return None
29+
return text[idx:]
30+
31+
32+
def _exception_chain(exc: BaseException):
33+
"""Yield *exc* and related exceptions (groups, __cause__, then __context__)."""
34+
seen: set[int] = set()
35+
36+
def walk(current: BaseException):
37+
if id(current) in seen:
38+
return
39+
seen.add(id(current))
40+
yield current
41+
if isinstance(current, BaseExceptionGroup):
42+
for child in current.exceptions:
43+
yield from walk(child)
44+
if current.__cause__ is not None:
45+
yield from walk(current.__cause__)
46+
if current.__context__ is not None and not current.__suppress_context__:
47+
yield from walk(current.__context__)
48+
49+
yield from walk(exc)
50+
51+
2352
def _map_runtime_exception(exc: BaseException, message: str, message_lower: str) -> click.ClickException | None:
2453
if isinstance(exc, TimeoutError):
2554
timeout_hint = (
@@ -96,6 +125,9 @@ def _map_grpc_exception(exc: BaseException) -> click.ClickException | None:
96125
code, details = _extract_grpc_code_and_details(exc)
97126
details_lower = details.lower()
98127

128+
if console_msg := _extract_console_in_use_message(details):
129+
return ClickExceptionRed(console_msg)
130+
99131
if code == "DEADLINE_EXCEEDED":
100132
return ClickExceptionRed(
101133
_append_details(
@@ -155,14 +187,15 @@ def _map_common_exception(exc: BaseException) -> click.ClickException | None:
155187

156188

157189
def _map_cli_exception(exc: BaseException) -> click.ClickException | None:
158-
if common_exc := _map_common_exception(exc):
159-
return common_exc
160-
if isinstance(exc, JumpstarterException):
161-
return ClickExceptionRed(str(exc))
162-
if isinstance(exc, KeyboardInterrupt):
163-
return ClickExceptionRed("Cancelled by user.")
164-
if isinstance(exc, click.ClickException):
165-
return exc
190+
for candidate in _exception_chain(exc):
191+
if common_exc := _map_common_exception(candidate):
192+
return common_exc
193+
if isinstance(candidate, JumpstarterException):
194+
return ClickExceptionRed(str(candidate))
195+
if isinstance(candidate, KeyboardInterrupt):
196+
return ClickExceptionRed("Cancelled by user.")
197+
if isinstance(candidate, click.ClickException):
198+
return candidate
166199
return None
167200

168201

python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions_test.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,64 @@ def fn():
262262

263263
# Original call + exactly one retry = 2 total.
264264
assert call_count == 2
265+
266+
267+
class _MockGrpcError(Exception):
268+
def __init__(self, code_name: str, details: str):
269+
super().__init__(details)
270+
self._code_name = code_name
271+
self._details = details
272+
273+
def code(self):
274+
return type("Code", (), {"name": self._code_name})()
275+
276+
def details(self):
277+
return self._details
278+
279+
280+
_WRAPPED_CONSOLE_IN_USE = (
281+
"Unexpected <class 'jumpstarter.streams.fanout.ExclusiveSessionActive'>: "
282+
"Console in use. Use --observe or release-console."
283+
)
284+
285+
286+
def test_handle_exceptions_maps_exclusive_session_active() -> None:
287+
from jumpstarter.streams.fanout import ExclusiveSessionActive
288+
289+
@handle_exceptions
290+
def fn():
291+
raise ExclusiveSessionActive()
292+
293+
with pytest.raises(click.ClickException, match="Console in use") as exc_info:
294+
fn()
295+
assert "Unexpected" not in str(exc_info.value)
296+
297+
298+
def test_handle_exceptions_maps_wrapped_console_in_use_grpc_error() -> None:
299+
from anyio import BrokenResourceError
300+
301+
@handle_exceptions
302+
def fn():
303+
raise BrokenResourceError from _MockGrpcError("UNKNOWN", _WRAPPED_CONSOLE_IN_USE)
304+
305+
with pytest.raises(click.ClickException, match="Console in use") as exc_info:
306+
fn()
307+
assert "Unexpected" not in str(exc_info.value)
308+
309+
310+
@pytest.mark.anyio
311+
async def test_async_handle_exceptions_maps_console_in_use_in_exception_group() -> None:
312+
from anyio import BrokenResourceError
313+
314+
@async_handle_exceptions
315+
async def fn():
316+
try:
317+
raise _MockGrpcError("UNKNOWN", _WRAPPED_CONSOLE_IN_USE)
318+
except _MockGrpcError as grpc_exc:
319+
wrapped = BrokenResourceError()
320+
wrapped.__cause__ = grpc_exc
321+
raise ExceptionGroup("unhandled errors in a TaskGroup", [wrapped]) from None
322+
323+
with pytest.raises(click.ClickException, match="Console in use") as exc_info:
324+
await fn()
325+
assert "Unexpected" not in str(exc_info.value)

python/packages/jumpstarter-cli/jumpstarter_cli/share.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,7 @@ def share_list(config, lease: str):
6969
\b
7070
$ jmp share list my-lease
7171
"""
72-
leases = config.list_leases(only_active=True)
73-
target = None
74-
for l in leases.leases:
75-
if l.name == lease:
76-
target = l
77-
break
78-
79-
if target is None:
80-
raise click.ClickException(f"Lease {lease!r} not found")
72+
target = config.get_lease(lease)
8173

8274
if not target.shared_with:
8375
click.echo(f"Lease {lease} is not shared with anyone.")

python/packages/jumpstarter-cli/jumpstarter_cli/share_test.py

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import inspect
22
from unittest.mock import Mock, patch
33

4-
import click
5-
import pytest
6-
74
from jumpstarter_cli.share import share_add, share_list, share_remove
85

96

@@ -43,45 +40,26 @@ def test_share_remove_calls_update_lease():
4340

4441
def test_share_list_shows_shared_clients(capsys):
4542
lease_entry = Mock()
46-
lease_entry.name = "my-lease"
4743
lease_entry.shared_with = ["alice", "bob"]
4844

49-
leases_result = Mock()
50-
leases_result.leases = [lease_entry]
51-
5245
config = Mock()
53-
config.list_leases.return_value = leases_result
46+
config.get_lease.return_value = lease_entry
5447

5548
inspect.unwrap(share_list.callback)(config=config, lease="my-lease")
5649

57-
config.list_leases.assert_called_once_with(only_active=True)
50+
config.get_lease.assert_called_once_with("my-lease")
5851
captured = capsys.readouterr()
5952
assert "my-lease" in captured.out
6053
assert "alice" in captured.out
6154
assert "bob" in captured.out
6255

6356

64-
def test_share_list_not_found():
65-
leases_result = Mock()
66-
leases_result.leases = []
67-
68-
config = Mock()
69-
config.list_leases.return_value = leases_result
70-
71-
with pytest.raises(click.ClickException, match="not found"):
72-
inspect.unwrap(share_list.callback)(config=config, lease="no-such-lease")
73-
74-
7557
def test_share_list_not_shared(capsys):
7658
lease_entry = Mock()
77-
lease_entry.name = "my-lease"
7859
lease_entry.shared_with = []
7960

80-
leases_result = Mock()
81-
leases_result.leases = [lease_entry]
82-
8361
config = Mock()
84-
config.list_leases.return_value = leases_result
62+
config.get_lease.return_value = lease_entry
8563

8664
inspect.unwrap(share_list.callback)(config=config, lease="my-lease")
8765

python/packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -520,12 +520,21 @@ async def _shell_with_signal_handling( # noqa: C901
520520
lease, exporter_logs, config, command, tg.cancel_scope
521521
)
522522
except BaseExceptionGroup as eg:
523+
# SIGINT cancels in-flight Dial, which is mapped to
524+
# ExporterUnreachableError. If we extract only that
525+
# error we swallow CancelledError and reacquire.
526+
if find_exception_in_group(eg, cancelled_exc_class):
527+
exit_code = 2
528+
break
523529
unreachable = find_exception_in_group(eg, ExporterUnreachableError)
524530
if unreachable is None:
525531
raise
526532
except ExporterUnreachableError as exc:
527533
unreachable = exc
528534
if unreachable is not None:
535+
if tg.cancel_scope.cancel_called:
536+
exit_code = 2
537+
break
529538
if lease.lease_ended:
530539
break # lease expired naturally — exit cleanly
531540
if lease.lease_transferred:
@@ -550,31 +559,39 @@ async def _shell_with_signal_handling( # noqa: C901
550559
_warn_about_expired_token(lease.name, selector)
551560
break
552561
except BaseExceptionGroup as eg:
553-
for exc in eg.exceptions:
554-
if isinstance(exc, TimeoutError):
555-
raise exc from None
556-
unreachable_exc = find_exception_in_group(eg, ExporterUnreachableError)
557-
if unreachable_exc:
558-
raise unreachable_exc from None
559-
offline_exc = find_exception_in_group(eg, ExporterOfflineError)
560-
if offline_exc:
561-
raise offline_exc from None
562-
lease_exc = find_exception_in_group(eg, LeaseError)
563-
if lease_exc:
564-
raise lease_exc from None
565-
if lease_used is not None:
566-
if lease_used.lease_ended:
567-
# Lease expired naturally (e.g. during beforeLease hook)
568-
# - exit gracefully instead of showing a scary error
569-
pass
570-
elif lease_used.lease_transferred:
571-
raise ExporterOfflineError(
572-
"Lease has been transferred to another client. Session is no longer valid."
573-
) from None
574-
else:
575-
raise ExporterOfflineError("Connection to exporter lost") from None
562+
if find_exception_in_group(eg, cancelled_exc_class):
563+
token = getattr(config, "token", None)
564+
if lease_used and token:
565+
remaining = get_token_remaining_seconds(token)
566+
if remaining is not None and remaining <= 0:
567+
_warn_about_expired_token(lease_used.name, selector)
568+
exit_code = 2
576569
else:
577-
raise
570+
for exc in eg.exceptions:
571+
if isinstance(exc, TimeoutError):
572+
raise exc from None
573+
unreachable_exc = find_exception_in_group(eg, ExporterUnreachableError)
574+
if unreachable_exc:
575+
raise unreachable_exc from None
576+
offline_exc = find_exception_in_group(eg, ExporterOfflineError)
577+
if offline_exc:
578+
raise offline_exc from None
579+
lease_exc = find_exception_in_group(eg, LeaseError)
580+
if lease_exc:
581+
raise lease_exc from None
582+
if lease_used is not None:
583+
if lease_used.lease_ended:
584+
# Lease expired naturally (e.g. during beforeLease hook)
585+
# - exit gracefully instead of showing a scary error
586+
pass
587+
elif lease_used.lease_transferred:
588+
raise ExporterOfflineError(
589+
"Lease has been transferred to another client. Session is no longer valid."
590+
) from None
591+
else:
592+
raise ExporterOfflineError("Connection to exporter lost") from None
593+
else:
594+
raise
578595
except cancelled_exc_class:
579596
# Check if cancellation was due to token expiry
580597
token = getattr(config, "token", None)

python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,3 +1332,76 @@ async def fake_run(*_):
13321332

13331333
assert exit_code == 0
13341334
assert state["call_count"] == 3
1335+
1336+
1337+
class TestRetryLoopUserInterrupt:
1338+
"""SIGINT must exit, not reacquire, even if Dial surfaces as Unreachable."""
1339+
1340+
def _config_with_lease(self, lease):
1341+
config = _DummyConfig()
1342+
1343+
@asynccontextmanager
1344+
async def lease_async(
1345+
selector, exporter_name, lease_name, duration, portal,
1346+
acquisition_timeout, retry_timeout=None, dial_timeout=None,
1347+
):
1348+
yield lease
1349+
1350+
config.lease_async = lease_async
1351+
return config
1352+
1353+
def _lease(self):
1354+
lease = Mock()
1355+
lease.release = True
1356+
lease.name = "test-lease"
1357+
lease.exporter_name = "test-exporter"
1358+
lease.retry_timeout = 10.0
1359+
lease.lease_ended = False
1360+
lease.lease_transferred = False
1361+
return lease
1362+
1363+
async def test_does_not_retry_when_cancel_scope_already_cancelled(self):
1364+
lease = self._lease()
1365+
config = self._config_with_lease(lease)
1366+
state = {"call_count": 0}
1367+
1368+
async def fake_run(*args):
1369+
state["call_count"] += 1
1370+
cancel_scope = args[4]
1371+
cancel_scope.cancel()
1372+
raise ExporterUnreachableError("dial cancelled")
1373+
1374+
with (
1375+
patch("jumpstarter_cli.shell._monitor_token_expiry", new_callable=AsyncMock),
1376+
patch("jumpstarter_cli.shell._run_shell_with_lease_async", side_effect=fake_run),
1377+
):
1378+
exit_code = await _shell_with_signal_handling(
1379+
config, None, None, None, timedelta(minutes=1), False, (), None
1380+
)
1381+
1382+
assert exit_code == 2
1383+
assert state["call_count"] == 1
1384+
1385+
async def test_does_not_retry_when_unreachable_is_mixed_with_cancellation(self):
1386+
lease = self._lease()
1387+
config = self._config_with_lease(lease)
1388+
state = {"call_count": 0}
1389+
cancelled_exc_class = anyio.get_cancelled_exc_class()
1390+
1391+
async def fake_run(*_):
1392+
state["call_count"] += 1
1393+
raise BaseExceptionGroup(
1394+
"task group",
1395+
[ExporterUnreachableError("dial cancelled"), cancelled_exc_class()],
1396+
)
1397+
1398+
with (
1399+
patch("jumpstarter_cli.shell._monitor_token_expiry", new_callable=AsyncMock),
1400+
patch("jumpstarter_cli.shell._run_shell_with_lease_async", side_effect=fake_run),
1401+
):
1402+
exit_code = await _shell_with_signal_handling(
1403+
config, None, None, None, timedelta(minutes=1), False, (), None
1404+
)
1405+
1406+
assert exit_code == 2
1407+
assert state["call_count"] == 1

0 commit comments

Comments
 (0)