Skip to content

fix(tests): make the suite hermetic against real user state - #76

Open
Mokuroh54 wants to merge 3 commits into
mainfrom
fix/test-suite-hermeticity
Open

fix(tests): make the suite hermetic against real user state#76
Mokuroh54 wants to merge 3 commits into
mainfrom
fix/test-suite-hermeticity

Conversation

@Mokuroh54

Copy link
Copy Markdown

Why

The suite reads and writes the developer's real ~/.cache/huggingface/ in several places. On a clean CI runner that is invisible, which is exactly why it survived: every one of these is green in CI and only misbehaves on a machine that has actually been used.

1. The unprotected from-import path (the one with a hardware cost)

tmp_lerobot_home patched the path constants on makermodslab.utils.config. That reaches every consumer reading them through the module — but not a from .utils.config import FOLLOWER_CONFIG_PATH, which binds a copy of the string at import time. Rebinding the name on cfg afterwards leaves that copy pointing at the real ~/.cache.

Seven such copies exist (auto_calibrate ×3, arm_identity ×2, server ×2) and nothing in the suite redirected any of them.

The concrete hazard verified on this branch: the calibration-config DELETE endpoint (server.py) builds its os.remove() target from server.py's own copy of FOLLOWER_CONFIG_PATH. No test repoints it — tests/test_server.py repoints only LEADER_CONFIG_PATH, and says so in a comment — while the developer's real calibration/robots/so_follower/home.json sits at the other end. Recovering that file costs a physical recalibration.

2. Real pins / hidden lists leaking into listing assertions

tests/test_models.py fails 7 tests on an unmodified main on a populated machine: the model listing folds in the real saved_custom_models.json, so a listing asserted to be ["imported_policy"] also carries the developer's real pins (lerobot/smolvla_base, …). The dataset listing leaks the same way in both directions — real pins added, real hidden_datasets.json entries silently subtracting a row a test just seeded. tmp_lerobot_home never covered these four files, and listing tests that don't request it got no redirection at all.

3. Real HF identity

cached_whoami() calls get_token(), so an unmocked test resolves the developer's real username and orgs — which decide default repo owners and which Hub rows survive a listing.

What changed

Guardrails

  • _FROM_IMPORTED_PATH_CONSTANTS — the seven from-imported copies, each repointed on its own module inside tmp_lerobot_home.
  • The fixture's guard widened into a REFUSING TO RUN probe over every config constant and every from-imported copy, so an unpatched path fails at fixture time instead of being discovered by a write.
  • A session-scoped autouse calibration canary fingerprinting the real calibration/** tree (mtime+size+sha1, .DS_Store ignored, absent-dir safe for CI) that fails the session loudly if anything changed.
  • One consolidated autouse isolation floor (_isolate_real_user_state) covering all six redirects: the four SAVED_CUSTOM_*/SAVED_HIDDEN_* files, HF_LEROBOT_HOME, and huggingface_hub.constants.HF_HUB_CACHE. Plain monkeypatches, so tmp_lerobot_home and per-test overrides still win — a floor, not a wall.
  • _stub_cached_whoami — same from-import disease, same per-module cure: the five copies (server, models, datasets, jobs, runners/hf_cloud) stubbed to None, the "not logged in" shape existing per-test mocks already use. hf_auth.cached_whoami is left real so tests/test_utils_hf_auth.py keeps testing genuine caching behaviour; per-test mocks still override.

Cleanup

  • Corrected the tmp_lerobot_home docstring, which claimed from-imports were covered by patching cfg.
  • Removed _reset_model_cache from tests/test_models.py: conftest's _reset_hub_listing_caches already calls invalidate_model_listing_cache(), so it was redundant and its docstring ("but not this one") was false.
  • The isolation fixture was drafted twice, once per test file; it is consolidated into tests/conftest.py rather than duplicated.

Verification

Check Result
Full suite (real HOME) 1265 passed
Populated-HOME + HF_HUB_OFFLINE=1, before 8 failed (7 × test_models, 1 × test_list_all_datasets_merges_hub_and_local)
Populated-HOME + HF_HUB_OFFLINE=1, after 267 passed, 0 failed
Real ~/.cache/huggingface/lerobot sha256 manifest (1053 files) byte-identical before/after every run
ruff check / ruff format --check clean
pre-commit run --all-files all hooks passed

The populated-HOME simulation seeds an adversarial HOME-shaped tree — pins, hidden lists naming ids the tests seed themselves, local dataset dirs, and a hub-cache entry — to prove the redirects hold rather than merely that CI is empty.

Deferred / follow-ups

  • Root cure: a call-time-accessor refactor of utils/config.py. After it, _FROM_IMPORTED_PATH_CONSTANTS empties and the guard becomes a pure regression detector. The list is a stopgap that must be kept in sync with grep -rn "from .utils.config import" makermodslab/.
  • Rig-side, intentionally not carried here: three stray-cleanup tests in tests/test_auto_calibrate.py (added by 5e95ea9, which is not on main) write through auto_calibrate's copy of CALIBRATION_BASE_PATH_ROBOTS into the real follower calibration. Their fix is commit b95680a, which must land on rig before the P0 safety train carve — this PR targets main, where neither those tests nor the feature they cover exist. The conftest protections here are derived from b95680a but authored fresh against main's actual fixture.
  • Discovered, not fixed: 6 tests/test_jobs.py cloud-resume tests fail under a token-less fake HOME (test_local_parent_resumed_on_the_cloud_*). Identical failures at baseline, so not a regression from this PR, but they indicate those tests depend on ambient HF auth — the same family of leak, worth a separate pass.

🤖 Generated with Claude Code

Mokuroh54 and others added 2 commits August 8, 2026 21:28
…t touch real calibration

`tmp_lerobot_home` patched the constants on `makermodslab.utils.config`, which
reaches every consumer that reads them THROUGH the module — but not a
`from .utils.config import FOLLOWER_CONFIG_PATH`, which binds a COPY of the
string at import time. Seven such copies exist (auto_calibrate x3,
arm_identity x2, server x2) and none of them were redirected by anything in the
suite.

That is not theoretical on this branch: the calibration-config DELETE endpoint
builds its `os.remove()` target from server.py's own copy of
FOLLOWER_CONFIG_PATH, and no test repoints it, while the developer's real
`calibration/robots/so_follower/home.json` sits at the other end. The follower
side had no patching at all; `tests/test_server.py` repoints only
LEADER_CONFIG_PATH, and says so in a comment.

- Add `_FROM_IMPORTED_PATH_CONSTANTS` (the seven copies) and repoint each one on
  its own module inside `tmp_lerobot_home`.
- Extend the fixture's guard into a probe over every config constant AND every
  from-imported copy, via `_assert_outside`, so an unpatched path fails loudly
  at fixture time instead of being discovered by a write.
- Add a session-scoped autouse canary that fingerprints the real
  `calibration/**` tree (mtime+size+sha1, `.DS_Store` ignored, absent-dir safe
  for CI) and fails the session loudly if anything changed.
- Correct the `tmp_lerobot_home` docstring, which claimed from-imports were
  covered by patching `cfg`.

Root cure is a call-time-accessor refactor of utils/config.py (follow-up), after
which `_FROM_IMPORTED_PATH_CONSTANTS` empties and the guard becomes a pure
regression detector.

Conftest protections derived from b95680a, whose test-side half targets rig: the
three stray-cleanup tests it repoints were added by 5e95ea9, which is not on
main, so only the conftest half applies here and is authored fresh against
main's actual fixture rather than as a widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and HF identity

On a clean CI runner the suite is green; on a developer's populated machine
`tests/test_models.py` fails 7 tests before any change of ours, because the
model listing folds in the real `saved_custom_models.json` — a listing asserted
to be `["imported_policy"]` also carries the developer's real pins. The dataset
listing has the same shape of leak in both directions (real pins added, real
`hidden_datasets.json` entries silently subtracting a row a test just seeded);
it is latent rather than failing on the current machine state.

`tmp_lerobot_home` never covered the four saved-repo JSON files, and the listing
tests that don't request that fixture got no redirection at all.

- Add one autouse `_isolate_real_user_state` in conftest covering all six
  redirects — the four SAVED_CUSTOM/SAVED_HIDDEN datasets+models files,
  `HF_LEROBOT_HOME`, and `huggingface_hub.constants.HF_HUB_CACHE` — into
  per-test tmp dirs. Consolidated deliberately: the same fixture was drafted
  twice, once per test file, and belongs in one place. Plain monkeypatches, so
  `tmp_lerobot_home` and per-test overrides still win — a floor, not a wall.
- Probe HF_HUB_CACHE in `tmp_lerobot_home` now that something patches it.
- Add autouse `_stub_cached_whoami`. `cached_whoami()` calls `get_token()`, so
  an unmocked test resolves the developer's real username and orgs, which decide
  default repo owners and which Hub rows survive a listing. Same from-import
  disease as the path constants, so the same per-module cure: the five copies
  (server, models, datasets, jobs, runners/hf_cloud) are stubbed to `None` — the
  "not logged in" shape the existing per-test mocks already use. `hf_auth`'s own
  `cached_whoami` is left real so tests/test_utils_hf_auth.py keeps testing the
  genuine caching behaviour, and per-test mocks still override.
- Drop `_reset_model_cache` from tests/test_models.py: conftest's
  `_reset_hub_listing_caches` already calls `invalidate_model_listing_cache()`,
  so the fixture was redundant and its docstring ("but not this one") false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mokuroh54 Mokuroh54 added chore Maintenance, removals, deps — no user-facing feature area: backend FastAPI server and Python modules labels Aug 9, 2026
Mokuroh54 added a commit that referenced this pull request Aug 11, 2026
Addresses the three findings on PR #74. All three reproduce against the
branch as it stood; each now has a regression test.

1. Only the first read was watchdogged; later reads were a raw cap.read()
   under the entry lock. A device dying mid-stream left the generator
   blocked inside cv2 forever, so the MJPEG response never completed and
   uvicorn's graceful shutdown waited on it — the exact hang this PR says
   it contains. _read_first_frame becomes _read_with_deadline and every
   read goes through it (READ_TIMEOUT for the steady state, the existing
   FIRST_FRAME_TIMEOUT for the first). A watchdog thread per frame costs
   ~25us, ~0.04% of a core at TARGET_FPS.

2. A wedge deregistered its entry, so the next client built a fresh one
   and opened a fresh capture on the same dead device. The preview tile
   retries forever by design, so that leaked a thread and a handle every
   12s: measured 10 captures and 10 stranded threads over 10 retries.
   The entry is now kept as a tombstone (_mark_wedged), which is what the
   class docstring already claimed and what identity keying made safe.
   Capped at one leaked thread and one capture per wedged camera.
   stop_all skips tombstones rather than re-waiting the lock on each
   Start.

3. LOCK_ACQUIRE_TIMEOUT (2s) sat below FIRST_FRAME_TIMEOUT (5s), so a
   contender gave up sooner than a healthy camera is allowed to spend on
   a first read: a second tile on a camera with a 3s warm-up refused it
   and marked it wedged. The contention timeout is now derived to sit
   above every read deadline, which is also what makes "lock still held
   => the reader is wedged" a sound inference for the first time.

Full suite: 1276 passed; the 7 test_models failures are pre-existing on
this base (real-user-state leakage, fixed separately by PR #76).
…nted before it

Ported from the parked fix/stray-cleanup-tests-real-calibration branch
(b95680ae): the three stray-cleanup tests now have the fake subprocess
write the calibration file DURING the run (new _follower_file_path
helper) instead of planting it before start(). A file that exists before
start() is, by construction, indistinguishable from the user's previous
calibration under the same name, which the cleanup's design deliberately
preserves (P0-1 / C1) — planting made the tests assert removal of
something the design says to keep. b95680ae's conftest hardening is
already on this branch in evolved form; this test-fidelity piece was the
one part left behind.

Verified: tests/test_auto_calibrate.py 56 passed; pre-commit --all-files
clean; real calibration tree untouched (session canary passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend FastAPI server and Python modules chore Maintenance, removals, deps — no user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant