Skip to content

fix(config): fail fast with a clean error on a malformed head_server block - #3561

Open
sven-rankov wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
sven-rankov:sven-rankov/fix-2686-malformed-head-server-config
Open

sven-rankov wants to merge 2 commits into
NVIDIA-NeMo:mainfrom
sven-rankov:sven-rankov/fix-2686-malformed-head-server-config

Conversation

@sven-rankov

@sven-rankov sven-rankov commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #2686.

A head_server value with the wrong type — typically a ++head_server.port=<not an int> override — was only validated when ServerClient.load_head_server_config built a BaseServerConfig, deep inside the command. BaseServerConfig is not a BaseNeMoGymCLIConfig, so _handle_pydantic_validation_error re-raised the pydantic error and the user got a 71-line traceback (gym eval run --no-serve), gym env start initialised Ray before failing, and gym env validate / gym env resolve accepted the value and exited 0.

This PR moves the parser's head_server handling to where the block is first read in GlobalConfigDictParser.parse() (previously it read port for the port allocator there and only filled the defaults ~20 lines later): fill the defaults, validate the block against BaseServerConfig, hand the validated port to the allocator, and raise a new HeadServerConfigMalformedError(ConfigError, ValueError) with an actionable message — the same pattern the parser already uses for config_paths (MalformedConfigPathsError). Every affected command loads the config first and is wrapped in exit_cleanly_on_config_error, so all of them now print one Error: line and exit 1 before any server (or Ray) starts.

Before / after

gym env validate ++head_server.port=notanint — before (main): exit 0

✓ Config is valid.

gym eval run --no-serve --agent simple_agent -i in.jsonl -o out ++head_server.port=notanint — before: exit 1, 71 lines on stderr (abridged):

Traceback (most recent call last):
  File "<repo>/.venv/bin/gym", line 10, in <module>
  ... 16 more frames ...
  File "<site-packages>/pydantic/main.py", line 732, in model_validate
pydantic_core._pydantic_core.ValidationError: 1 validation error for BaseServerConfig
port
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='notanint', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

After — the same for every command that loads the config (env validate, env resolve, env start, eval run): exit 1, nothing on stderr

Error: 'head_server' is invalid: head_server.port (Input should be a valid integer, unable to parse string as an integer).
`head_server.host` must be a hostname or IP; `head_server.port` and, if set, `head_server.num_workers` must be integers. Set them in a config or as overrides, e.g.:
  ++head_server.host=127.0.0.1 ++head_server.port=11000

Notes:

  • The issue's other finding (the override dropping the defaulted host) was already fixed by feat(orchestration): return and persist a record of every submission #3188; this PR covers the remaining traceback half.
  • _handle_pydantic_validation_error's re-raise for non-CLI models is intentionally left as is: test_non_config_validation_error_is_reraised documents that a ValidationError from a non-CLI model may be a real bug that must propagate. Handling the user-facing block at the source keeps that contract.
  • Validation only — no coerced values are written back, so gym env resolve output is unchanged for valid configs. The validated (coerced) port is what now reaches the port allocator, so a quoted port: "11000" is kept off it (before, the string '11000' never matched an allocated int).
  • A scalar block (++head_server=foo) used to fail with an AttributeError at the parser's first .get; it now gets the same ConfigError treatment with its own message.
  • Out of scope / possible follow-ups: a generic fallback for other non-CLI models validated mid-command (GlobalAIOHTTPAsyncClientConfig, TelemetryConfig, exporter configs).

Validation

Before (main dbd26a9) / after, from the repo root:

  • gym eval run --no-serve --agent simple_agent -i in.jsonl -o out ++head_server.port=notanint — before: exit 1, 71-line traceback ending in ValidationError ... for BaseServerConfig; after: exit 1, the 5-line Error: message above on stdout, nothing on stderr.
  • gym env validate ++head_server.port=notanint — before: ✓ Config is valid., exit 0; after: Error: ..., exit 1.
  • gym env resolve ++head_server.port=notanint — before: dumps port: notanint, exit 0; after: Error: ..., exit 1.
  • gym env validate ++head_server.port=12345 — still ✓ Config is valid., exit 0.
  • gym env validate ++head_server.host=null — after: Error: naming head_server.host, exit 1.
  • gym env validate ++head_server=foo — before: exit 1, 29-line traceback ending in AttributeError: 'str' object has no attribute 'get'; after: Error: 'head_server' must be a mapping of hostandport. Got: 'foo'., exit 1.
  • gym env validate ++head_server.num_workers=abc — after: Error: naming head_server.num_workers, exit 1.

Tests:

  • New TestConfigLoadErrors::test_parse_malformed_head_server_raises_actionable_error (parser level, parametrized over a non-integer port, a null host, and both at once; asserts every field path, the override hint, the ValueError compat contract, and that __cause__ is the pydantic error).
  • New TestConfigLoadErrors::test_parse_scalar_head_server_raises_actionable_error (head_server: foo raises the ConfigError with the override hint instead of an AttributeError).
  • New TestConfigLoadErrors::test_parse_keeps_a_head_server_value_pydantic_can_coerce (a quoted port: "8080" still parses and is left as written — the check is lax and validate-only — while disallowed_ports holds the validated int 8080).
  • New TestFriendlyValidationError::test_malformed_head_server_override_is_a_config_error_not_a_traceback (through the real main(), parametrized over env validate, env resolve, eval run --no-serve; asserts exit 1, Error: on stdout, no "Config is valid", no traceback).
  • New TestFriendlyValidationError::test_malformed_head_server_is_rejected_before_any_server_starts (gym env start through the real main() with RunHelper replaced by a sentinel that fails if constructed — proves the error fires before Ray or the head server come up).
  • Verified each of these fails on main for the issue's reasons (exit 0 / raw ValidationError / the sentinel firing).
  • pytest tests/unit_tests/test_global_config.py tests/unit_tests/test_cli_main.py tests/unit_tests/test_cli.py tests/unit_tests/test_server_utils.py tests/unit_tests/test_server_status.py tests/unit_tests/test_cli_eval.py tests/unit_tests/test_cli_eval_submit.py — 594 passed.
  • pytest tests/unit_tests/ (macOS) — 5127 passed; the 53 failures are all pre-existing on main and unrelated (test_super_vllm_launcher.py bash launcher subtests, test_ci_environment.py, test_e2b_provider.py, test_enroot_provider.py).
  • ruff check / ruff format --check, pre-commit run --all-files — clean.
  • python3 tests/unit_tests/test_fern_docs_links.py — OK.

Rollouts

N/A — config parsing / CLI error handling only; no environment, agent, model, or verifier behavior changes.

Compatibility and benchmark impact

  • A head_server block that was previously accepted at parse time despite a wrong-typed value (non-integer port, null host/port) now fails with a ConfigError for every command, instead of a traceback (or exit 0) later. Well-typed configs, including a string port that pydantic coerces (e.g. port: "8080"), are unaffected.
  • No change to gym env resolve output for valid configs.
  • HeadServerConfigMalformedError subclasses ValueError like the rest of the ConfigError family.
  • No benchmark-result impact.

Proposed labels (I cannot apply labels): bug, area:config, complexity:low

Checklist

  • I have read the contributing guidelines.
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when applicable).
  • Documentation added or updated, or N/A with justification.
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format checks pass).
  • New source files include the required Apache-2.0 SPDX header.
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

@copy-pr-bot

copy-pr-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Sep 20, 2026
@sven-rankov
sven-rankov marked this pull request as ready for review September 20, 2026 19:36
@yaoyu-33 yaoyu-33 added area:config Hydra configuration, schemas, composition, and compatibility bug Something isn't working complexity:low Localized change in one scope with a small, straightforward review surface needs-review PR is ready for code review and waiting on a reviewer labels Sep 20, 2026
@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Sep 21, 2026
@sven-rankov

Copy link
Copy Markdown
Contributor Author

Friendly ping on this one. @yaoyu-33 Would you be able review?
I'm happy to rebase or split it if that would make review easier.

sven-rankov and others added 2 commits September 23, 2026 16:47
…block

A wrong-typed `head_server` value (typically a `++head_server.port=<not an int>`
override) was only validated when `ServerClient.load_head_server_config` built a
`BaseServerConfig`, deep inside the command. `BaseServerConfig` is not a CLI
config, so the router re-raised the pydantic error: `gym eval run` ended in a
raw traceback, `gym env start` initialised Ray before failing, and
`gym env validate` / `gym env resolve` accepted the value and exited 0.

Validate the block against `BaseServerConfig` in `GlobalConfigDictParser.parse()`
right after its defaults are filled, raising the new
`HeadServerConfigMalformedError(ConfigError, ValueError)` with an actionable
message, the same way the parser handles `config_paths`. Every affected command
loads the config first and is wrapped in `exit_cleanly_on_config_error`, so all
of them now print one `Error:` line and exit 1 before any server starts.

The router's re-raise for non-CLI models is intentionally unchanged
(`test_non_config_validation_error_is_reraised`); the dropped-host half of the
issue was already fixed by NVIDIA-NeMo#3188.

Closes NVIDIA-NeMo#2686

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tsvetan (Sven) Rankov <tsvetan.rankov@gmail.com>
The first commit validated `head_server` about twenty lines after the
parser had already read `head_server.port` for the port allocator, so
`++head_server=<scalar>` still died with an AttributeError at that earlier
`.get`, and a quoted `port: "11000"` bypassed the collision guard (the
string sat in `initial_disallowed_ports`, never matching an allocated int).

Merge the two blocks into one at first read: reject a non-mapping block
with the same ConfigError, fill the defaults, validate against
BaseServerConfig, and hand the validated port to the allocator. The stored
block is still left as written, so `gym env resolve` output is unchanged.
The hint now also names `num_workers`, the third BaseServerConfig field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tsvetan (Sven) Rankov <tsvetan.rankov@gmail.com>
@sven-rankov
sven-rankov force-pushed the sven-rankov/fix-2686-malformed-head-server-config branch from 46ca5cf to 6544cd3 Compare September 23, 2026 14:50

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:config Hydra configuration, schemas, composition, and compatibility bug Something isn't working community-request Issue reported or requested by someone from the community complexity:low Localized change in one scope with a small, straightforward review surface needs-review PR is ready for code review and waiting on a reviewer sla:triage-overdue Review assignment is over the one-business-day SLA

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[VDR][v0.6.0] ERR-f5cffc34 · Server-config validation errors escape main() as a raw doubled pydantic traceback

2 participants