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

Commit cc44664

Browse files
committed
Fix lease status race condition causing E2E tests to fail
1 parent cab55c9 commit cc44664

2 files changed

Lines changed: 53 additions & 22 deletions

File tree

packages/jumpstarter/jumpstarter/exporter/exporter.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ class Exporter(AsyncContextManagerMixin, Metadata):
132132
AFTER_LEASE_HOOK, BEFORE_LEASE_HOOK_FAILED, AFTER_LEASE_HOOK_FAILED.
133133
"""
134134

135-
_lease_scope: LeaseContext | None = field(init=False, default=None)
135+
_lease_context: LeaseContext | None = field(init=False, default=None)
136136
"""Encapsulates all resources associated with the current lease.
137137
138138
Contains the session, socket path, and synchronization event needed
@@ -266,9 +266,10 @@ async def _report_status(self, status: ExporterStatus, message: str = ""):
266266
"""Report the exporter status with the controller and session."""
267267
self._exporter_status = status
268268

269-
# Update session status if available
270-
if self._lease_scope and self._lease_scope.session:
271-
self._lease_scope.session.update_status(status, message)
269+
# Update status in lease context (handles session update internally)
270+
# This ensures status is stored even before session is created
271+
if self._lease_context:
272+
self._lease_context.update_status(status, message)
272273

273274
try:
274275
controller = await self._get_controller_stub()
@@ -409,6 +410,10 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC
409410
# Populate the lease scope with session and socket path
410411
lease_scope.session = session
411412
lease_scope.socket_path = path
413+
# Sync current status to the newly created session
414+
# This ensures the session has the correct status even if _report_status
415+
# was called before the session was created (race condition fix)
416+
session.update_status(lease_scope.current_status, lease_scope.status_message)
412417

413418
# Wait for before-lease hook to complete before processing client connections
414419
logger.info("Waiting for before-lease hook to complete before accepting connections")
@@ -449,23 +454,23 @@ async def serve(self): # noqa: C901
449454
async for status in status_rx:
450455
# Check if lease name changed (and there was a previous active lease)
451456
lease_changed = (
452-
self._lease_scope
453-
and self._lease_scope.is_active()
454-
and self._lease_scope.lease_name != status.lease_name
457+
self._lease_context
458+
and self._lease_context.is_active()
459+
and self._lease_context.lease_name != status.lease_name
455460
)
456461
if lease_changed:
457462
# After-lease hook for the previous lease (lease name changed)
458-
if self.hook_executor and self._lease_scope.has_client():
463+
if self.hook_executor and self._lease_context.has_client():
459464
with CancelScope(shield=True):
460465
await self.hook_executor.run_after_lease_hook(
461-
self._lease_scope,
466+
self._lease_context,
462467
self._report_status,
463468
self.stop,
464469
)
465470

466471
logger.info("Lease status changed, killing existing connections")
467472
# Clear lease scope for next lease
468-
self._lease_scope = None
473+
self._lease_context = None
469474
self.stop()
470475
break
471476

@@ -482,43 +487,48 @@ async def serve(self): # noqa: C901
482487
lease_name=status.lease_name,
483488
before_lease_hook=Event(),
484489
)
485-
self._lease_scope = lease_scope
490+
self._lease_context = lease_scope
486491
tg.start_soon(self.handle_lease, status.lease_name, tg, lease_scope)
487492

488493
if current_leased:
489494
logger.info("Currently leased by %s under %s", status.client_name, status.lease_name)
490-
if self._lease_scope:
491-
self._lease_scope.update_client(status.client_name)
495+
if self._lease_context:
496+
self._lease_context.update_client(status.client_name)
492497

493498
# Before-lease hook when transitioning from unleased to leased
494499
if not previous_leased:
495-
if self.hook_executor and self._lease_scope:
500+
if self.hook_executor and self._lease_context:
496501
tg.start_soon(
497502
self.hook_executor.run_before_lease_hook,
498-
self._lease_scope,
503+
self._lease_context,
499504
self._report_status,
500505
self.stop, # Pass shutdown callback
501506
)
502507
else:
503508
# No hook configured, set event immediately
504509
await self._report_status(ExporterStatus.LEASE_READY, "Ready for commands")
505-
if self._lease_scope:
506-
self._lease_scope.before_lease_hook.set()
510+
if self._lease_context:
511+
self._lease_context.before_lease_hook.set()
507512
else:
508513
logger.info("Currently not leased")
509514

510515
# After-lease hook when transitioning from leased to unleased
511-
if previous_leased and self.hook_executor and self._lease_scope and self._lease_scope.has_client():
516+
if (
517+
previous_leased
518+
and self.hook_executor
519+
and self._lease_context
520+
and self._lease_context.has_client()
521+
):
512522
# Shield the after-lease hook from cancellation
513523
with CancelScope(shield=True):
514524
await self.hook_executor.run_after_lease_hook(
515-
self._lease_scope,
525+
self._lease_context,
516526
self._report_status,
517527
self.stop,
518528
)
519529

520530
# Clear lease scope for next lease
521-
self._lease_scope = None
531+
self._lease_context = None
522532

523533
if self._stop_requested:
524534
self.stop(should_unregister=True)

packages/jumpstarter/jumpstarter/exporter/lease_context.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
"""
66

77
from dataclasses import dataclass, field
8+
from typing import TYPE_CHECKING
89

910
from anyio import Event
1011

11-
from jumpstarter.exporter.session import Session
12+
from jumpstarter.common import ExporterStatus
13+
14+
if TYPE_CHECKING:
15+
from jumpstarter.exporter.session import Session
1216

1317

1418
@dataclass
@@ -25,13 +29,17 @@ class LeaseContext:
2529
socket_path: Unix socket path where the session is serving (set in handle_lease)
2630
before_lease_hook: Event that signals when before-lease hook completes
2731
client_name: Name of the client currently holding the lease (empty if unleased)
32+
current_status: Current exporter status (stored here for access before session is created)
33+
status_message: Message describing the current status
2834
"""
2935

3036
lease_name: str
3137
before_lease_hook: Event
32-
session: Session | None = None
38+
session: "Session | None" = None
3339
socket_path: str = ""
3440
client_name: str = field(default="")
41+
current_status: ExporterStatus = field(default=ExporterStatus.AVAILABLE)
42+
status_message: str = field(default="")
3543

3644
def __post_init__(self):
3745
"""Validate that required resources are present."""
@@ -61,3 +69,16 @@ def update_client(self, client_name: str):
6169
def clear_client(self):
6270
"""Clear the client name when the lease is no longer held."""
6371
self.client_name = ""
72+
73+
def update_status(self, status: ExporterStatus, message: str = ""):
74+
"""Update the current status in the lease context.
75+
76+
This stores the status in the LeaseContext so it's available even before
77+
the session is created, fixing the race condition where GetStatus is called
78+
before the session can be updated.
79+
"""
80+
self.current_status = status
81+
self.status_message = message
82+
# Also update session if it exists
83+
if self.session:
84+
self.session.update_status(status, message)

0 commit comments

Comments
 (0)