Skip to content

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

Open
liayan wants to merge 12 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 12 commits into
mlcommons:mainfrom
liayan:fix/lcb-service-fork-start-method

Conversation

@liayan

@liayan liayan commented Jul 29, 2026

Copy link
Copy Markdown
Member

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

❌ Patch coverage is 97.87234% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@af3ffff). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...nce_endpoint/evaluation/livecodebench/lcb_serve.py 97.61% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #433   +/-   ##
=======================================
  Coverage        ?   79.73%           
=======================================
  Files           ?      151           
  Lines           ?    20592           
  Branches        ?        0           
=======================================
  Hits            ?    16419           
  Misses          ?     4173           
  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
Member 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
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
Member 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
Member 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.

@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
Member 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
Member 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
Member 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
Member 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.

@nv-alicheng

Copy link
Copy Markdown
Collaborator

One thing to note - I think this is a bug on my part: I'd used py3.14 as the base container for the lcb_runner, but maybe this also should be studied:

From the lcb_runner repo, they use py3.11 (https://github.com/LiveCodeBench/LiveCodeBench)

uv venv --python 3.11
source .venv/bin/activate

uv pip install -e .

I have personally not checked but there might be some variance between python versions (i.e. version specific language features like walrus operator, same-line multi-context managers, etc.), and the code generated by the worker should be either version-agnostic or catered to a specific version.

This can get solved a little more easily if we just pin to a python 3.11 container for lcb-service.

@liayan

liayan commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

One thing to note - I think this is a bug on my part: I'd used py3.14 as the base container for the lcb_runner, but maybe this also should be studied:

From the lcb_runner repo, they use py3.11 (https://github.com/LiveCodeBench/LiveCodeBench)

uv venv --python 3.11
source .venv/bin/activate

uv pip install -e .

I have personally not checked but there might be some variance between python versions (i.e. version specific language features like walrus operator, same-line multi-context managers, etc.), and the code generated by the worker should be either version-agnostic or catered to a specific version.

This can get solved a little more easily if we just pin to a python 3.11 container for lcb-service.

3.14's forkserver default is what exposed this, but rolling back to 3.11 would just hide it — fork-from-thread, the -6/-8 attribution, and the SystemExit handling are all version-independent bugs that'd still exist, so I don't see much upside to reverting now.

liayan added 8 commits August 11, 2026 09:22
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.
…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.
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.
@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch from 7f4d040 to 750f3aa Compare August 11, 2026 13:22
@liayan

liayan commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Kindly ping.

@arekay-nv

Copy link
Copy Markdown
Collaborator

3.14's forkserver default is what exposed this, but rolling back to 3.11 would just hide it — fork-from-thread, the -6/-8 attribution, and the SystemExit handling are all version-independent bugs that'd still exist, so I don't see much upside to reverting now.

Lets also revert to 3.11 to keep it consistent with LCB specification. We can do that separately.

@arekay-nv arekay-nv 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

Reviewers: Codex (gpt-5.6-sol) + Claude (concurrency + accuracy lenses) + Grok 4.5 (Cursor). Event: comment only (no approve/reject). Findings verified across models; a couple of single-model claims were dropped after checking (e.g. an "open Manager() makes the fork worker multi-threaded" claim — empirically the worker stays single-threaded, so the spawn-pool / fork-child split is sound).

This PR has iterated well: the earlier SystemExit-7, outer-pool fork→spawn, Manager-leak→with+join, return-types, and DRY threads are all resolved — not re-raised here.

Posted inline (still open)

Line Sev Finding
117 high started_flag flips before judge-side setup → a judge-setup crash is mislabeled -8 and escapes the all-infra guard → silent pass@1=0 (mirror of the 83db6ed fix; boundary one frame too high).
213 high The regression test the PR body claims does not exist anywhere in tests/.
400 med All-infra guard is narrow: -8/-1 excluded, a single non-infra result disables it, and LCB's -4 "Error during testing" (run_lcb_tests.py:521/541) carries no "error" key so it's never classified/logged.
371 med warningerror floods ERROR with routine timeouts/submission failures, drowning genuine -5/-6.

Lower priority (not posted inline)

  • Lazy imports in execute_code_single (numpy, run_lcb_tests) — hoisting to module scope also shrinks the line-117 window (two birds).
  • PR description drift: the body says fork is used for the "outer process pool," but the code uses spawn there (fork only for the grading child) — please update.
  • Document the invariant that the fork child is safe only while the pool worker is single-threaded, so a future top-level import that spawns a thread doesn't silently reintroduce a fork+lock deadlock.
  • started_flag's Value can be lock=False (parent reads only post-join); the per-sample Manager could be a Pipe/SimpleQueue — hardening only.

Filed separately

  • A pre-existing accuracy bug found during this review — an empty test suite scores every submission as PASS (all([]) == True) — is tracked in #443 (out of scope for this PR's diff).

Orthogonal (existing thread)

The Python-3.11 discussion: pinning would only mask the (real, version-independent) MP bugs, so keep these fixes — but grading-interpreter parity with upstream LCB is a separate result-validity concern worth its own tracking, not a substitute.

# submission) is about to execute; if the interpreter dies now it is
# the submission's doing. Deaths before this point are judge startup
# failures.
started_flag.value = True

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 (correctness): started_flag is flipped True at wrapper entry — before the judge's own setup runs (import numpy / from .run_lcb_tests import run_test in execute_code_single, reliability_guard(), and the trusted-input json.loads/parse in grade_*). A hard interpreter death in that window (segfault on a bad numpy/arch, OOM SIGKILL, os._exit) is therefore attributed to the submission as -8 SubmissionKilledChild. Because -8 ∉ _LCB_INFRA_ERROR_CODES, infra_errors stays below len(futures), the all-infra RuntimeError guard never fires, and a systemically broken judge reports a silent pass@1 = 0 — the exact failure this PR exists to prevent.

This is the mirror/residual of the os._exit-6 fix (83db6ed): that fix moved the attribution boundary one frame too high. The flag should bracket only the untrusted submission's execution.

Fix: set started_flag.value = True immediately before the submission actually executes — push it into run_test/grade_*, after imports + reliability_guard() + suite parse. Hoisting the two lazy imports in execute_code_single to module scope (so they run in the parent before the fork) shrinks the window further and also satisfies the no-lazy-imports rule. The line-113 comment ("submission … is about to execute") is itself inaccurate, since trusted judge setup still runs first.

Independently flagged high by the concurrency reviewer and critical by Grok 4.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fix as suggested, moved the flag into run_test itself, set right after reliability_guard()/suite parse and before dispatching into grade_call_based/grade_stdio, so it only brackets the submission's own execution; hoisted the two lazy imports to module scope like you suggested (f866eb7).

Comment thread src/inference_endpoint/evaluation/livecodebench/lcb_serve.py
)
if metadata.get("error_code") in _LCB_INFRA_ERROR_CODES:
infra_errors += 1
logger.error(f"Test execution error for question {qid}: {metadata}")

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 (error-handling): warningerror here fires for every sample whose metadata has an "error" key — including routine outcomes: -1 timeouts, -7 SubmissionExit, -8 SubmissionKilledChild, and ordinary submission runtime errors. On a normal accuracy batch (many slow/buggy submissions) this floods ERROR and makes the genuine -5/-6 judge-broken signals indistinguishable in ops. The infra-vs-submission split already exists (_LCB_INFRA_ERROR_CODES) — gate the level on it: ERROR for infra codes, WARNING/INFO for submission-attributed ones.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — split it exactly that way: ERROR for infra codes, WARNING for submission-attributed ones (225598a).

# broken, not that every code sample failed its tests. Timeouts are
# excluded: a batch where every submission loops forever is a valid
# 0 score, not a broken judge.
if futures and infra_errors == len(futures):

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 (error-handling / accuracy): The all-infra guard is narrower than its intent:

  • It only fires when every future is -5/-6. A single legitimate non-infra result among N (a real -2/-7/timeout) drops infra_errors below len(futures), so a judge broken for a subset of samples is silently folded into the score.
  • -8 (a post-started_flag crash — see the line-117 thread) and -1 (timeout) are excluded, so a wholesale judge crash during setup escapes entirely.
  • The classification gate keys on "error" in metadata (line 368), but LCB's own outer handler returns [-4] with only error_code/error_message and no "error" key (run_lcb_tests.py:521, 541). A systemic exception inside grade_* → every sample -4 → not logged, not counted infra → silent pass@1 = 0.

Consider deriving the infra set structurally (an IntEnum) and treating "no result / judge-side death / grade-time exception" as infra regardless of the "error" key.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added the missing error key so -4 at least logs (225598a), but kept it out of _LCB_INFRA_ERROR_CODES on purpose — that outer catch is reached almost entirely by submissions that fail to compile, which is squarely the submission's fault, not the judge's; flagging it infra would abort ordinary "model wrote broken code" batches. The all-or-nothing threshold and -8/-1 exclusion are real gaps but a false-positive/negative tradeoff on the guard, so I'd rather get your take before picking a number than just change it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

IntEnum's the same ask as before with @nv-alicheng

Oop - this is an artifact of a direct copy/paste port from the official LCB runner repo. I can take the task to clean it up to fit code standards.

@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch 2 times, most recently from 05f5608 to 0066d11 Compare August 12, 2026 19:03
liayan added 2 commits August 12, 2026 15:22
Should've gone out with the earlier attribution-fix commits -- had this
written already, just missed staging it at the time. Covers timeout (-1),
sys.exit (-7), os._exit (-8), and judge-side deaths (-6, both before
run_test and during its own setup), plus the all-infra guard: an
os._exit()-only batch scores 0 without tripping it, a judge-startup-death
batch does.
started_flag flipped True at wrapper entry, before run_test's own setup
(reliability_guard, suite parse) ran -- a death in that window got
misattributed as -8 SubmissionKilledChild instead of -6 GradingChildDied,
so a real judge bug could sneak past the all-infra guard as a silent 0.

Moved the flag into run_test itself, set right before dispatch to
grade_call_based/grade_stdio -- the actual first line of the submission's
own code. Hoisted the numpy/run_lcb_tests imports to module scope while
in there too (same window, and it'd been flagged as a lazy import anyway);
costs the grading child nothing since it forks from an already-warm pool
worker.
@liayan
liayan force-pushed the fix/lcb-service-fork-start-method branch from 89dbccb to f866eb7 Compare August 12, 2026 19:25
…e logging

Two gaps in the all-infra guard/logging: the outer except in
grade_call_based/grade_stdio's callers returns -4 with no "error" key, so a
bad submission that fails to compile or define the expected function never
gets logged (the classification gate keys on "error" in metadata) -- give it
one. Left -4 out of _LCB_INFRA_ERROR_CODES on purpose: it's reached whenever
the submission's own code fails to compile, which is the common case and is
plainly not the judge's fault, not some rare harness bug worth aborting a
whole run over.

Also split logger.error into error (infra codes) vs warning (everything
else) -- it was firing for every timeout/sys.exit/os._exit/bad-code sample,
which drowns the real -5/-6 signal in ops on any batch with a few slow or
broken submissions.
Comment thread tests/unit/evaluation/test_lcb_serve.py
@arekay-nv

Copy link
Copy Markdown
Collaborator

@liayan can you add more details on how to reproduce the failures. I have tried to run LCB with py3.11 and py3.14 base images on x86/linux and unable to see any failures without the PR. Can you share the steps to reproduce the caused grading children to die at startup and left evaluation at 0/N.

@liayan

liayan commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@liayan can you add more details on how to reproduce the failures. I have tried to run LCB with py3.11 and py3.14 base images on x86/linux and unable to see any failures without the PR. Can you share the steps to reproduce the caused grading children to die at startup and left evaluation at 0/N.

Good catch — I originally hit this on vera-rubin with a new lcb-service image on 3.14, but also confirmed it reproduces on GB200/GB300. Just haven't checked on x86 yet. Just reproduced it again

Steps:

  1. Get any aarch64 Linux host with Python 3.14 (default forkserver start method) — I used an enroot/pyxis container on a GB300 NVL72 node, but the actual lcb-service image isn't required; a stock python:3.14-slim reproduces it too.
  2. Run any workload shaped like ProcessPoolExecutor → mp.Manager() → mp.Process(), all default context — no LCB code, dataset, or server needed. I used a trivial driver with 8 pool workers and 32 dummy "grading" samples.
  3. Watch it stall with zero output. ps -eo pid,ppid,stat,cmd on the node while it's stuck:
274448  274444 S  /app/venv/bin/python -c from multiprocessing.forkserver import main; main(10, 11, ...)
274455  274445 S  /app/venv/bin/python -c from multiprocessing.forkserver import main; main(8, 9, ...)
274463  274455 Sl /app/venv/bin/python -c from multiprocessing.forkserver import main; main(8, 9, ...)
274481  274458 Z  [python] <defunct>
274484  274459 Z  [python] <defunct>
274487  274461 Z  [python] <defunct>
274490  274462 Z  [python] <defunct>
274495  274457 Z  [python] <defunct>
274506  274455 Z  [python] <defunct>
274515  274456 Z  [python] <defunct>
274518  274460 Z  [python] <defunct>

Grading children dead as zombies under a nested tree of forkserver helper processes — same shape as the original 0/349-for-3.5h, one defunct child per pool worker.

@liayan

liayan commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Interesting — same exact steps on x86_64 (same Python 3.14, same code): finishes in ~1s, every time, at every scale I tried (up to 349 samples / 176 workers). So this looks architecture-specific rather than Python-only-specific.

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.

6 participants