Skip to content

fix(timeout): restore v1 precedence, unify default, fix distributed timeout resolution - #177

Open
coketaste wants to merge 7 commits into
developfrom
coketaste/timeout-logic-update
Open

fix(timeout): restore v1 precedence, unify default, fix distributed timeout resolution#177
coketaste wants to merge 7 commits into
developfrom
coketaste/timeout-logic-update

Conversation

@coketaste

@coketaste coketaste commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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.

-1 on 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 --timeout equal to the default was invisible.
    The old resolver detected "unspecified" by comparing the CLI value against 7200, so --timeout 7200 looked identical to no flag and silently lost to a model card's timeout. In v1, the explicit flag always won.

  • --timeout 0 crashed.
    The CLI mapped 0 to None before it reached consumers. container_runner.py and slurm.py guarded with a bare timeout > 0, raising TypeError on None. The SLURM job template's {{ timeout | default(3600) }} only substitutes for undefined, not None, so it rendered the literal string --timeout None, which Typer then rejected.

  • A model card's "timeout": -1 was 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 -1 for 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_distributed forwarded the CLI's unresolved -1 sentinel straight into DeploymentConfig, so subprocess_timeout(-1) mapped to None and SLURM in-allocation runs had no wall-clock cap at all on any run that did not pass --timeout explicitly.

  • 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": 3600 silently ran under the 7200s default instead. DeploymentConfig now 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.j2 never referenced it, so Kubernetes jobs ran unbounded regardless of the model card or --timeout. Unlike SLURM, Kubernetes has no inner madengine process to re-resolve inside the pod, so the timeout is now resolved at manifest render time and the model script is wrapped in timeout, terminating with exit code 124 on overrun.

Changes

  • core/timeout.py is now the single source of truth:

    • resolve_run_timeout() applies default < model card < explicit CLI precedence.
    • subprocess_timeout() is the sole guard that maps timeout values onto subprocess/communicate semantics. This is important because timeout=0 means "expire immediately," not "no timeout."
    • DEFAULT_RUN_TIMEOUT = 7200 unifies 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 from execution/container_runner_helpers.py to core/timeout.py (the old import path remains re-exported).

  • run_orchestrator._execute_distributed() now resolves the sentinel before building DeploymentConfig, restoring the default cap for SLURM and Kubernetes.

  • DeploymentConfig now 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.j2 now renders the resolved value directly instead of using {{ timeout | default(3600) }}.

  • job.yaml.j2 and k8s_template_context.py now resolve against the model card at render time and enforce the result with timeout in the generated script.

  • build_orchestrator and run_orchestrator manifest writers

coketaste and others added 2 commits August 21, 2026 17:03
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>
@coketaste coketaste self-assigned this Aug 21, 2026
Copilot AI lite review requested due to automatic review settings August 21, 2026 23:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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() and core.timeout.subprocess_timeout() and updated call sites to use a shared sentinel contract (-1 unspecified, 0 no timeout, >0 explicit seconds).
  • Unified defaults to DEFAULT_RUN_TIMEOUT = 7200 across 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.

Comment thread src/madengine/orchestration/run_orchestrator.py Outdated
coketaste and others added 2 commits August 21, 2026 21:08
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>
Copilot AI review requested due to automatic review settings August 24, 2026 16:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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() defaults timeout to DEFAULT_RUN_TIMEOUT (7200) but then passes it into resolve_run_timeout(model_info, timeout) where any non-negative value is treated as an explicit CLI timeout. Calling run_container() without a timeout argument will therefore force 7200s and prevent a model card timeout from taking precedence. Defaulting this parameter to the CLI sentinel -1 preserves 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 as run_container(): the default timeout value is a concrete DEFAULT_RUN_TIMEOUT, but downstream resolution interprets any non-negative value as an explicit CLI timeout and will prevent model-card timeout from overriding it when the caller omits the argument. Default this to the CLI sentinel -1 instead.
        timeout: int = DEFAULT_RUN_TIMEOUT,

Comment thread src/madengine/orchestration/run_orchestrator.py Outdated
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>
Copilot AI review requested due to automatic review settings August 25, 2026 02:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 defaults timeout to DEFAULT_RUN_TIMEOUT (7200). Because run_container() immediately calls resolve_run_timeout(model_info, timeout), a caller that omits timeout will unintentionally treat 7200 as an explicit CLI timeout and override any model-card timeout, violating the documented precedence (default < model card < explicit CLI). Use the -1 sentinel 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>
Copilot AI review requested due to automatic review settings August 25, 2026 03:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-exit in the timeout>0 branch, which skips the later artifact copy and the final exit ${MODEL_EXIT_CODE:-0}. Also, in the timeout<=0 branch bash /tmp/run_model.sh is not guarded; under set -e a non-zero model exit would abort the script before MODEL_EXIT_CODE is captured. Prefer capturing the exit code in an if ...; then ... else ... fi and 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

Comment thread src/madengine/deployment/templates/kubernetes/job.yaml.j2
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>
Copilot AI review requested due to automatic review settings August 25, 2026 15:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 to None, 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 is None, and passing that variable into subprocess.run.
                        timeout=subprocess_timeout(timeout),

Comment on lines +319 to +323
{% if timeout > 0 %}
echo "⏰ Setting timeout to {{ timeout }} seconds."
if timeout {{ timeout }} bash /tmp/run_model.sh; then
MODEL_EXIT_CODE=0
else
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.

2 participants