From d3815deadbc690de36108df5d95be6d197b53753 Mon Sep 17 00:00:00 2001 From: Paul Wallrabe Date: Thu, 16 Apr 2026 20:45:45 +0200 Subject: [PATCH] fix: release lease on unused timeout when hooks are configured When a lease times out without any client connection and hooks are configured, _cleanup_after_lease skipped the afterLease hook (correct) but also skipped calling _request_lease_release (bug). This left the exporter permanently stuck in LeaseReady status because the controller was never notified that the lease should be freed. Add _request_lease_release() call in the else branch of _cleanup_after_lease so the controller always frees the lease, regardless of whether the afterLease hook ran. Fixes: #237 Generated-By: Forge/20260416_202053_681470_86fec9bd_i237 Co-Authored-By: Claude Opus 4.6 --- .../jumpstarter/exporter/exporter.py | 1 + .../jumpstarter/exporter/exporter_test.py | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter.py index 236651766..422856bef 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter.py @@ -638,6 +638,7 @@ async def _cleanup_after_lease(self, lease_scope: LeaseContext) -> None: await self._report_status(ExporterStatus.AVAILABLE, "Available for new lease") else: logger.debug("Exporter is shutting down, skipping AVAILABLE status report") + await self._request_lease_release() if not lease_scope.after_lease_hook_done.is_set(): lease_scope.after_lease_hook_done.set() else: diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py index a25c496e2..1de7c5da5 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py @@ -184,6 +184,62 @@ async def track_status(status, message=""): assert ExporterStatus.AVAILABLE in statuses assert lease_ctx.after_lease_hook_done.is_set() + async def test_unused_lease_with_hooks_calls_request_lease_release(self): + """When a lease ends with no client and hooks are configured, + _request_lease_release must still be called so the controller + frees the lease. This prevents the exporter from getting stuck + in LeaseReady status permanently.""" + from jumpstarter.config.exporter import HookConfigV1Alpha1, HookInstanceConfigV1Alpha1 + from jumpstarter.exporter.hooks import HookExecutor + + lease_ctx = make_lease_context(client_name="") + lease_ctx.before_lease_hook.set() + + hook_config = HookConfigV1Alpha1( + after_lease=HookInstanceConfigV1Alpha1(script="echo cleanup", timeout=10), + ) + hook_executor = HookExecutor(config=hook_config) + + exporter = make_exporter(lease_ctx, hook_executor) + + await exporter._cleanup_after_lease(lease_ctx) + + exporter._request_lease_release.assert_awaited_once() + + async def test_unused_lease_without_hooks_calls_request_lease_release(self): + """When a lease ends with no client and no hooks configured, + _request_lease_release must be called so the controller frees + the lease.""" + lease_ctx = make_lease_context(client_name="") + lease_ctx.before_lease_hook.set() + + exporter = make_exporter(lease_ctx) + + await exporter._cleanup_after_lease(lease_ctx) + + exporter._request_lease_release.assert_awaited_once() + + async def test_unused_lease_during_shutdown_still_releases(self): + """When a lease ends with no client during exporter shutdown, + _request_lease_release must still be called even though AVAILABLE + status is not reported.""" + lease_ctx = make_lease_context(client_name="") + lease_ctx.before_lease_hook.set() + + statuses = [] + + async def track_status(status, message=""): + statuses.append(status) + + exporter = make_exporter(lease_ctx) + exporter._stop_requested = True + exporter._report_status = AsyncMock(side_effect=track_status) + + await exporter._cleanup_after_lease(lease_ctx) + + exporter._request_lease_release.assert_awaited_once() + assert ExporterStatus.AVAILABLE not in statuses + async def test_new_lease_after_unused_timeout_recovery(self): """After recovering from unused lease timeout, a new lease can be accepted and processed."""