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

Commit 5fec1e0

Browse files
committed
use lease ending handler
1 parent fea8bea commit 5fec1e0

3 files changed

Lines changed: 52 additions & 33 deletions

File tree

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +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,
24-
process_callback=lambda proc: setattr(lease, 'shell_process', proc)
23+
config.shell.use_profiles, command=command, lease=lease
2524
)
2625

2726
with lease.serve_unix() as path:

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 9 additions & 5 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,
@@ -53,7 +53,9 @@ class Lease(ContextManagerMixin, AsyncContextManagerMixin):
5353
grpc_options: dict[str, Any] = field(default_factory=dict)
5454
acquisition_timeout: int = field(default=7200) # Timeout in seconds for lease acquisition, polled in 5s intervals
5555
exporter_name: str = field(default="remote", init=False) # Populated during acquisition
56-
shell_process: Any = field(default=None, init=False) # Shell process to terminate on expiry
56+
lease_ending_callback: Callable[[timedelta], None] | None = field(
57+
default=None, init=False
58+
) # Called when lease is ending
5759

5860
def __post_init__(self):
5961
if hasattr(super(), "__post_init__"):
@@ -263,9 +265,8 @@ async def _monitor():
263265
if remain < timedelta(0):
264266
# lease already expired, stopping monitor
265267
logger.info("Lease {} ended at {}".format(self.name, end_time))
266-
if self.shell_process is not None:
267-
import signal
268-
self.shell_process.send_signal(signal.SIGHUP)
268+
if self.lease_ending_callback is not None:
269+
self.lease_ending_callback(timedelta(0))
269270
break
270271
# Log once when entering the threshold window
271272
if threshold - timedelta(seconds=check_interval) <= remain < threshold:
@@ -274,6 +275,9 @@ async def _monitor():
274275
self.name, int((remain.total_seconds() + 30) // 60), end_time
275276
)
276277
)
278+
# Notify callback about approaching expiration
279+
if self.lease_ending_callback is not None:
280+
self.lease_ending_callback(remain)
277281
await sleep(min(remain.total_seconds(), check_interval))
278282
else:
279283
await sleep(1)

packages/jumpstarter/jumpstarter/common/utils.py

Lines changed: 42 additions & 26 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,33 @@ def serve(root_device: Driver):
4649
PROMPT_CWD = "\\W"
4750

4851

52+
def lease_ending_handler(process: Popen, remaining_time) -> None:
53+
"""Lease ending handler to terminate a process when lease ends.
54+
55+
Args:
56+
process: The process to terminate
57+
remaining_time: Time remaining until lease expiration
58+
"""
59+
60+
if remaining_time <= timedelta(0):
61+
try:
62+
process.send_signal(signal.SIGHUP)
63+
except (ProcessLookupError, OSError):
64+
pass # Process already terminated
65+
66+
67+
def _run_process(
68+
cmd: list[str],
69+
env: dict[str, str],
70+
lease=None,
71+
) -> int:
72+
"""Helper to run a process with an option to set a lease ending callback."""
73+
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
74+
if lease is not None:
75+
lease.lease_ending_callback = partial(lease_ending_handler, process)
76+
return process.wait()
77+
78+
4979
def launch_shell(
5080
host: str,
5181
context: str,
@@ -54,7 +84,7 @@ def launch_shell(
5484
use_profiles: bool,
5585
*,
5686
command: tuple[str, ...] | None = None,
57-
process_callback=None,
87+
lease=None,
5888
) -> int:
5989
"""Launch a shell with a custom prompt indicating the exporter type.
6090
@@ -63,7 +93,12 @@ def launch_shell(
6393
context: The context of the shell (e.g. "local" or exporter name)
6494
allow: List of allowed drivers
6595
unsafe: Whether to allow drivers outside of the allow list
66-
process_callback: Optional callback to receive the process object before waiting
96+
use_profiles: Whether to load shell profile files
97+
command: Optional command to run instead of launching an interactive shell
98+
lease: Optional Lease object to set up lease ending callback
99+
100+
Returns:
101+
The exit code of the shell or command process
67102
"""
68103

69104
shell = os.environ.get("SHELL", "bash")
@@ -75,23 +110,16 @@ def launch_shell(
75110
}
76111

77112
if command:
78-
process = Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
79-
if process_callback:
80-
process_callback(process)
81-
return process.wait()
113+
return _run_process(list(command), common_env, lease)
82114

83115
if shell_name.endswith("bash"):
84116
env = common_env | {
85117
"PS1": f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}{ANSI_WHITE}{context} {ANSI_YELLOW}{ANSI_RESET} ",
86118
}
87-
88119
cmd = [shell]
89120
if not use_profiles:
90121
cmd.extend(["--norc", "--noprofile"])
91-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
92-
if process_callback:
93-
process_callback(process)
94-
return process.wait()
122+
return _run_process(cmd, env, lease)
95123

96124
elif shell_name == "fish":
97125
fish_fn = (
@@ -108,32 +136,20 @@ def launch_shell(
108136
"end"
109137
)
110138
cmd = [shell, "--init-command", fish_fn]
111-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
112-
if process_callback:
113-
process_callback(process)
114-
return process.wait()
139+
return _run_process(cmd, common_env, lease)
115140

116141
elif shell_name == "zsh":
117142
env = common_env | {
118143
"PS1": f"%F{{8}}%1~ %F{{yellow}}⚡%F{{white}}{context} %F{{yellow}}➤%f ",
119144
}
120-
121145
if "HISTFILE" not in env:
122146
env["HISTFILE"] = os.path.join(os.path.expanduser("~"), ".zsh_history")
123147

124148
cmd = [shell]
125149
if not use_profiles:
126150
cmd.append("--no-rcs")
127-
128151
cmd.extend(["-o", "inc_append_history", "-o", "share_history"])
129-
130-
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
131-
if process_callback:
132-
process_callback(process)
133-
return process.wait()
152+
return _run_process(cmd, env, lease)
134153

135154
else:
136-
process = Popen([shell], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=common_env)
137-
if process_callback:
138-
process_callback(process)
139-
return process.wait()
155+
return _run_process([shell], common_env, lease)

0 commit comments

Comments
 (0)