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."""