fix(timeout): restore v1 precedence, unify default, fix distributed timeout resolution - #177
fix(timeout): restore v1 precedence, unify default, fix distributed timeout resolution#177coketaste wants to merge 7 commits into
Conversation
The v2 rewrite spread timeout handling across five layers and lost two v1 guarantees in the process: an explicit --timeout equal to the default was indistinguishable from "not specified" and silently lost to a model card timeout, and --timeout 0 crashed two call sites (container_runner.py, slurm.py) because 0 was rewritten to None before either could apply subprocess-safe handling. core/timeout.py is now the single source of truth for the sentinel contract (-1 unspecified, 0 no timeout, >0 explicit seconds): resolve_run_timeout() applies default < model card < explicit CLI precedence, and subprocess_timeout() is the one named guard mapping the sentinel onto subprocess/communicate semantics (which reads timeout=0 as "expire immediately", not "no timeout"). The CLI now forwards the user's value verbatim instead of resolving it early. Also unifies the default at 7200s everywhere -- distributed execution previously defaulted to 3600s while local execution and the docs said 7200s. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…spatch
_execute_distributed forwarded args.timeout to DeploymentConfig
verbatim, unlike the local path which resolves the sentinel in
container_runner.py. A default run (--timeout unspecified, i.e. -1)
therefore left DeploymentConfig.timeout == -1; subprocess_timeout(-1)
maps to None, silently dropping the wall-clock cap on the SLURM
in-allocation path instead of applying the intended 7200s default.
Call resolve_run_timeout() before building DeploymentConfig so the
distributed path gets the same precedence as local execution. Update
job.sh.j2 to render the resolved --timeout value directly instead of
{{ timeout | default(3600) }}, which only substitutes for undefined,
not None, and so never actually caught the sentinel.
Verified the new regression test fails against the pre-fix code
(DeploymentConfig.timeout == -1 instead of 7200) before restoring the
fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR centralizes and restores the v1 timeout sentinel/precedence behavior after the v2 rewrite, aiming to make timeout handling consistent across local and distributed execution (SLURM/K8s) with a unified default of 7200 seconds. It introduces madengine.core.timeout as the single source of truth for resolving run timeouts and for mapping timeout values onto subprocess semantics.
Changes:
- Added
core.timeout.resolve_run_timeout()andcore.timeout.subprocess_timeout()and updated call sites to use a shared sentinel contract (-1unspecified,0no timeout,>0explicit seconds). - Unified defaults to
DEFAULT_RUN_TIMEOUT = 7200across orchestration, execution, and deployment paths, and updated SLURM job template timeout rendering. - Added/updated regression tests plus documentation and changelog entries covering precedence and sentinel behavior.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_slurm_job_template.py | Adds regression coverage for SLURM job script timeout forwarding and in-allocation subprocess timeout mapping. |
| tests/unit/test_orchestration.py | Verifies distributed path resolves/handles timeout sentinel when building DeploymentConfig. |
| tests/unit/test_execution.py | Updates unit coverage for new core.timeout resolution and subprocess mapping helpers. |
| tests/unit/test_container_runner.py | Adds coverage ensuring subprocess timeout uses None for “no timeout” sentinels. |
| tests/unit/test_cli.py | Ensures CLI forwards timeout sentinel verbatim as an int downstream. |
| tests/e2e/test_execution_features.py | Adds E2E assertion that explicit --timeout 7200 still beats model card timeout. |
| src/madengine/orchestration/run_orchestrator.py | Unifies defaults and updates distributed execution timeout handling. |
| src/madengine/execution/container_runner.py | Switches timeout defaults to DEFAULT_RUN_TIMEOUT and uses subprocess_timeout() at subprocess call sites. |
| src/madengine/execution/container_runner_helpers.py | Re-exports resolve_run_timeout from core.timeout for backward-compatible imports. |
| src/madengine/deployment/templates/slurm/job.sh.j2 | Stops using Jinja default() filter and renders the passed timeout value directly. |
| src/madengine/deployment/slurm.py | Uses subprocess_timeout() when running inside an existing allocation. |
| src/madengine/deployment/base.py | Aligns DeploymentConfig.timeout default with DEFAULT_RUN_TIMEOUT. |
| src/madengine/core/timeout.py | Adds shared default constant plus resolver + subprocess mapping functions alongside Timeout class. |
| src/madengine/cli/commands/run.py | Stops materializing the timeout sentinel early; updates user-facing timeout display. |
| docs/usage.md | Documents precedence and sentinel semantics (including explicit --timeout 7200 behavior). |
| CHANGELOG.md | Records behavior changes and bug fixes related to timeout precedence and sentinels. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
resolve_run_timeout() guarded the model card with `>= 0`, so a card setting "timeout": -1 fell through to the 7200s default. v1 read the card unconditionally and passed the value to signal.alarm(), where a negative wrapped to ~136 years -- effectively unbounded. Real MAD cards (migx_onnxrt_resnet50_v1_5_benchmarks, pyt_huggingface_msft_phi3.5) set -1 for exactly that, and were silently being capped. Drop the guard so the card is taken as-is, matching v1. Only the CLI keeps a sentinel: -1 means --timeout was not passed. 0 needs no special case either -- Timeout and subprocess_timeout already map anything non-positive to "no timeout", which is how v1 got the behavior in the first place. This required fixing the manifest writers, which used -1 as filler for an absent card timeout. Once a card's -1 means "unbounded", that filler collides with a real value: a model with no timeout field would be written as -1 and read back as unbounded instead of defaulting to 7200s. The filler is now None (JSON null), which the resolver already treats as absent and which can never be a real value. The -1 filler was introduced earlier on this same branch and never shipped, so no existing manifest carries it. Verified end-to-end against real containers: a card with timeout -1 runs unbounded, a card with no timeout gets 7200s, and explicit values on the card and CLI are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SLURM resolves the run timeout twice: once in _execute_distributed, and again inside the job, where the generated script re-invokes madengine run. Rendering the resolved value into that script made the inner run see an explicit --timeout, which correctly outranks the model card, so a model declaring "timeout": 3600 silently ran under the 7200s default. Before the precedence fix earlier in this branch this was masked: the rendered 7200 happened to equal the constant the old resolver compared against, so it read as "no flag passed" and the card won by accident. DeploymentConfig now carries the two values separately -- timeout, the resolved cap on madengine's own wait on the deployment, and cli_timeout, the sentinel forwarded verbatim for the in-job run to resolve against the model card itself. Local runs were never affected. The TestTimeoutForwarding cases asserted config.timeout, which is exactly the value that must not reach the job; they now assert cli_timeout, and two cases cover the round trip. Also documents an upgrade note: a build_manifest.json written by 2.1.x stored -1 as filler for every model without a timeout key, and a card's -1 now means "no timeout", so replaying such a manifest runs those models unbounded. Corrects the changelog's Kubernetes claim -- K8s threads timeout onto DeploymentConfig but no manifest template reads it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/madengine/execution/container_runner.py:1103
ContainerRunner.run_container()defaultstimeouttoDEFAULT_RUN_TIMEOUT(7200) but then passes it intoresolve_run_timeout(model_info, timeout)where any non-negative value is treated as an explicit CLI timeout. Callingrun_container()without atimeoutargument will therefore force 7200s and prevent a model cardtimeoutfrom taking precedence. Defaulting this parameter to the CLI sentinel-1preserves the same effective default while allowing model-card overrides.
This issue also appears on line 2768 of the same file.
timeout: int = DEFAULT_RUN_TIMEOUT,
src/madengine/execution/container_runner.py:2768
ContainerRunner.run_models_from_manifest()has the same issue asrun_container(): the defaulttimeoutvalue is a concreteDEFAULT_RUN_TIMEOUT, but downstream resolution interprets any non-negative value as an explicit CLI timeout and will prevent model-cardtimeoutfrom overriding it when the caller omits the argument. Default this to the CLI sentinel-1instead.
timeout: int = DEFAULT_RUN_TIMEOUT,
job.yaml.j2 never referenced the resolved timeout, so K8s jobs ran the model script unbounded regardless of the model card. Resolve against the card at render time (K8s has no inner madengine to re-resolve inside the pod, unlike SLURM) and wrap the script execution in `timeout`, treating a non-positive result as "run unbounded" per v1 precedence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/madengine/execution/container_runner.py:1103
run_container()now defaultstimeouttoDEFAULT_RUN_TIMEOUT(7200). Becauserun_container()immediately callsresolve_run_timeout(model_info, timeout), a caller that omitstimeoutwill unintentionally treat 7200 as an explicit CLI timeout and override any model-cardtimeout, violating the documented precedence (default < model card < explicit CLI). Use the-1sentinel as the default here so model cards can still override when callers don't specify a timeout.
build_info: typing.Dict = None,
keep_alive: bool = False,
keep_model_dir: bool = False,
skip_model_run: bool = False,
timeout: int = DEFAULT_RUN_TIMEOUT,
tools_json_file: str = "scripts/common/tools.json",
The K8s timeout fix (103e751) landed after the timeout docs were written, leaving four stale spots. CHANGELOG claimed "Kubernetes threads the value onto DeploymentConfig but no manifest template reads it, so nothing is enforced there either before or after" -- true when written, false once job.yaml.j2 started reading it. Drops the parenthetical, narrows the "SLURM/K8s lost their wall-clock cap" entry to SLURM (the K8s half was never that bug), and adds a Fixed entry for the K8s enforcement, which had no coverage. usage.md explained the SLURM two-stage resolution but omitted K8s; adds the render-time resolution and the exit-code-124 surface. wiki/index.html described the pre-fix rule -- a model card timeout "used when CLI is default (-1)" reads as a special case rather than a precedence level, and gave no hint that a card's 0/-1 means "no timeout". Rewritten as an explicit precedence statement, same for the models.json field-notes row. cli-reference.md's "-1=default 7200s" hid the fall-through to the model card, which is the whole point of the sentinel. No source changes; the 53 timeout unit tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/madengine/deployment/templates/kubernetes/job.yaml.j2:526
- The direct-script path has the same early-
exitin the timeout>0 branch, which skips the later artifact copy and the finalexit ${MODEL_EXIT_CODE:-0}. Also, in the timeout<=0 branchbash /tmp/run_model.shis not guarded; underset -ea non-zero model exit would abort the script beforeMODEL_EXIT_CODEis captured. Prefer capturing the exit code in anif ...; then ... else ... fiand exit only once at the end of the container script.
echo "⏰ Setting timeout to {{ timeout }} seconds."
if timeout {{ timeout }} bash /tmp/run_model.sh; then
MODEL_EXIT_CODE=0
else
MODEL_EXIT_CODE=$?
if [ "$MODEL_EXIT_CODE" -eq 124 ]; then
echo "ERROR: model script timed out after {{ timeout }}s"
fi
exit $MODEL_EXIT_CODE
Two issues raised in review of the timeout work.
`RunOrchestrator.execute()`, `ContainerRunner.run_container()`, and
`ContainerRunner.run_models_from_manifest()` each defaulted `timeout` to
DEFAULT_RUN_TIMEOUT, but resolve_run_timeout() reads any non-negative value
as an explicit --timeout. A programmatic caller that omitted the argument
therefore forced 7200s over every model card, contradicting the precedence
this branch exists to restore. All three default to the -1 sentinel, which
still resolves to 7200s when no card specifies one. The CLI always passes a
value, so it was never affected.
Separately, the K8s pod script runs under `set -e` and copies artifacts to
the results PVC only after the model returns, so a non-zero model exit
aborted the container before its post-scripts, perf.csv, and logs were
published -- the runs most worth diagnosing left nothing behind. This
predates the branch on the unbounded path (verified: a bare invocation under
`set -e` aborts identically), but the timeout wrapper added here would have
extended it to timeouts. Both branches now capture the exit code and defer
to the single `exit ${MODEL_EXIT_CODE:-0}` at the end.
Regression tests for each, verified to fail against the prior behavior.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/madengine/execution/container_runner.py:988
subprocess_timeout(timeout)correctly maps non-positive values toNone, but the log message printed earlier in this block still says "Setting timeout to {timeout} seconds" even when the effective subprocess timeout is disabled (e.g., model card timeout 0/-1 or CLI--timeout 0). This makes logs misleading and can complicate debugging of unbounded runs. Consider computing the mapped subprocess timeout once, printing a "no timeout" message when it isNone, and passing that variable intosubprocess.run.
timeout=subprocess_timeout(timeout),
| {% if timeout > 0 %} | ||
| echo "⏰ Setting timeout to {{ timeout }} seconds." | ||
| if timeout {{ timeout }} bash /tmp/run_model.sh; then | ||
| MODEL_EXIT_CODE=0 | ||
| else |
Summary
The v2 rewrite spread timeout handling across five layers and, in doing so, lost several v1 guarantees. This PR restores a single source of truth (
core/timeout.py) and fixes every place that drifted from it: local runs, SLURM, Kubernetes, and the documentation describing all three.Precedence, lowest to highest: default (7200s) < model card
timeout< explicit--timeout.-1on the CLI means "not passed" and falls through.0(CLI or model card) means "no timeout." A model card's value is otherwise taken as-is, including negative numbers.Bugs fixed
Explicit
--timeoutequal to the default was invisible.The old resolver detected "unspecified" by comparing the CLI value against 7200, so
--timeout 7200looked identical to no flag and silently lost to a model card's timeout. In v1, the explicit flag always won.--timeout 0crashed.The CLI mapped
0toNonebefore it reached consumers.container_runner.pyandslurm.pyguarded with a baretimeout > 0, raisingTypeErroronNone. The SLURM job template's{{ timeout | default(3600) }}only substitutes for undefined, notNone, so it rendered the literal string--timeout None, which Typer then rejected.A model card's
"timeout": -1was silently capped.The resolver guarded the model card with
>= 0, so a card asking for no timeout fell through to the 7200s default instead. Two real MAD-internal models (migx_onnxrt_resnet50_v1_5_benchmarks,pyt_huggingface_msft_phi3.5) set-1for exactly that and were being capped without warning. The card is now taken as-is, matching v1, where any non-positive value means unbounded.Distributed default was inconsistent and sometimes missing entirely.
SLURM and Kubernetes defaulted to 3600s while local execution and the documentation said 7200s. Worse,
_execute_distributedforwarded the CLI's unresolved-1sentinel straight intoDeploymentConfig, sosubprocess_timeout(-1)mapped toNoneand SLURM in-allocation runs had no wall-clock cap at all on any run that did not pass--timeoutexplicitly.SLURM ignored the model card's timeout on the resolved path.
SLURM resolves the timeout twice: once for the submitting process's own wait, and again inside the job, which re-invokes
madengine run. Rendering the already resolved value into that inner invocation made it look like an explicit--timeout, which correctly outranks the model card. As a result, a model declaring"timeout": 3600silently ran under the 7200s default instead.DeploymentConfignow carries the resolved cap and the raw CLI sentinel separately, so the inner run resolves against the card itself.Kubernetes never enforced the timeout at all.
job.yaml.j2never referenced it, so Kubernetes jobs ran unbounded regardless of the model card or--timeout. Unlike SLURM, Kubernetes has no innermadengineprocess to re-resolve inside the pod, so the timeout is now resolved at manifest render time and the model script is wrapped intimeout, terminating with exit code 124 on overrun.Changes
core/timeout.pyis now the single source of truth:resolve_run_timeout()appliesdefault < model card < explicit CLIprecedence.subprocess_timeout()is the sole guard that maps timeout values ontosubprocess/communicatesemantics. This is important becausetimeout=0means "expire immediately," not "no timeout."DEFAULT_RUN_TIMEOUT = 7200unifies the default everywhere.The CLI (
cli/commands/run.py) now forwards the user's value verbatim instead of resolving it early. It only builds a display string.resolve_run_timeout()moved fromexecution/container_runner_helpers.pytocore/timeout.py(the old import path remains re-exported).run_orchestrator._execute_distributed()now resolves the sentinel before buildingDeploymentConfig, restoring the default cap for SLURM and Kubernetes.DeploymentConfignow includes:timeout: the resolved value used to cap madengine's own wait.cli_timeout: the raw CLI sentinel forwarded to the inner SLURM job.job.sh.j2now renders the resolved value directly instead of using{{ timeout | default(3600) }}.job.yaml.j2andk8s_template_context.pynow resolve against the model card at render time and enforce the result withtimeoutin the generated script.build_orchestratorandrun_orchestratormanifest writers