Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 33dc02b

Browse files
Clean up leases on timeout and termination
When lease is not available we try for 300s to acquire it. Users can also interrupt this with Ctrl+C while waiting. Let's remove such lease requests since after termination the shell is gone and it leaves behind a mostly useless lease.
1 parent 748be71 commit 33dc02b

2 files changed

Lines changed: 74 additions & 26 deletions

File tree

  • packages

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import sys
22
from datetime import timedelta
33

4+
import anyio
45
import click
6+
from anyio import create_task_group, get_cancelled_exc_class
57
from jumpstarter_cli_common.config import opt_config
68
from jumpstarter_cli_common.exceptions import handle_exceptions_with_reauthentication
9+
from jumpstarter_cli_common.signal import signal_handler
710

811
from .common import opt_duration_partial, opt_selector
912
from .login import relogin_client
@@ -12,6 +15,55 @@
1215
from jumpstarter.config.exporter import ExporterConfigV1Alpha1
1316

1417

18+
def _run_shell_with_lease(lease, exporter_logs, config, command):
19+
"""Run shell with lease context managers."""
20+
def launch_remote_shell(path: str) -> int:
21+
return launch_shell(
22+
path, "remote", config.drivers.allow, config.drivers.unsafe,
23+
config.shell.use_profiles, command=command
24+
)
25+
26+
with lease.serve_unix() as path:
27+
with lease.monitor():
28+
if exporter_logs:
29+
with lease.connect() as client:
30+
with client.log_stream():
31+
return launch_remote_shell(path)
32+
else:
33+
return launch_remote_shell(path)
34+
35+
36+
async def _shell_with_signal_handling(config, selector, lease_name, duration, exporter_logs, command):
37+
"""Handle lease acquisition and shell execution with signal handling."""
38+
exit_code = 0
39+
cancelled_exc_class = get_cancelled_exc_class()
40+
41+
try:
42+
async with create_task_group() as tg:
43+
tg.start_soon(signal_handler, tg.cancel_scope)
44+
try:
45+
try:
46+
async with anyio.from_thread.BlockingPortal() as portal:
47+
async with config.lease_async(selector, lease_name, duration, portal) as lease:
48+
exit_code = await anyio.to_thread.run_sync(
49+
_run_shell_with_lease, lease, exporter_logs, config, command
50+
)
51+
except BaseExceptionGroup as eg:
52+
for exc in eg.exceptions:
53+
if isinstance(exc, TimeoutError):
54+
raise exc from None
55+
raise
56+
except cancelled_exc_class:
57+
exit_code = 2
58+
finally:
59+
if not tg.cancel_scope.cancel_called:
60+
tg.cancel_scope.cancel()
61+
except* TimeoutError:
62+
exit_code = 1
63+
64+
return exit_code
65+
66+
1567
@click.command("shell")
1668
@opt_config()
1769
@click.argument("command", nargs=-1)
@@ -38,27 +90,9 @@ def shell(config, command: tuple[str, ...], lease_name, selector, duration, expo
3890

3991
match config:
4092
case ClientConfigV1Alpha1():
41-
exit_code = 0
42-
def _launch_remote_shell(path: str) -> int:
43-
return launch_shell(
44-
path,
45-
"remote",
46-
config.drivers.allow,
47-
config.drivers.unsafe,
48-
config.shell.use_profiles,
49-
command=command,
50-
)
51-
52-
with config.lease(selector=selector, lease_name=lease_name, duration=duration) as lease:
53-
with lease.serve_unix() as path:
54-
with lease.monitor():
55-
if exporter_logs:
56-
with lease.connect() as client:
57-
with client.log_stream():
58-
exit_code = _launch_remote_shell(path)
59-
else:
60-
exit_code = _launch_remote_shell(path)
61-
# we exit here to make sure that all the with clauses unwind
93+
exit_code = anyio.run(
94+
_shell_with_signal_handling, config, selector, lease_name, duration, exporter_logs, command
95+
)
6296
sys.exit(exit_code)
6397

6498
case ExporterConfigV1Alpha1():

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
from datetime import datetime, timedelta
1010
from typing import Any, Self
1111

12-
from anyio import AsyncContextManagerMixin, ContextManagerMixin, create_task_group, fail_after, sleep
12+
from anyio import (
13+
AsyncContextManagerMixin,
14+
CancelScope,
15+
ContextManagerMixin,
16+
create_task_group,
17+
fail_after,
18+
sleep,
19+
)
1320
from anyio.from_thread import BlockingPortal
1421
from grpc.aio import Channel
1522
from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc
@@ -99,6 +106,7 @@ async def request_async(self):
99106
await self._create()
100107
else:
101108
await self._create()
109+
102110
return await self._acquire()
103111

104112
async def _acquire(self):
@@ -138,15 +146,21 @@ async def _acquire(self):
138146

139147
@asynccontextmanager
140148
async def __asynccontextmanager__(self) -> AsyncGenerator[Self]:
141-
value = await self.request_async()
142149
try:
150+
value = await self.request_async()
143151
yield value
144152
finally:
145153
if self.release:
146154
logger.info("Releasing Lease %s", self.name)
147-
await self.svc.DeleteLease(
148-
name=self.name,
149-
)
155+
# Shield cleanup from cancellation to ensure it completes
156+
with CancelScope(shield=True):
157+
try:
158+
with fail_after(30):
159+
await self.svc.DeleteLease(
160+
name=self.name,
161+
)
162+
except TimeoutError:
163+
logger.warning("Timeout while deleting lease %s during cleanup", self.name)
150164

151165
@contextmanager
152166
def __contextmanager__(self) -> Generator[Self]:

0 commit comments

Comments
 (0)