Skip to content

fix(level7): targeted behavioral fixes for M04/M06/M07 checkers + P03 CLI - #67

Merged
michalmaj merged 3 commits into
mainfrom
feature/level7-concurrency-behavior-fixes
Oct 1, 2026
Merged

michalmaj merged 3 commits into
mainfrom
feature/level7-concurrency-behavior-fixes

Conversation

@michalmaj

Copy link
Copy Markdown
Owner

Summary

First targeted-rework batch for Level 7, scoped to exactly 5 items from the full-level audit. No architecture changes — ProcessPoolTournamentWorker (module-level worker, serializable seed data, pool.map, local Random, spawn compatibility) was verified correct and left untouched.

  • M04 check.py: replaced the "POST returns 202" check (which a fully-synchronous fake job also satisfies) with a behavioral proof — block simulate_tournament on a threading.Event, fire the POST from a helper thread, and require the HTTP response to come back while the worker is still blocked.
  • M06 check.py: replaced the "ThreadPoolExecutor" in source grep with a recording ThreadPoolExecutor stand-in that proves submit()/map() was actually called with ≥2 independent work items, not just instantiated.
  • M07 check.py: same recording-executor technique for ProcessPoolExecutor, plus a new exact-match check (_simulate_one is deterministic by seed, so parallel and sequential must produce identical hero_wins) and a real, unpatched ProcessPoolExecutor spawn/pickling smoke test.
  • M07 README.md: moved the full simulate_tournament_parallel implementation out of the main flow into a collapsible hint — this is the only mission where students write ProcessPool code themselves.
  • P03 cli.py: fixed the stale callable-based worker.submit(job_id, lambda: ...) call (current contract is submit(job_id, battles: int)), and — after confirming experimentally that the lambda→int fix alone still leaves jobs stuck in "running" forever (daemon thread dies with the short-lived CLI process) — made start wait for the job to reach a terminal status before exiting, using the same JobRepository polling pattern taught elsewhere in the course. Also added battles <= 0 validation, since the CLI bypasses the API's Pydantic layer entirely.

Explicitly not touched

M08, M01, M05, the P03 Ruff gate, .gitignore for job files, NumPy/Pandas, course-wide integration — reserved for Batch 2.

Test plan

  • M04: real background impl → PASS; synchronous fake → FAIL; worker-blocked-but-POST-returns-202 → PASS; cleanup verified (no hanging threads)
  • M06: pool.map solution → PASS; pool.submit solution → PASS; pool-created-but-unused fake → FAIL
  • M07: reference solution → PASS (Gates A/B/C); chunked alternative (multiple seeds per work item) → PASS; Fake A (unused pool) → FAIL; Fake B (one submit for whole tournament) → FAIL; Fake C (constant-result worker) → FAIL via new exact-match check; Fake D (lambda worker) → FAIL with a clean pickling error message; determinism confirmed across repeated runs; real spawn smoke test passes
  • P03 CLI: bug reproduced before fix (job stuck in running forever, confirmed via raw JSON); lambda→int alone insufficient (reproduced independently); full fix verified end-to-end across three separate process invocations (start, then status, then report); battles=0 → controlled error, exit 1
  • P03 boss fight checker (unchanged, full reference solution) → PASS, 9 gates including ProcessPool distribution/pickling
  • M01–M08 + P01/P02/P03 scaffolds → all correctly FAIL
  • tools/author_check.py → 727 checks passed, 0 errors
  • tools/course_status.py → runs clean
  • No zombie processes, no hanging threads, no __pycache__, no leaked job files, working tree clean except the intended patch

🤖 Generated with Claude Code

- M04 check.py: the checker only verified that POST /tournaments returned
  202 with a job_id, so a fake that ran the whole tournament synchronously
  and fabricated a "completed" job entry passed every gate — defeating the
  mission's entire point. Added a behavioral proof: monkeypatch
  SimulationService.simulate_tournament to block on a threading.Event,
  fire POST in a helper thread, and require the HTTP response to come back
  while the worker is still blocked. A synchronous implementation now
  hangs the helper thread and is correctly rejected; cleanup (Event
  release + thread join + method restore) runs in a finally block.
- M06 check.py: the checker only grepped "ThreadPoolExecutor" in the
  function's source text, so creating the pool and never calling
  submit()/map() on it (falling back to the sequential baseline) passed.
  Added a recording ThreadPoolExecutor stand-in that runs work inline
  (keeping results correct) while recording every independent item handed
  to submit()/map(); requires >=2 independent items were actually
  dispatched. Verified against map-based and submit-based correct
  solutions (both pass) and the create-but-unused fake (fails).
check.py previously only grepped "ProcessPoolExecutor" in the function's
source text, so three distinct fakes all passed: a pool created but never
used (sequential fallback), a single submit() for the entire tournament,
and a worker that ignores its seed and returns a constant result.

- Gate A: a recording ProcessPoolExecutor stand-in (same technique as M06)
  proves the pool actually receives >1 independent work item and rejects
  lambdas submitted to it. Handles both pool.map and chunked pool.submit
  styles without requiring an exact item count.
- New exact-match check: _simulate_one(seed) is fully deterministic, so a
  correct parallel run over seeds 0..N-1 must produce the exact same
  hero_wins as the sequential baseline — not just "a number that adds up
  to N". This is what catches the constant-result worker; Gate A alone
  does not.
- Gate B: a real (unpatched) ProcessPoolExecutor smoke test runs the
  actual _simulate_one across real worker processes under this platform's
  spawn start method, plus a pickle.dumps() check — proving the worker is
  genuinely spawn-safe, not just textually present.

Verified against the map-based reference solution, a submit-based
variant, a chunked variant (multiple seeds per work item), and all four
fakes (unused pool, single-submit, lambda worker, constant-result
worker) — each fake fails at the gate it's supposed to.

docs: moved the full simulate_tournament_parallel implementation out of
the main README flow into a collapsible hint — this was the only mission
where students write ProcessPool code themselves, so the full answer was
the highest-leakage spot in the level. Main flow now has the contract,
pickling constraint, and a syntax reminder instead of a ready solution.
cli.py's `start` command called worker.submit(job_id, lambda: ...), but
ProcessPoolTournamentWorker.submit() takes (job_id, battles: int) — the
old callable-based Worker contract was removed everywhere else but never
updated here. Reproduced the exact failure: `start` printed a job_id but
the job stayed stuck at "running" forever (confirmed via the real JSON
job file, error: null).

Fixing just the lambda->int argument was not sufficient: submit() spawns
a daemon thread, and the CLI process exits right after start() returns,
killing that thread before it can do any work — reproduced this
independently with a minimal int-only submit() call, same stuck-forever
result. A short-lived CLI invocation cannot host a background job that
outlives it without real daemonization (Celery/RQ-style), which is out
of scope here.

Chosen fix: `start` now waits for the job to reach a terminal status
before the process exits, using the same JobRepository polling pattern
taught elsewhere in the course, then prints the result. The job is still
driven through the real ProcessPoolTournamentWorker/JobRepository
machinery — `status`/`report` run afterward in separate processes still
read the same persisted, completed job from disk. Also added `battles <=
0` validation, since the CLI bypasses the API's Pydantic layer entirely
and previously "succeeded" silently with a meaningless 0-battle result.

Verified end-to-end across three separate process invocations (start,
then status, then report in fresh processes) and the error path
(battles=0 -> controlled exit 1, not a hung job). No lambdas remain in
cli.py. ProcessPoolTournamentWorker itself was not modified.
@michalmaj
michalmaj merged commit b385dc5 into main Oct 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant