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

Commit 5300757

Browse files
authored
Merge pull request #757 from evakhoni/close_ended_lease
close the session on lease expire
2 parents 615124b + 17de08c commit 5300757

3 files changed

Lines changed: 57 additions & 17 deletions

File tree

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _run_shell_with_lease(lease, exporter_logs, config, command):
2020
def launch_remote_shell(path: str) -> int:
2121
return launch_shell(
2222
path, lease.exporter_name, config.drivers.allow, config.drivers.unsafe,
23-
config.shell.use_profiles, command=command
23+
config.shell.use_profiles, command=command, lease=lease
2424
)
2525

2626
with lease.serve_unix() as path:

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import os
33
import sys
4-
from collections.abc import AsyncGenerator, Generator
4+
from collections.abc import AsyncGenerator, Callable, Generator
55
from contextlib import (
66
ExitStack,
77
asynccontextmanager,
@@ -54,6 +54,9 @@ class Lease(ContextManagerMixin, AsyncContextManagerMixin):
5454
grpc_options: dict[str, Any] = field(default_factory=dict)
5555
acquisition_timeout: int = field(default=7200) # Timeout in seconds for lease acquisition, polled in 5s intervals
5656
exporter_name: str = field(default="remote", init=False) # Populated during acquisition
57+
lease_ending_callback: Callable[[Self, timedelta], None] | None = field(
58+
default=None, init=False
59+
) # Called when lease is ending
5760

5861
def __post_init__(self):
5962
if hasattr(super(), "__post_init__"):
@@ -208,11 +211,14 @@ async def __asynccontextmanager__(self) -> AsyncGenerator[Self]:
208211
yield value
209212
finally:
210213
if self.release and self.name:
211-
logger.info("Releasing Lease %s", self.name)
212214
# Shield cleanup from cancellation to ensure it completes
213215
with CancelScope(shield=True):
214216
try:
215217
with fail_after(30):
218+
# skip the message if the lease is already expired
219+
lease = await self.get()
220+
if not lease.effective_end_time:
221+
logger.info("Releasing Lease %s", self.name)
216222
await self.svc.DeleteLease(
217223
name=self.name,
218224
)
@@ -280,6 +286,8 @@ async def _monitor():
280286
if remain < timedelta(0):
281287
# lease already expired, stopping monitor
282288
logger.info("Lease {} ended at {}".format(self.name, end_time))
289+
if self.lease_ending_callback is not None:
290+
self.lease_ending_callback(self, timedelta(0))
283291
break
284292
# Log once when entering the threshold window
285293
if threshold - timedelta(seconds=check_interval) <= remain < threshold:
@@ -288,6 +296,9 @@ async def _monitor():
288296
self.name, int((remain.total_seconds() + 30) // 60), end_time
289297
)
290298
)
299+
# Notify callback about approaching expiration
300+
if self.lease_ending_callback is not None:
301+
self.lease_ending_callback(self, remain)
291302
await sleep(min(remain.total_seconds(), check_interval))
292303
else:
293304
await sleep(1)

packages/jumpstarter/jumpstarter/common/utils.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import os
2+
import signal
23
import sys
34
from contextlib import ExitStack, asynccontextmanager, contextmanager
5+
from datetime import timedelta
6+
from functools import partial
47
from subprocess import Popen
58

69
from anyio.from_thread import BlockingPortal, start_blocking_portal
@@ -46,6 +49,34 @@ def serve(root_device: Driver):
4649
PROMPT_CWD = "\\W"
4750

4851

52+
def lease_ending_handler(process: Popen, lease, remaining_time) -> None:
53+
"""Lease ending handler to terminate a process when lease ends.
54+
55+
Args:
56+
process: The process to terminate
57+
lease: The lease instance
58+
remaining_time: Time remaining until lease expiration
59+
"""
60+
61+
if remaining_time <= timedelta(0):
62+
try:
63+
process.send_signal(signal.SIGHUP)
64+
except (ProcessLookupError, OSError):
65+
pass # Process already terminated
66+
67+
68+
def _run_process(
69+
cmd: list[str],
70+
env: dict[str, str],
71+
lease=None,
72+
) -> int:
73+
"""Helper to run a process with an option to set a lease ending callback."""
74+
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
75+
if lease is not None:
76+
lease.lease_ending_callback = partial(lease_ending_handler, process)
77+
return process.wait()
78+
79+
4980
def launch_shell(
5081
host: str,
5182
context: str,
@@ -54,6 +85,7 @@ def launch_shell(
5485
use_profiles: bool,
5586
*,
5687
command: tuple[str, ...] | None = None,
88+
lease=None,
5789
) -> int:
5890
"""Launch a shell with a custom prompt indicating the exporter type.
5991
@@ -62,6 +94,12 @@ def launch_shell(
6294
context: The context of the shell (e.g. "local" or exporter name)
6395
allow: List of allowed drivers
6496
unsafe: Whether to allow drivers outside of the allow list
97+
use_profiles: Whether to load shell profile files
98+
command: Optional command to run instead of launching an interactive shell
99+
lease: Optional Lease object to set up lease ending callback
100+
101+
Returns:
102+
The exit code of the shell or command process
65103
"""
66104

67105
shell = os.environ.get("SHELL", "bash")
@@ -73,19 +111,16 @@ def launch_shell(
73111
}
74112

75113
if command:
76-
process = Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
77-
return process.wait()
114+
return _run_process(list(command), common_env, lease)
78115

79116
if shell_name.endswith("bash"):
80117
env = common_env | {
81118
"PS1": f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}{ANSI_WHITE}{context} {ANSI_YELLOW}{ANSI_RESET} ",
82119
}
83-
84120
cmd = [shell]
85121
if not use_profiles:
86122
cmd.extend(["--norc", "--noprofile"])
87-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
88-
return process.wait()
123+
return _run_process(cmd, env, lease)
89124

90125
elif shell_name == "fish":
91126
fish_fn = (
@@ -102,26 +137,20 @@ def launch_shell(
102137
"end"
103138
)
104139
cmd = [shell, "--init-command", fish_fn]
105-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
106-
return process.wait()
140+
return _run_process(cmd, common_env, lease)
107141

108142
elif shell_name == "zsh":
109143
env = common_env | {
110144
"PS1": f"%F{{8}}%1~ %F{{yellow}}⚡%F{{white}}{context} %F{{yellow}}➤%f ",
111145
}
112-
113146
if "HISTFILE" not in env:
114147
env["HISTFILE"] = os.path.join(os.path.expanduser("~"), ".zsh_history")
115148

116149
cmd = [shell]
117150
if not use_profiles:
118151
cmd.append("--no-rcs")
119-
120152
cmd.extend(["-o", "inc_append_history", "-o", "share_history"])
121-
122-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
123-
return process.wait()
153+
return _run_process(cmd, env, lease)
124154

125155
else:
126-
process = Popen([shell], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
127-
return process.wait()
156+
return _run_process([shell], common_env, lease)

0 commit comments

Comments
 (0)