v3.28.0 audit: the mid-equip race

The review of the connection-check fix found the second-order race it
opened: _apply_equipped_card sets _CARD_CONNECTED = True and bumps
card_session *before* it clears and reads the ICCID (and long before the
network state / TERMINAL PROFILE / drain finish).  A worker poll landing
in that window saw connected + a fresh session + iccid = None and
consumed the card with a bogus 'no ICCID' skip - the card was never
installed.  The `equipping` flag could not guard it either: the manual
/api/command equip path never sets it.

The fix marks the finished init instead of guessing:
- _apply_equipped_card sets `server.card_ready_session =
  server.card_session` as its last statement;
- the startup init (which inlines the post-equip work and never runs
  _apply_equipped_card) sets the same marker;
- the worker requires the marker to match the card session, so it acts
  only on a fully initialized card - a genuinely unreadable ICCID still
  acts and reports its skip, as designed.

Tests: a worker test that keeps the marker stale for a couple of polls
(no attempt - verified to fail without the guard), the marker pins for
both equip paths, and the fake server carries the marker (a real
attribute) instead of inventing state.  The unused `server` parameter of
_ram_batch_start is gone, and the two comments that called this work a
"review" now say audit.

915 frontend + 924 Python; sw cache simple-v438 (unchanged).
This commit is contained in:
2026-10-14 15:34:12 +03:00
parent 80a67fb493
commit 262041795c
3 changed files with 73 additions and 13 deletions
+16 -5
View File
@@ -2701,8 +2701,13 @@ def _ram_batch_worker(state):
session = getattr(server, 'card_session', 0) if server is not None else None
# The connection state is the module global the whole server uses
# (_apply_equipped_card / _handle_card_disconnect); the server
# object itself has no `connected` attribute (v3.28.0 review).
connected = bool(server is not None and _CARD_CONNECTED)
# object itself has no `connected` attribute. The equip must also
# have finished (`card_ready_session`, set at the end of
# _apply_equipped_card and by the startup init) - acting mid-equip
# would consume the card session with a bogus 'no ICCID' skip
# (v3.28.0 audit).
connected = bool(server is not None and _CARD_CONNECTED
and getattr(server, 'card_ready_session', None) == session)
with _RAM_BATCH_LOCK:
if state['stop']:
break
@@ -2732,9 +2737,9 @@ def _ram_batch_worker(state):
sys.stderr.write('RAM-BATCH: %s after %d attempts\n' % (phase, total))
def _ram_batch_start(server, body):
def _ram_batch_start(body):
"""Validate the CAP/parameters (the plan is card independent, so it is built
once) and spawn the worker. Raises ValueError (400) for a bad request and
once) and spawn the worker (the worker reads `_server_ref` itself). Raises ValueError (400) for a bad request and
RuntimeError (409) when another batch or a test run is active."""
global _RAM_BATCH
with _RAM_BATCH_LOCK:
@@ -5106,6 +5111,12 @@ def _apply_equipped_card(server):
server.sim_menu = sm
server.event_list = el
_tlog('equip: terminal profile done')
# The card session is now fully initialized (the ICCID read, the network
# state, the TERMINAL PROFILE and its drain are done). The batch worker
# waits for this marker before acting: _CARD_CONNECTED flips True before
# the ICCID is read, so a poll landing mid-equip would consume the session
# with a bogus 'no ICCID' skip (v3.28.0 audit).
server.card_ready_session = server.card_session
def _card_reset_reinit(server, label='RESET'):
@@ -10038,7 +10049,7 @@ class PysimHandler(BaseHTTPRequestHandler):
body = self._read_body()
self._log_req(body)
try:
_ram_batch_start(self.server, body)
_ram_batch_start(body)
except ValueError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)