Skip to content

Commit e5c437d

Browse files
committed
test(update): make claim-race regression tests genuinely concurrent
The concurrent-apply and supersession-cleanup regression tests added in 70a4cc6 only called apply_windows_staged_update_now() sequentially or without a claim-time failure hook, so a future non-atomic check-then-rename regression could pass them undetected. - test_apply_now_concurrent_callers_only_one_wins now races two real threads through the claim via a threading.Barrier immediately before the call, instead of calling the function twice in sequence. - New test_apply_now_stale_claim_failure_preserves_concurrently_staged_newer_manifest publishes a newer canonical manifest from inside the digest-check hook (the failure mode that runs after a successful claim), and asserts it survives the claimed manifest's discard.
1 parent 70a4cc6 commit e5c437d

1 file changed

Lines changed: 70 additions & 8 deletions

File tree

tests/ui/test_update_staging.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import threading
67
import time
78
from pathlib import Path
89

@@ -328,19 +329,43 @@ def forbidden_smoke():
328329

329330
def test_apply_now_concurrent_callers_only_one_wins(staging: Path, monkeypatch):
330331
"""Regression: two processes racing to apply the same stage must not both
331-
pass validation and spawn duplicate installers."""
332+
pass validation and spawn duplicate installers.
333+
334+
Uses real threads synchronized on a barrier immediately before the call, so
335+
both callers reach the atomic claim (``os.rename``) at as close to the same
336+
instant as possible — a genuine OS-level race, not merely two sequential
337+
calls, which would pass even against a naive check-then-rename
338+
implementation that only breaks under real concurrency.
339+
"""
332340
staged = _stage(staging)
333341
spawned: list[Path] = []
342+
spawned_lock = threading.Lock()
334343
monkeypatch.setattr("pythinker_code.constant.VERSION", "0.1.0")
335-
monkeypatch.setattr(
336-
upd, "_spawn_detached_windows_installer", lambda p: spawned.append(p) or True
337-
)
338344

339-
first = upd.apply_windows_staged_update_now()
340-
second = upd.apply_windows_staged_update_now()
345+
def fake_spawn(p: Path) -> bool:
346+
with spawned_lock:
347+
spawned.append(p)
348+
return True
349+
350+
monkeypatch.setattr(upd, "_spawn_detached_windows_installer", fake_spawn)
341351

342-
assert first is True
343-
assert second is False
352+
barrier = threading.Barrier(2)
353+
results: list[bool] = []
354+
results_lock = threading.Lock()
355+
356+
def racer() -> None:
357+
barrier.wait()
358+
result = upd.apply_windows_staged_update_now()
359+
with results_lock:
360+
results.append(result)
361+
362+
threads = [threading.Thread(target=racer) for _ in range(2)]
363+
for t in threads:
364+
t.start()
365+
for t in threads:
366+
t.join()
367+
368+
assert sorted(results) == [False, True]
344369
assert spawned == [staged.installer_path]
345370

346371

@@ -353,3 +378,40 @@ def test_apply_now_failed_claim_cleans_up_only_claimed_copy(staging: Path, monke
353378
assert upd.apply_windows_staged_update_now() is False
354379
assert upd.read_windows_staged_update() is None
355380
del staged
381+
382+
383+
def test_apply_now_stale_claim_failure_preserves_concurrently_staged_newer_manifest(
384+
staging: Path, monkeypatch
385+
):
386+
"""A validation failure on the claimed (now-stale) manifest must not delete
387+
a newer manifest another process publishes to the canonical path during the
388+
failure window — the claim already removed the old manifest from that path,
389+
so the two can never collide, but this proves it end-to-end via the digest
390+
check, the one failure mode that runs after a successful claim+version pass."""
391+
staged = _stage(staging, version="9.9.9")
392+
monkeypatch.setattr("pythinker_code.constant.VERSION", "0.1.0")
393+
394+
def fake_verify_and_supersede(path: Path, expected: str) -> bool:
395+
newer_dir = staging / "pythinker-update-newer"
396+
newer_dir.mkdir()
397+
installer = newer_dir / "PythinkerSetup-10.0.0.exe"
398+
installer.write_bytes(b"newer")
399+
import hashlib
400+
401+
upd._write_windows_staged_manifest(
402+
upd.StagedWindowsUpdate(
403+
version="10.0.0",
404+
installer_path=installer,
405+
sha256=hashlib.sha256(b"newer").hexdigest(),
406+
created_at=time.time(),
407+
)
408+
)
409+
return False # the claimed (stale) manifest still fails verification
410+
411+
monkeypatch.setattr(upd, "_verify_sha256", fake_verify_and_supersede)
412+
413+
assert upd.apply_windows_staged_update_now() is False
414+
survivor = upd.read_windows_staged_update()
415+
assert survivor is not None
416+
assert survivor.version == "10.0.0"
417+
del staged

0 commit comments

Comments
 (0)