Skip to content

fix(lcb-service): use the fork start method explicitly - #433

Open
liayan wants to merge 8 commits into
mlcommons:mainfrom
liayan:fix/lcb-service-fork-start-method
Open

fix(lcb-service): use the fork start method explicitly#433
liayan wants to merge 8 commits into
mlcommons:mainfrom
liayan:fix/lcb-service-fork-start-method

Conversation

@liayan

@liayan liayan commented Jul 29, 2026

Copy link
Copy Markdown

What

Pin the fork multiprocessing context for the outer process pool, per-problem process, and manager used by the LiveCodeBench service. Python 3.14 changed the Linux default to forkserver; in the shipped Python 3.14 service image that caused grading children to die at startup and left evaluation at 0/N.

The patch intentionally preserves the existing x86/Python 3.12 grading contract. An empty child response remains an ordinary failed submission, with no new error codes or service-level failure conditions.

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Refactor/cleanup

Testing

  • pre-commit run --all-files
  • pytest -m unit — 1,514 passed, 5 skipped
  • Added a regression test proving submitted code that exits its grading child remains a failed sample instead of aborting evaluation
  • Previously validated on the Python 3.14 lcb-service image: explicit fork completed grading successfully

Checklist

  • Code follows project style
  • Tests added and passing
  • Existing grading semantics preserved

@liayan
liayan requested a review from a team July 29, 2026 19:32
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@1df1bb2). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #433   +/-   ##
=======================================
  Coverage        ?   81.70%           
=======================================
  Files           ?      146           
  Lines           ?    19343           
  Branches        ?        0           
=======================================
  Hits            ?    15804           
  Misses          ?     3539           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch from e9aa1db to 4ff0df4 Compare July 29, 2026 19:42
@liayan

liayan commented Aug 4, 2026

Copy link
Copy Markdown
Author

Verified on the Python 3.14 lcb-service image (forkserver default): grading works with the fork pin; dead grading children classify as -6, and the all-infra-errors check raises, so the original failure is still caught; an all-timeout batch scores 0 without raising — that case would have raised on commit one fix.

@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch 2 times, most recently from 47115a5 to 9c6739e Compare August 4, 2026 14:26
@nvzhihanj
nvzhihanj requested a review from hvagadia August 4, 2026 18:33
liayan added 2 commits August 4, 2026 17:46
Python 3.14 changed the default multiprocessing start method on Linux
from fork to forkserver. The grading pipeline (pool workers forking a
per-problem mp.Process + mp.Manager) only works with fork: under
forkserver the grading children die at startup, every result comes back
as an error, and execute_code_single_suppressed_errors turns that into
all-failed tests, so the service sits at 0/N forever.

Pin the fork context for the executor, the per-problem Process and its
Manager. Also raise if every subprocess reported an execution error --
that means the judge is broken, not that all samples failed -- and log
those errors at error level instead of warning.

Seen on a python 3.14 lcb-service image: 0/349 after 3.5h, one defunct
child per pool worker. Same inputs with fork forced: done in 6 min.
The repo pins 3.12 so CI won't hit this, but shipped images have.
Timeouts were counted as execution errors, so a small batch where every
submission loops forever would trip the guard and raise instead of
scoring 0. Split the empty-buffer case in run_code_subprocess: child
still alive at the deadline -> timeout (-1, submission's fault), child
exited without reporting -> new GradingChildDied (-6, judge's fault).
The guard now only counts -5/-6, so the forkserver startup deaths still
raise and all-timeout batches score normally.

Also log the multiprocessing start method at service init; that would
have made the original 0/N a one-line diagnosis.
@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch from 9c6739e to 3dfa3ca Compare August 4, 2026 23:33
@hvagadia
hvagadia force-pushed the fix/lcb-service-fork-start-method branch from 83a5f2f to 3dfa3ca Compare August 5, 2026 20:23

with ProcessPoolExecutor(max_workers=self.n_lcb_workers) as executor:
with ProcessPoolExecutor(
max_workers=self.n_lcb_workers, mp_context=_MP_CTX

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — concurrency: LCBServe.evaluate() is invoked through event_loop.run_in_executor() in _server.py, so this outer ProcessPoolExecutor is created from a worker thread in the already multithreaded uvicorn process. Forcing it to use fork can inherit locks held by threads that disappear in the child, leaving grading workers deadlocked and the evaluation hung. The reported compatibility problem establishes that the inner grading child needs fork semantics, but not that the outer pool does. Please use separate contexts—for example, forkserver for this outer pool and fork only for the Manager/Process inside run_code_subprocess, where the pool worker is single-threaded.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — concurrency: LCBServe.evaluate() is invoked through event_loop.run_in_executor() in _server.py, so this outer ProcessPoolExecutor is created from a worker thread in the already multithreaded uvicorn process. Forcing it to use fork can inherit locks held by threads that disappear in the child, leaving grading workers deadlocked and the evaluation hung. The reported compatibility problem establishes that the inner grading child needs fork semantics, but not that the outer pool does. Please use separate contexts—for example, forkserver for this outer pool and fork only for the Manager/Process inside run_code_subprocess, where the pool worker is single-threaded.

Good catch — tried forkserver as suggested, it passed locally but hung at pool shutdown in the lcb-service container (Python 3.14.5, unreaped zombie workers), so I switched the outer pool to spawn instead: worker startup is a bit slower but it's thread-safe for the same reason and all local + container tests pass; the grading child keeps fork. Let me know if you see any other issues.

# submitted code.
res = [-1] * len(suite["inputs"])
metadata = {
"error": "Grading subprocess died before reporting a result",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — correctness: execute_code_single_suppressed_errors() catches Exception, but SystemExit derives from BaseException. A submitted solution that calls sys.exit() therefore terminates the grading child without filling resp_buffer, and this branch classifies it as GradingChildDied (-6). For a one-sample batch—or when every submission exits—the all-infrastructure-error guard raises RuntimeError instead of recording ordinary failed submissions. This was reproduced through the actual LiveCodeBench path. Please catch SystemExit as a submission runtime failure and account for os._exit, which can bypass exception handling entirely.

@liayan liayan Aug 5, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — I did see SystemExit getting mixed in with GradingChildDied. Turns out it's specifically the call-based (fn_name) grading path, since the stdio path already guards against it internally. Added a SystemExit except guard as suggested (bc502f1), scoring it as its own -7 SubmissionExit instead of -6, so it doesn't get miscounted as an infra failure anymore.
Let me know if you still see any issues.

…failure

sys.exit() is a BaseException, not caught by the existing `except
Exception`. grade_call_based's method invocation has no SystemExit
guard (unlike the stdio path's call_method, which already does),
so a call-based submission calling sys.exit() killed the grading
child before it filled resp_buffer and got misclassified as -6
GradingChildDied - an infra error that can trip the all-errors guard
even for a single-sample batch. Give it its own code (-7) instead,
kept out of _LCB_INFRA_ERROR_CODES.

@nv-alicheng nv-alicheng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Council — Multi-AI Code Review

Reviewed by: Claude + Code-Quality (×2: diff + import-neighborhood, per request) | Depth: quick + forced code-quality

codex was unavailable in this environment. See the summary comment for neighborhood findings on _server.py/run_lcb_tests.py and untouched-line items that can't be posted inline.

metadata = {
"error": "Grading subprocess died before reporting a result",
"error_code": -6,
"error_message": f"GradingChildDied (exitcode={p.exitcode})",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] high (data-integrity): The -6 GradingChildDied branch conflates a broken judge with a submission that kills its own grading process, and -6 is in _LCB_INFRA_ERROR_CODES (line 64), so an all-crash batch trips the infra_errors == len(futures) RuntimeError (line 361) and refuses to report a legitimately-0 score — the exact opposite of this PR's stated goal. Untrusted submitted code runs inside the child (_MP_CTX.Process(target=execute_code_single_suppressed_errors)run_test(..., test=code)), and the child only records a result at the very end (resp_buffer.append(...), line 122). Any path terminating the interpreter before that append — and before the SystemExit/except handlers run — leaves resp_buffer empty with p.is_alive()==False, landing here as -6. Submission-controlled ways to hit it (all bypass Python exception handling, so the new SystemExit→-7 fix at :106 does NOT cover them): os._exit(0); a native segfault (malicious/broken numpy, ctypes, C-extension); an OOM SIGKILL from a huge allocation. So -6 is reachable by ordinary adversarial/bad model output, not just infra. Consequence: a batch where every submission crashes its interpreter aborts with RuntimeError instead of reporting the correct pass@1=0 — discarding a true 0 score.

Root cause / fix: the infra-vs-submission distinction is asserted in a comment + a set literal, not derived from how the child died. Reserve -6 for judge-startup failures (the fork/forkserver problem this PR targets) and classify submission-self-terminated deaths as a submission fault alongside -7 — e.g. treat a clean exitcode==0 empty-buffer as submission fault, and exclude negative/signal exit codes a submission can self-induce from _LCB_INFRA_ERROR_CODES. (Distinct from the already-fixed SystemExit miscount at :176.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — os._exit/segfault/OOM do bypass the -7 guard entirely. Went with a shared started-flag instead of exit codes (a submission can fake any exit code via os._exit): the child flips it right before grading starts, so pre-flag deaths stay -6 infra and post-flag deaths become -8 SubmissionKilledChild, scored as a normal fail. Verified in the container that an all-os._exit batch now reports a true 0 instead of tripping the guard (83db6ed).

Comment thread src/inference_endpoint/evaluation/livecodebench/lcb_serve.py
) + flat_timeout_extension

manager = mp.Manager()
manager = _MP_CTX.Manager()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Quality] medium (code-quality): manager = _MP_CTX.Manager() creates a full multiprocessing Manager (a separate server process) for every single code sample and never shuts it down — no manager.shutdown(), no with — so it's only reclaimed by GC finalizer and manager processes leak under load. This compounds with the fork change: run_code_subprocess already runs inside a forked ProcessPoolExecutor worker, so each grade is pool-worker-fork → Manager-server-fork → grading-Process-fork, nested fork-from-fork each carrying inherited parent state. Wrap in with _MP_CTX.Manager() as manager: (or reuse one manager per _LCBWorker batch) to bound process count deterministically.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — this actually predates this PR, but worth tightening while we're in here: wrapped it in a with-block and added a join() after kill so a killed grading child gets reaped instead of lingering as a zombie (1918fd7).

_LCB_INFRA_ERROR_CODES = {-5, -6}


def execute_code_single(test_suite_json: str, code: str, timeout_sec: int = 60):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Quality] low (code-quality): Missing return-type annotations on the three grading-path functions that cross the process boundary: execute_code_single (67), execute_code_single_suppressed_errors (91), run_code_subprocess (126) — all return tuple[list, dict] (results + metadata) but none declare it, so the res, metadata = ... unpack the whole error-code protocol depends on is unchecked. Relatedly, the fork target at line 91 is def execute_code_single_suppressed_errors(*args, resp_buffer=None, **kwargs) — fully untyped variadics exactly where _MP_CTX.Process(target=..., args=..., kwargs=...) wires args, so a wrong positional/keyword fails only at runtime inside the child (surfacing as a spurious -6). Give both concrete signatures / a named result-tuple alias.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — gave the fork target named parameters and declared tuple[list, dict] on all three grading helpers (7f4d040); the variadics were from previous prs but agreed they hid wiring mistakes until runtime.

if timed_out:
p.kill()

if len(resp_buffer) == 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Both] low (code-quality): The two no-result branches duplicate res = [-1] * len(suite["inputs"]) plus a same-shape {error, error_code, error_message} dict (timeout arm 164-169, child-died arm 174-179), and use an else-after-return. A guard clause flattens it: if resp_buffer: return resp_buffer[0] up front, then handle the empty case with no nested else; hoist the shared res assignment and set only the differing metadata per branch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — early return for the reported-result case and hoisted the shared res (35feb8e); the duplication was honestly my own doing from stacking the attribution branches one review round at a time.

@nv-alicheng

Copy link
Copy Markdown
Collaborator

Review Council — Multi-AI Code Review

Reviewed by: Claude + Code-Quality (run twice — diff scope + import-neighborhood, per request) | Depth: quick with code-quality forced on (normally skipped at quick depth)
(codex CLI unavailable in this environment.)

Tight, well-reasoned fix. One high-severity correctness gap survives it, plus code-quality/neighborhood items you asked for. Existing bot/human threads corroborated but not duplicated (see bottom).

🔴 Must Fix (high)

File Line Category Reviewer Summary
lcb_serve.py 178 data-integrity Claude os._exit() / segfault / OOM-kill in submitted code dies before the result-append → classified -6 GradingChildDied → counted as infra → an all-crash batch trips the RuntimeError and discards a true pass@1=0, the opposite of the PR's goal. Bypasses the new SystemExit→-7 fix (those never reach an except). Reserve -6 for judge-startup failures; classify submission-induced exits as a submission fault like -7.

🟡 Should Fix (medium)

File Line Category Reviewer Summary
lcb_serve.py 64 code-quality Both Magic error codes (-1/-2/-5/-6/-7) + prose-encoded infra invariant → IntEnum, derive _LCB_INFRA_ERROR_CODES from it. This is the data-model root of the high finding above.
lcb_serve.py 141 code-quality Quality _MP_CTX.Manager() per sample, never shutdown() → leaks manager server processes; nested fork-from-fork under the pool. Use with.
lcb_serve.py 71 code-quality Quality (nbhd) Lazy import numpy / from .run_lcb_tests import run_test inside the fork target — violates the repo no-lazy-imports rule; hoisting also warms the modules in the parent before fork. (untouched line — not inline)
lcb_serve.py 513 code-quality Quality (nbhd) evaluate_dataframe mutates the caller's DataFrame in place (df["extracted_code"] = ...fillna("")) — hidden side effect on a caller-owned object. Assign to a local. (untouched line)
_server.py 405 code-quality Quality (nbhd) Websocket handler passes the module-global lcb_serve (typed LCBServe | None) into EvaluationSession with no None-guard → opaque AttributeError later inside run_in_executor; /info already 503-guards. Also asyncio.get_event_loop() in a coroutine is deprecated → get_running_loop(). (other file — not in PR diff)
_server.py 32 code-quality Quality (nbhd) from lib.lcb_serve import LCBServelib. prefix doesn't match the module's real location (same dir as _server.py); an implicit, undocumented container-packaging contract that ModuleNotFoundErrors if run in-place. Prefer from .lcb_serve import LCBServe or document the lib packaging. (other file)

🔵 Consider (low)

File Line Category Reviewer Summary
lcb_serve.py 67 code-quality Quality Missing -> tuple[list, dict] on the 3 grading funcs (67/91/126); fork target at 91 uses untyped *args/**kwargs where Process(...) wires args → wrong wiring fails only at runtime as -6.
lcb_serve.py 161 code-quality Both DRY: the two no-result branches duplicate res/metadata shape; a if resp_buffer: return ... guard clause flattens the else-after-return.
lcb_serve.py 459 code-quality Quality (nbhd) assert self.df is not None as a public-method precondition — stripped under python -O; raise explicitly. (untouched line)
run_lcb_tests.py 491 code-quality Quality (nbhd) except ValueError as e: raise e loses the traceback (use bare raise) and the following in_outs = None is unreachable dead code — on the exact -5 TestRunnerError path the PR relies on. (other file)

Existing threads (corroborated, not duplicated)

  • lcb_serve.py:309 (hvagadia, high/concurrency) — forcing fork on the outer ProcessPoolExecutor, created from a uvicorn worker thread via run_in_executor, can inherit thread-held locks and deadlock. Still live and unaddressed — the neighborhood Manager-leak/nested-fork finding (141) sits in the same fork-safety area and reinforces it. Not re-filed.
  • lcb_serve.py:176 (hvagadia/liayan, SystemExit)already fixed by this PR (bc502f1, -7 SubmissionExit). The high finding above is the residual case (os._exit/signals) that fix cannot reach.

⚠️ Commit hygiene: 9 commits including 4 apparent fixups. Consider squashing before merge.

liayan added 5 commits August 5, 2026 19:16
evaluate() runs on an executor thread (the server dispatches it via
run_in_executor), so the per-request ProcessPoolExecutor was forking an
already-multithreaded process - a known deadlock risk: only the forking
thread survives in the child, locks held by other threads stay locked
forever. Switch the pool to spawn: fork+exec inherits no locks, so it is
safe to start from a thread, and everything submitted to the pool is
picklable, so it is a drop-in.

Tried forkserver first, but its helper hangs at pool shutdown in the
lcb-service container (Python 3.14.5) and leaks semaphores. Probed all
three start methods in the deployment image: fork and spawn tear down
cleanly, forkserver hangs indefinitely.

The inner grading child keeps fork: grading relies on fork semantics, and
forking from a freshly exec'd single-threaded pool worker is fine. The
startup log now prints both start methods.
A submission can kill its own grading child in ways no except block sees
(os._exit(), a native segfault, an OOM kill). That landed in the -6
GradingChildDied bucket and counted as an infrastructure error, so a batch
where every submission crashed its interpreter tripped the all-infra-errors
guard and aborted instead of reporting a legitimate 0 score.

The child now sets a shared started flag right before grading begins, so an
empty resp_buffer can be attributed: died before the flag means a judge
startup failure - still -6, still counted by the guard; died after means
the submission killed the interpreter - new -8 SubmissionKilledChild,
scored as a normal failed sample. Exit codes cannot make this distinction
because os._exit() lets the submission pick any code.
…process

Return the reported result early, hoist the shared all-failed res out of
the attribution branches, and keep only the metadata construction per
branch. No behavior change.
Each graded sample created a Manager (its own server process) and relied on
the GC finalizer to shut it down. Make the lifecycle explicit with a
with-block so the process count under load is bounded deterministically,
capture the child's started/exitcode state before the scope closes, and
reap a killed grading child with join() instead of leaving a zombie in the
pool worker.
execute_code_single_suppressed_errors is the fork target, but took fully
untyped variadics, so a miswired argument only failed at runtime inside the
grading child (surfacing as a spurious child-death error). Give it named
parameters, and declare the tuple[list, dict] return type on all three
grading helpers so the res/metadata unpacking is checked.
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.

4 participants