Skip to content

feat(runtime): add failover replay [GMS 01/18]#11049

Open
hutm wants to merge 1 commit into
mainfrom
review/gms-v2-latehost-06-runtime-failover-replay
Open

feat(runtime): add failover replay [GMS 01/18]#11049
hutm wants to merge 1 commit into
mainfrom
review/gms-v2-latehost-06-runtime-failover-replay

Conversation

@hutm

@hutm hutm commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Make in-flight work replayable when an inference worker fails, so a replacement worker can resume service without coupling recovery to engine-owned KV memory.

Scope

Adds the runtime replay and failure-notification path. It does not introduce GMS allocation or engine-specific integration.

Stack

Position 1/18 in the GMS-managed KV review train. This PR is based on main.

Parent: #10450
Tracking: #10454

Review migration

This is the current review entry replacing historical merged PR #10470. GitHub does not allow a merged PR to be retargeted; #10470 and all of its comments and reviews remain intact as the historical record.

The train is rebased on main at aeda23b483831df2f75b697b8ccf65f4ffd9f3d6. The reordered functionality is preserved while each PR boundary is independently buildable and lintable: compatibility call sites and the engine-facing placement adapter now appear at first use; missing type imports and test markers were added; repository-pinned formatting was applied; one unused constant was removed; and every commit carries a DCO sign-off. The complete range passes git diff --check and the full pre-commit suite.

Validation

The Bulwark boundary was validated with the Dynamo Go unit tests and command build after positions 1-3 were assembled.


Open in Devin Review

Summary by CodeRabbit

  • New Features

    • Added support for Bulwark gateway endpoint mode and related configuration validation.
    • Introduced OpenTelemetry trace header forwarding for downstream requests.
    • Added configurable failover and shutdown behaviors to improve service continuity.
    • Added new discovery, runtime, and request-routing capabilities for dynamic and distributed serving.
  • Bug Fixes

    • Improved handling of empty or early-ended streams and request timeouts.
    • Refined discovery updates and process teardown to reduce false warnings and avoid stalled shutdowns.

@hutm hutm requested review from a team as code owners June 29, 2026 17:33
@copy-pr-bot

copy-pr-bot Bot commented Jun 29, 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.

@github-actions github-actions Bot added the frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` label Jun 29, 2026
@datadog-official

datadog-official Bot commented Jun 29, 2026

Copy link
Copy Markdown

Pipelines

⚠️ Warnings

🚦 4 Pipeline jobs failed

Docs link check | lychee   View in Datadog   GitHub Actions

Pre Merge | pre-merge-status-check   View in Datadog   GitHub Actions

Pre Merge | rust-clippy (.)   View in Datadog   GitHub Actions

View all 4 failed jobs.

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: faac3c6 | Docs | Give us feedback!

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 5 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +234 to +235
let resp_bytes = serde_json::to_vec(&resp_wrapper)
.expect("fatal error: invalid response object - this should never happen");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Empty-stream error frame uses wrong serialization format, corrupting the response when non-JSON codec is active

The empty-stream error frame is serialized with hard-coded JSON (serde_json::to_vec at lib/runtime/src/pipeline/network/ingress/push_handler.rs:234) instead of the negotiated request-plane codec, so the receiving side fails to decode it when MsgPack is configured.

Impact: When the request-plane codec is MsgPack, workers that produce an empty response stream send a JSON-encoded error frame that the client cannot deserialize, replacing a clear "stream ended before first response" error with a confusing deserialization failure.

Codec path inconsistency in pump_response_stream

Every other serialization site in pump_response_stream uses payload_codec.encode(&resp_wrapper) — see the normal response path at line 176 and the complete-final frame at line 260-262. The new empty-stream error injection path at line 234 bypasses this and calls serde_json::to_vec(&resp_wrapper) directly. The payload_codec parameter is available in scope and should be used here instead.

On the egress side (lib/runtime/src/pipeline/network/egress/addressed_router.rs:81-82), the decoder uses payload_codec.decode::<NetworkStreamWrapper<U>>(&res_bytes) for every frame. When the codec is MsgPack, the JSON bytes from the ingress error frame will fail this decode, producing a generic deserialization error instead of the typed Disconnected error that the migration retry manager needs to trigger failover replay.

Suggested change
let resp_bytes = serde_json::to_vec(&resp_wrapper)
.expect("fatal error: invalid response object - this should never happen");
let resp_bytes = payload_codec
.encode(&resp_wrapper)
.expect("fatal error: invalid request-plane response object");
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread lib/llm/src/migration.rs
Comment on lines +89 to +105
let mut current: Option<&(dyn StdError + 'static)> = Some(err);
while let Some(e) = current {
let message = e.to_string();
let message = message.to_ascii_lowercase();
if message.contains("no instances found for endpoint")
|| message.contains("no instances in selected device group for endpoint")
|| message.contains("connection refused")
|| message.contains("connection reset by peer")
|| message.contains("connection aborted")
|| message.contains("worker disconnected before response stream was established")
{
return true;
}
current = e.source();
}

false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 String-based error matching in is_migratable is brittle

The new string-matching path in is_migratable (lib/llm/src/migration.rs:89-105) matches error messages by substring (e.g., "no instances found for endpoint", "connection refused", "connection reset by peer"). These patterns are inherently fragile — if the upstream library changes the error wording, migration will silently stop working for those error cases. The code already has a typed DynamoError path via error::match_error_chain; these string matches are a pragmatic workaround for errors that haven't been typed yet. Similarly is_failover_backend_cancel_message (lib/llm/src/migration.rs:108-120) matches "cancellederror", "backendcancelled", and "event loop is closed" by substring. Consider tracking these as tech debt to convert to typed errors.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread lib/llm/src/migration.rs
Comment on lines +260 to 287
fn observed_failover_at() -> &'static Mutex<Option<Instant>> {
static OBSERVED_AT: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
OBSERVED_AT.get_or_init(|| Mutex::new(None))
}

fn mark_failover_observed() {
if migration_first_chunk_timeout().is_zero() && migration_recent_failover_window().is_zero() {
return;
}
if let Ok(mut observed_at) = observed_failover_at().lock() {
*observed_at = Some(Instant::now());
}
}

pub(crate) fn observed_failover_elapsed() -> Option<Duration> {
let Ok(observed_at) = observed_failover_at().lock() else {
return None;
};
observed_at.as_ref().map(Instant::elapsed)
}

fn recently_observed_failover() -> bool {
let window = migration_recent_failover_window();
if window.is_zero() {
return false;
}
observed_failover_elapsed().is_some_and(|elapsed| elapsed <= window)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Global mutable failover timestamp shared across all request streams

The observed_failover_at() function at lib/llm/src/migration.rs:260 uses a process-global Mutex<Option<Instant>> to track the last failover observation. mark_failover_observed() is called from any RetryManager instance's next() method whenever a migratable or failover-cancel error is seen. This means a failover event from one model/endpoint influences the retry behavior (via recently_observed_failover()) of ALL concurrent requests to ALL models. In a multi-model frontend, a failover on model A could cause model B requests to enter the failover replay path unnecessarily. This is likely acceptable for single-model deployments but could cause unexpected retries in multi-tenant setups.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


fn request_timeout_from_value(value: Option<&str>) -> Duration {
value
.and_then(|raw| raw.parse::<u64>().ok())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 New NATS request timeout defaults to 2 seconds which may be aggressive

The new NATS ack timeout (lib/runtime/src/pipeline/network/egress/nats_client.rs:29, DEFAULT_NATS_REQUEST_TIMEOUT_MS = 2000) wraps the existing request_with_headers call with a 2-second tokio::time::timeout. The previous code used the async-nats default (10 seconds). Under heavy load or network latency, 2 seconds may be too short for the request-plane ack (which is just the routing acknowledgment, not the full generation). A timeout here produces a CannotConnect error which triggers migration retries — potentially amplifying load during congestion. The value is configurable via DYN_NATS_REQUEST_TIMEOUT_MS, but the 5x reduction in default may surprise existing deployments.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread lib/llm/src/migration.rs
Comment on lines +1452 to +1463
let mut retry_manager = RetryManager::build(
ctx,
BTreeMap::new(),
request,
next_generate,
1,
None,
Arc::new(TEST_MODEL.to_string()),
metrics.clone(),
)
.await
.expect("Failed to build RetryManager");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Test calls to RetryManager::build missing session_affinity argument would fail at compile time

Several new test functions (test_retry_manager_empty_first_stream_migration at line 1452, test_retry_manager_empty_stream_retries_after_context_stop at line 1497, test_retry_manager_does_not_replay_when_output_budget_exhausted at line 1606) call RetryManager::build with 8 positional arguments, but the method signature requires 9 (the 9th being session_affinity: Option<SessionAffinityId>). This would be caught by the Rust compiler and is not reported as a bug per review guidelines, but it indicates these tests may not have been compiled in CI yet.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@hutm hutm force-pushed the review/gms-v2-latehost-06-runtime-failover-replay branch from d52bf50 to 096c478 Compare June 29, 2026 17:45
@hutm hutm changed the title [GMS 01/18] Runtime failover replay for restartable workers feat(runtime): add failover replay [GMS 01/18] Jun 29, 2026
@github-actions github-actions Bot added the feat label Jun 29, 2026
@hutm

hutm commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

CI follow-up: moved the three RetryManager::build test compatibility arguments into this first commit, removed an unused header constant, added DCO sign-off to every commit, and changed all PR titles to conventional-commit form. Position 1 now passes cargo check -p dynamo-llm --all-targets locally; fresh GitHub checks are running.

@hutm hutm force-pushed the review/gms-v2-latehost-06-runtime-failover-replay branch from 096c478 to 7eda2c0 Compare June 29, 2026 17:57
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR adds a Bulwark gateway endpoint mode to the LLM frontend (Python config, Rust dynamic backend discovery, readiness monitoring, HTTP failover waiting), overhauls migration/failover retry logic with concurrency controls, unifies discovery logical instance ID resolution across kube and kv-store backends, adds GMS KV placement metadata structs, hardens runtime transport (NATS timeout, empty-stream disconnect propagation, KV watch cleanup), adds a graceful-shutdown fast-exit path, and improves ManagedProcess zombie-aware teardown.

Changes

Bulwark Gateway, Failover, and Runtime Reliability

Layer / File(s) Summary
Discovery logical instance ID resolution
lib/runtime/src/discovery/mod.rs, lib/runtime/src/config/environment_names.rs, lib/runtime/src/discovery/kube/utils.rs, lib/runtime/src/discovery/kube.rs, lib/runtime/src/discovery/kube/daemon.rs, lib/runtime/src/discovery/kv_store.rs, lib/runtime/src/distributed.rs
Introduces resolve_logical_instance_id with DYN_DISCOVERY_LOGICAL_INSTANCE_ID/DYN_DISCOVERY_LOGICAL_INSTANCE_KEY (supporting $(ENV) expansion) and shared hash_logical_instance_key/DISCOVERY_INSTANCE_ID_MASK constants. Wires logical ID into kube and kv-store constructors, replaces ad-hoc DefaultHasher hashing in kube utils, makes kube watch emit Added for both new and updated instances, and makes kube daemon debounce configurable via DYN_KUBE_DISCOVERY_DEBOUNCE_MS.
GMS KV placement metadata structs and runtime config
lib/llm/src/protocols/common/preprocessor.rs, lib/llm/src/local_model/runtime_config.rs
Adds GmsMemoryRegion, GmsBlockDescriptor, GmsPlacementInfo serde structs for disaggregated KV transfer metadata and extends PreprocessedRequest with optional gms_placement. Adds GmsRuntimeConfig struct and ModelRuntimeConfig accessors for GMS control/daemon-socket metadata.
NATS timeout, empty-stream disconnect propagation, KV watch cleanup
lib/runtime/src/pipeline/network/egress/nats_client.rs, lib/runtime/src/pipeline/network/egress/addressed_router.rs, lib/runtime/src/pipeline/network/ingress/push_handler.rs, lib/runtime/src/storage/kv.rs, lib/runtime/src/pipeline/network/egress/push_router.rs
Adds configurable DYN_NATS_REQUEST_TIMEOUT_MS timeout to NatsRequestClient.send_request with separate timeout/error metric labels. addressed_router and push_handler both detect and forward a Disconnected DynamoError when a stream closes before its first frame. KV watch task exits cleanly when the receiver is dropped. Push router removal watcher breaks on cancellation without spurious warnings.
Bulwark gateway frontend config and dispatch
components/src/dynamo/frontend/frontend_args.py, components/src/dynamo/frontend/main.py
Adds bulwark_gateway_endpoint to FrontendConfig with dyn:// format validation, namespace requirements, and incompatible mode rejection; wires --bulwark-gateway-endpoint CLI arg with DYN_BULWARK_GATEWAY_ENDPOINT. async_main conditionally preserves DYN_SYSTEM_PORT and dispatches to run_input via the new gateway branch.
Bulwark gateway dynamic endpoint (Rust)
lib/llm/src/entrypoint/input/endpoint.rs
Replaces the unreachable EngineConfig::Dynamic arm with full implementation: namespace validation, wait_for_gateway_backend discovery, KV chooser and pipeline construction, gateway_public_card derivation, public card registration, and spawn_gateway_backend_readiness_monitor with configurable grace period (DYN_BULWARK_GATEWAY_NOTREADY_GRACE_MS).
HTTP endpoint failover waiting
lib/llm/src/http/service/service_v2.rs, lib/llm/src/http/service/openai.rs
Adds wait_for_failover polling loop (DYN_HTTP_MODEL_FAILOVER_WAIT_MS) scoped to Chat/Completion/Responses endpoints. Per-route middleware awaits endpoint_enabled_or_wait. All four openai.rs handler paths use new failover-wait engine lookup helpers treating ModelNotFound/ModelUnavailable as transient.
Migration and failover retry overhaul
lib/llm/src/migration.rs
Extends RetryManager with output tracking, per-stream failover replay flags, and concurrency semaphores. is_migratable gains chain-walking and string-based heuristics. Adds failover-backend-cancel detection and replayable-cancel logic. new_stream recreation loop gains a failover wait deadline and periodic polling. Tests cover empty-first-stream migration, budget exhaustion, and new error classifications.
Graceful shutdown fast-exit and OTel tracing helper
components/src/dynamo/common/utils/graceful_shutdown.py, components/src/dynamo/common/utils/otel_tracing.py, components/src/dynamo/common/utils/tests/test_graceful_shutdown.py
Adds DYN_GMS_FAILOVER_FAST_EXIT_ON_SIGTERM-controlled fast-exit path (is_shutdown_in_progress, fast_failover_exit_enabled, _fast_exit_after_failover_unregister) invoked after endpoint unregistration, bypassing grace period and runtime.shutdown(). Adds build_trace_headers OTel compatibility helper. Tests harden sys.modules stubbing and cover new shutdown state and execution order.
Bulwark shadow cutover end-to-end test
lib/runtime/tests/bulwark_cutover.rs
New integration test issues high-load routed requests through a phased primary→shadow cutover, measures TTFT and inter-token latency p95 regressions, and asserts post-cutover traffic comes exclusively from the shadow worker.

ManagedProcess zombie-aware teardown

Layer / File(s) Summary
Zombie-aware process group teardown and parent-first SIGTERM
tests/utils/managed_process.py, tests/utils/test_managed_process_teardown.py
Adds terminate_parent_only_first and graceful_shutdown_timeout fields to ManagedProcess. Introduces _live_process_groups_for_pids to identify zombie-only groups. SIGTERM/SIGKILL loops skip zombie-only groups; optional parent-first SIGTERM polls for parent exit before narrowing the PGID set. Tests validate parent cleanup markers and assert no spurious "child process groups are still live" warning.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~150 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning It omits the required template sections and the required Related Issues section. Reformat the PR using the repo template with Overview, Details, Where should reviewer start?, and Related Issues, and include the required issue linkage or no-issue confirmation.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 81.63% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and matches the main runtime failover replay change.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
components/src/dynamo/frontend/main.py (1)

178-195: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the saved DYN_SYSTEM_PORT value for the warning.

Line 179 removes the env var before this check, so Line 195 never warns for normal frontend mode. Check frontend_system_port instead.

Proposed fix
-    if os.environ.get("DYN_SYSTEM_PORT") and not config.bulwark_gateway_endpoint:
+    if frontend_system_port and not config.bulwark_gateway_endpoint:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/src/dynamo/frontend/main.py` around lines 178 - 195, The warning
check in the frontend startup flow is using os.environ after DYN_SYSTEM_PORT has
already been removed, so it will miss the normal-frontend warning path. In the
main startup logic around frontend_system_port and parse_args, change the
warning condition to read the saved frontend_system_port value instead of
re-checking os.environ, while keeping the gateway-endpoint restore behavior
unchanged.
🧹 Nitpick comments (1)
tests/utils/managed_process.py (1)

586-598: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the terminate() exception handler. Keep ProcessLookupError, but change the blanket except Exception to except OSError so non-OS bugs still surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/managed_process.py` around lines 586 - 598, The exception
handling around managed process termination is too broad in the `terminate()`
path. Keep the existing `ProcessLookupError` handling in
`ManagedProcess.terminate`, but narrow the generic `except Exception as e` block
to `except OSError` so only OS-level failures are swallowed and unrelated bugs
still fail visibly. Update the logger warning in that same
`self.proc.terminate()` try/except block accordingly.

Sources: Coding guidelines, Path instructions, Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/src/dynamo/common/utils/tests/test_graceful_shutdown.py`:
- Around line 334-351: The shutdown state is leaking between tests because
graceful_shutdown_with_discovery() leaves the module-global
_gs._shutdown_started set, making later assertions in is_shutdown_in_progress()
order-dependent. Add a test fixture in this test module to reset/cleanup the
global shutdown flag before and after each test, and apply it to the
shutdown-related tests (including
test_shutdown_in_progress_reflects_active_shutdown and the other affected cases)
so each test starts with isolated _gs state.

In `@lib/llm/src/entrypoint/input/endpoint.rs`:
- Around line 417-464: The readiness monitor in the private backend watch path
currently exits permanently when the discovery stream ends, leaving the gateway
stuck NotReady after a transient disconnect. Update the loop around
`distributed_runtime.discovery().list_and_watch(...)`,
`discovery_stream.next()`, and `pending_notready_deadline` so it re-establishes
the watch with cancel-aware backoff instead of breaking on end-of-stream,
following the same reconnect pattern used by the existing runtime watcher logic.
Keep `set_gateway_health` transitions and `public_endpoint_id` logging intact
while ensuring the task stays alive and re-subscribes after failures.

In `@lib/llm/src/http/service/service_v2.rs`:
- Around line 126-141: The endpoint_enabled_or_wait logic in
service_v2::endpoint_enabled_or_wait is treating config-disabled routes as
eligible for failover waiting because it falls through to wait_for_failover when
state.flags.get(&endpoint_type) is false. Change the gating so statically
disabled endpoints return false immediately, and only invoke wait_for_failover
for endpoints that are explicitly in a failover/readiness state; use the
existing endpoint_type check and state.flags lookup to distinguish
config-disabled from transiently unavailable routes.

In `@lib/llm/src/local_model/runtime_config.rs`:
- Around line 441-464: The GMS runtime config accessors are swallowing
deserialization failures by turning them into None, which lets the setters
overwrite malformed runtime metadata with defaults. Update gms_runtime_config to
propagate errors instead of using ok().flatten(), and adjust
set_gms_placement_enabled, set_gms_control_enabled, and set_gms_daemon_socket to
use the fallible get_engine_specific path so corrupt GMS runtime_data fails fast
rather than being silently replaced.

In `@lib/llm/src/migration.rs`:
- Around line 846-849: The retry-exhausted early return in
`MigrationModel::handle_stream_output` and the matching path near the later
stream handler are skipping `stop_conditions.max_tokens` accounting, so
`output_budget_exhausted()` never sees the budget hit zero. Update both paths to
keep decrementing or otherwise finalize the output budget even when
`self.retries_left == 0`, while still preventing further retries, and ensure the
stream output bookkeeping stays active after the retry budget is exhausted.

In `@lib/runtime/src/config/environment_names.rs`:
- Around line 591-598: The duplicate-name coverage in
test_no_duplicate_env_var_names is missing the new discovery constants, so
update the test to include DYN_KUBE_DISCOVERY_DEBOUNCE_MS,
DYN_DISCOVERY_LOGICAL_INSTANCE_ID, and DYN_DISCOVERY_LOGICAL_INSTANCE_KEY
alongside the existing discovery env vars. Keep the change in the
environment_names test module and use the same symbol names so future duplicate
public constants are caught automatically.

In `@lib/runtime/src/discovery/mod.rs`:
- Around line 30-34: The discovery ID helper hash_logical_instance_key currently
uses DefaultHasher, which is not a stable contract for persisted or distributed
identities. Replace it with an explicit stable hashing approach inside
hash_logical_instance_key, and make sure the masking with
DISCOVERY_INSTANCE_ID_MASK still applies to the final value. Also add a
fixed-vector test for this helper in the discovery module so the expected ID
output is pinned and won’t drift across Rust versions.

In `@lib/runtime/src/pipeline/network/ingress/push_handler.rs`:
- Around line 230-235: The synthetic error response in the empty-stream path is
serialized with serde_json instead of the negotiated payload codec, so fix the
branch that builds NetworkStreamWrapper in push_handler to encode the error
frame using payload_codec the same way the normal and final frames do. Keep the
response shape the same, but route the U::from_err(err) wrapper through the
codec selected for the stream so non-JSON peers can decode the Disconnected
signal consistently.

In `@tests/utils/managed_process.py`:
- Around line 652-659: The SIGTERM/SIGKILL teardown logic in managed_process.py
is incorrectly treating a PGID as finished when only the snapshotted PIDs are
zombie/dead, which can miss live children forked later in the same group. Update
the PGID checks in the shutdown flow around the existing
_live_process_groups_for_pids usage so a group is skipped only when the process
group itself has no live members, not just because pgid_to_pids contains
zombie-only PIDs. Apply the same helper-based live-group check in both the
initial wait loop and the SIGKILL loop, using the existing sigtermed_pgids,
pgid_to_pids, and _live_process_groups_for_pids symbols.

In `@tests/utils/test_managed_process_teardown.py`:
- Around line 362-457: The teardown tests in TestParentOnlyFirstTeardown rely on
polling and sleep loops, so add an explicit pytest timeout marker to these new
test methods to prevent them from hanging indefinitely. Apply
`@pytest.mark.timeout`(...) consistently to both
test_parent_only_signal_lets_parent_cleanup_child and
test_parent_only_ignores_zombie_child_process_group, using the same style as
other timeout-covered tests in this module.
- Line 405: The two child_pid_file reads currently use open(...).read() without
managing the file handle; update both reads to use context managers so the file
is always closed properly. Locate the reads around child_pid and child_pid_file
and wrap each open call in a with block, keeping the existing int(...) parsing
and behavior unchanged.

---

Outside diff comments:
In `@components/src/dynamo/frontend/main.py`:
- Around line 178-195: The warning check in the frontend startup flow is using
os.environ after DYN_SYSTEM_PORT has already been removed, so it will miss the
normal-frontend warning path. In the main startup logic around
frontend_system_port and parse_args, change the warning condition to read the
saved frontend_system_port value instead of re-checking os.environ, while
keeping the gateway-endpoint restore behavior unchanged.

---

Nitpick comments:
In `@tests/utils/managed_process.py`:
- Around line 586-598: The exception handling around managed process termination
is too broad in the `terminate()` path. Keep the existing `ProcessLookupError`
handling in `ManagedProcess.terminate`, but narrow the generic `except Exception
as e` block to `except OSError` so only OS-level failures are swallowed and
unrelated bugs still fail visibly. Update the logger warning in that same
`self.proc.terminate()` try/except block accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 62bb3112-a31c-4da4-8428-7f9a93741149

📥 Commits

Reviewing files that changed from the base of the PR and between 1e05725 and 096c478.

📒 Files selected for processing (26)
  • components/src/dynamo/common/utils/graceful_shutdown.py
  • components/src/dynamo/common/utils/otel_tracing.py
  • components/src/dynamo/common/utils/tests/test_graceful_shutdown.py
  • components/src/dynamo/frontend/frontend_args.py
  • components/src/dynamo/frontend/main.py
  • lib/llm/src/entrypoint/input/endpoint.rs
  • lib/llm/src/http/service/openai.rs
  • lib/llm/src/http/service/service_v2.rs
  • lib/llm/src/local_model/runtime_config.rs
  • lib/llm/src/migration.rs
  • lib/llm/src/protocols/common/preprocessor.rs
  • lib/runtime/src/config/environment_names.rs
  • lib/runtime/src/discovery/kube.rs
  • lib/runtime/src/discovery/kube/daemon.rs
  • lib/runtime/src/discovery/kube/utils.rs
  • lib/runtime/src/discovery/kv_store.rs
  • lib/runtime/src/discovery/mod.rs
  • lib/runtime/src/distributed.rs
  • lib/runtime/src/pipeline/network/egress/addressed_router.rs
  • lib/runtime/src/pipeline/network/egress/nats_client.rs
  • lib/runtime/src/pipeline/network/egress/push_router.rs
  • lib/runtime/src/pipeline/network/ingress/push_handler.rs
  • lib/runtime/src/storage/kv.rs
  • lib/runtime/tests/bulwark_cutover.rs
  • tests/utils/managed_process.py
  • tests/utils/test_managed_process_teardown.py

Comment on lines +334 to +351
def test_shutdown_in_progress_reflects_active_shutdown():
"""The shutdown-in-progress flag is visible before shutdown_event is set."""
assert is_shutdown_in_progress() is False

async def _run():
mock_runtime = MagicMock()
mock_endpoint = AsyncMock()
mock_endpoint.unregister_endpoint_instance = AsyncMock(return_value=None)
await graceful_shutdown_with_discovery(
runtime=mock_runtime,
endpoints=[mock_endpoint],
shutdown_event=None,
grace_period_s=0,
)

asyncio.run(_run())

assert is_shutdown_in_progress() is True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset the shutdown flag between tests.

These tests leave _gs._shutdown_started set after calling graceful_shutdown_with_discovery(), so later tests can no-op and Line 336 can fail if an earlier test already initiated shutdown. Add a fixture to isolate this module-global state. As per path instructions, tests should avoid shared mutable module state; based on learnings, module-level mutable test state causes order-dependent failures.

Proposed fix
+@pytest.fixture(autouse=True)
+def _reset_shutdown_started(monkeypatch):
+    monkeypatch.setattr(_gs, "_shutdown_started", asyncio.Event())
+
+
 def test_shutdown_in_progress_reflects_active_shutdown():

Also applies to: 367-403

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/src/dynamo/common/utils/tests/test_graceful_shutdown.py` around
lines 334 - 351, The shutdown state is leaking between tests because
graceful_shutdown_with_discovery() leaves the module-global
_gs._shutdown_started set, making later assertions in is_shutdown_in_progress()
order-dependent. Add a test fixture in this test module to reset/cleanup the
global shutdown flag before and after each test, and apply it to the
shutdown-related tests (including
test_shutdown_in_progress_reflects_active_shutdown and the other affected cases)
so each test starts with isolated _gs state.

Sources: Path instructions, Learnings

Comment on lines +417 to +464
let discovery_stream = match distributed_runtime
.discovery()
.list_and_watch(DiscoveryQuery::AllModels, Some(cancel_token.clone()))
.await
{
Ok(stream) => stream,
Err(err) => {
tracing::error!(
%err,
public_endpoint = %public_endpoint_id,
"Bulwark gateway failed to start private backend readiness monitor"
);
set_gateway_health(&health_runtime, HealthStatus::NotReady, 0);
return;
}
};
tokio::pin!(discovery_stream);

let notready_grace = gateway_notready_grace();
tracing::info!(
public_endpoint = %public_endpoint_id,
notready_grace_ms = notready_grace.as_millis(),
"Bulwark gateway readiness monitor configured"
);

let mut active_backends: HashSet<DiscoveryInstanceId> = HashSet::new();
let mut last_ready = false;
let mut pending_notready_deadline: Option<Instant> = None;
set_gateway_health(&health_runtime, HealthStatus::NotReady, 0);

loop {
let pending_notready = async {
match pending_notready_deadline {
Some(deadline) => sleep_until(deadline).await,
None => pending::<()>().await,
}
};

tokio::select! {
event = discovery_stream.next() => {
let Some(event) = event else {
tracing::warn!(
public_endpoint = %public_endpoint_id,
"Bulwark gateway private backend readiness stream ended"
);
set_gateway_health(&health_runtime, HealthStatus::NotReady, 0);
break;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Reconnect the readiness watch instead of exiting permanently.

If list_and_watch succeeds once but the stream later ends, this task sets the gateway NotReady and breaks forever. A transient discovery-watch disconnect would keep the gateway unhealthy even after private backends reappear; wrap the watch in a reconnect loop with cancel-aware backoff, like the existing runtime watcher pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/llm/src/entrypoint/input/endpoint.rs` around lines 417 - 464, The
readiness monitor in the private backend watch path currently exits permanently
when the discovery stream ends, leaving the gateway stuck NotReady after a
transient disconnect. Update the loop around
`distributed_runtime.discovery().list_and_watch(...)`,
`discovery_stream.next()`, and `pending_notready_deadline` so it re-establishes
the watch with cancel-aware backoff instead of breaking on end-of-stream,
following the same reconnect pattern used by the existing runtime watcher logic.
Keep `set_gateway_health` transitions and `public_endpoint_id` logging intact
while ensuring the task stays alive and re-subscribes after failures.

Comment on lines +126 to +141
async fn endpoint_enabled_or_wait(state: &State, endpoint_type: EndpointType) -> bool {
if state.flags.get(&endpoint_type) {
return true;
}
if !should_wait_for_endpoint_failover(endpoint_type) {
return false;
}

wait_for_failover(
"endpoint",
endpoint_type.as_str(),
|| state.flags.get(&endpoint_type).then_some(()).ok_or(()),
|_| true,
)
.await
.is_ok()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not wait on statically disabled endpoint flags.

state.flags is initialized from config in build(), so a deliberately disabled Chat/Completion/Responses route now waits up to DYN_HTTP_MODEL_FAILOVER_WAIT_MS before returning 404. Gate waiting on an explicit failover/readiness signal, or keep config-disabled endpoints as immediate 404s.

Also applies to: 1282-1282

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/llm/src/http/service/service_v2.rs` around lines 126 - 141, The
endpoint_enabled_or_wait logic in service_v2::endpoint_enabled_or_wait is
treating config-disabled routes as eligible for failover waiting because it
falls through to wait_for_failover when state.flags.get(&endpoint_type) is
false. Change the gating so statically disabled endpoints return false
immediately, and only invoke wait_for_failover for endpoints that are explicitly
in a failover/readiness state; use the existing endpoint_type check and
state.flags lookup to distinguish config-disabled from transiently unavailable
routes.

Comment on lines +441 to +464
pub fn set_gms_placement_enabled(&mut self) -> anyhow::Result<()> {
self.set_engine_specific(GMS_RUNTIME_CONFIG_KEY, {
let mut config = self.gms_runtime_config().unwrap_or_default();
config.control_enabled = true;
config
})
}

pub fn set_gms_control_enabled(&mut self) -> anyhow::Result<()> {
self.set_gms_placement_enabled()
}

pub fn set_gms_daemon_socket(&mut self, daemon_socket: String) -> anyhow::Result<()> {
let mut config = self.gms_runtime_config().unwrap_or_default();
config.control_enabled = true;
config.daemon_socket = Some(daemon_socket);
self.set_engine_specific(GMS_RUNTIME_CONFIG_KEY, config)
}

pub fn gms_runtime_config(&self) -> Option<GmsRuntimeConfig> {
self.get_engine_specific(GMS_RUNTIME_CONFIG_KEY)
.ok()
.flatten()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Propagate malformed GMS runtime metadata instead of treating it as absent.

gms_runtime_config().ok().flatten() hides deserialization errors, and the setters then overwrite bad runtime_data["gms"] with defaults. Return anyhow::Result<Option<GmsRuntimeConfig>> or use get_engine_specific(...)? in setters so corrupt runtime metadata fails fast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/llm/src/local_model/runtime_config.rs` around lines 441 - 464, The GMS
runtime config accessors are swallowing deserialization failures by turning them
into None, which lets the setters overwrite malformed runtime metadata with
defaults. Update gms_runtime_config to propagate errors instead of using
ok().flatten(), and adjust set_gms_placement_enabled, set_gms_control_enabled,
and set_gms_daemon_socket to use the fallible get_engine_specific path so
corrupt GMS runtime_data fails fast rather than being silently replaced.

Comment thread lib/llm/src/migration.rs
Comment on lines +846 to +849
self.current_stream_has_output = true;
if self.retries_left == 0 {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep output-budget accounting active after retry budget is exhausted.

Line 847 returns before decrementing stop_conditions.max_tokens, but output_budget_exhausted() relies on that field. With migration_limit = 0 or on the final attempt, a backend can emit all requested tokens and then send a disconnect error, and this path will still report the error because the budget never reaches Some(0).

Proposed adjustment
         self.current_stream_has_output = true;
-        if self.retries_left == 0 {
-            return;
-        }
+        let token_ids = llm_engine_output.token_ids();
+        if let Some(max_tokens) = self.request.stop_conditions.max_tokens {
+            self.request.stop_conditions.max_tokens =
+                Some(max_tokens.saturating_sub(token_ids.len() as u32));
+        }
+        if self.retries_left == 0 {
+            return;
+        }
         // Capture the worker's engine.generate span pointer so a future
         // migration retry can render an OTel Link back to it. The adapter
         // stamps this on the first non-empty chunk; subsequent chunks may
         // also carry it. Keep the most-recently-seen value.
         if let Some(link) = llm_engine_output.worker_trace_link() {
             self.last_worker_link = Some(link.clone());
         }
-        let token_ids = llm_engine_output.token_ids();
         if self.exceed_max_seq_len(token_ids.len() as u32) {
             return;
         }
-        if let Some(max_tokens) = self.request.stop_conditions.max_tokens {
-            self.request.stop_conditions.max_tokens =
-                Some(max_tokens.saturating_sub(token_ids.len() as u32));
-        }

Also applies to: 870-872

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/llm/src/migration.rs` around lines 846 - 849, The retry-exhausted early
return in `MigrationModel::handle_stream_output` and the matching path near the
later stream handler are skipping `stop_conditions.max_tokens` accounting, so
`output_budget_exhausted()` never sees the budget hit zero. Update both paths to
keep decrementing or otherwise finalize the output budget even when
`self.retries_left == 0`, while still preventing further retries, and ensure the
stream output bookkeeping stays active after the retry budget is exhausted.

Comment on lines +30 to +34
pub(crate) fn hash_logical_instance_key(key: &str) -> u64 {
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
hasher.finish() & DISCOVERY_INSTANCE_ID_MASK
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether discovery instance IDs have fixed-vector tests and locate all DefaultHasher identity uses.
rg -n -C3 'DefaultHasher|hash_logical_instance_key|DISCOVERY_INSTANCE_ID_MASK' lib/runtime/src

Repository: ai-dynamo/dynamo

Length of output: 4090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the discovery module and inspect the hashing/tests around it.
wc -l lib/runtime/src/discovery/mod.rs lib/runtime/src/discovery/kube/utils.rs
ast-grep outline lib/runtime/src/discovery/mod.rs --view expanded
printf '\n--- tests around hash_logical_instance_key ---\n'
sed -n '930,1035p' lib/runtime/src/discovery/mod.rs
printf '\n--- kube utils around instance_id/hash_pod_name ---\n'
sed -n '50,90p' lib/runtime/src/discovery/kube/utils.rs
printf '\n--- all DefaultHasher uses in runtime discovery ---\n'
rg -n -C2 'DefaultHasher|hash_logical_instance_key|DISCOVERY_INSTANCE_ID_MASK' lib/runtime/src/discovery

Repository: ai-dynamo/dynamo

Length of output: 11763


Use a stable hash for discovery IDs.

DefaultHasher is not a stable contract for persisted/distributed identities, and the current test only checks this helper against itself. Switch to an explicit stable hash and add a fixed-vector test so the ID format doesn’t drift across Rust versions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/runtime/src/discovery/mod.rs` around lines 30 - 34, The discovery ID
helper hash_logical_instance_key currently uses DefaultHasher, which is not a
stable contract for persisted or distributed identities. Replace it with an
explicit stable hashing approach inside hash_logical_instance_key, and make sure
the masking with DISCOVERY_INSTANCE_ID_MASK still applies to the final value.
Also add a fixed-vector test for this helper in the discovery module so the
expected ID output is pinned and won’t drift across Rust versions.

Comment on lines +230 to +235
let resp_wrapper = NetworkStreamWrapper {
data: Some(U::from_err(err)),
complete_final: false,
};
let resp_bytes = serde_json::to_vec(&resp_wrapper)
.expect("fatal error: invalid response object - this should never happen");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Encode the synthetic error with the negotiated payload codec.

Line 234 bypasses payload_codec, unlike the normal and final frames. If this path uses a non-JSON codec, the peer may fail to decode the empty-stream error and miss the intended Disconnected signal.

Proposed fix
-            let resp_bytes = serde_json::to_vec(&resp_wrapper)
-                .expect("fatal error: invalid response object - this should never happen");
+            let resp_bytes = payload_codec
+                .encode(&resp_wrapper)
+                .expect("fatal error: invalid request-plane empty-stream error object");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let resp_wrapper = NetworkStreamWrapper {
data: Some(U::from_err(err)),
complete_final: false,
};
let resp_bytes = serde_json::to_vec(&resp_wrapper)
.expect("fatal error: invalid response object - this should never happen");
let resp_wrapper = NetworkStreamWrapper {
data: Some(U::from_err(err)),
complete_final: false,
};
let resp_bytes = payload_codec
.encode(&resp_wrapper)
.expect("fatal error: invalid request-plane empty-stream error object");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/runtime/src/pipeline/network/ingress/push_handler.rs` around lines 230 -
235, The synthetic error response in the empty-stream path is serialized with
serde_json instead of the negotiated payload codec, so fix the branch that
builds NetworkStreamWrapper in push_handler to encode the error frame using
payload_codec the same way the normal and final frames do. Keep the response
shape the same, but route the U::from_err(err) wrapper through the codec
selected for the stream so non-JSON peers can decode the Disconnected signal
consistently.

Comment on lines +652 to +659
# Check if any signaled group is still alive. Zombie-only
# child groups (common with MPI launch helpers such as orted) do not
# hold resources and cannot be killed, so don't burn the timeout.
still_alive = False
for pgid in sigtermed_pgids:
known_pids = pgid_to_pids.get(pgid, set())
if known_pids and not self._live_process_groups_for_pids(known_pids):
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not skip a PGID solely because snapshotted PIDs are zombie-only.

Lines 657-659 and 675-677 treat known zombie/dead PIDs as proof the process group has no live members. If a snapshotted child forks a same-PGID process during shutdown, that live process is unknown to pgid_to_pids, so teardown can skip both waiting and SIGKILL, leaking the child process.

Proposed direction
+    `@staticmethod`
+    def _process_group_has_live_members(pgid: int, known_pids: set[int]) -> bool:
+        if pgid in ManagedProcess._live_process_groups_for_pids(known_pids):
+            return True
+        try:
+            os.killpg(pgid, 0)
+        except (ProcessLookupError, OSError):
+            return False
+
+        # `killpg(..., 0)` can be true for zombie-only groups. Scan before
+        # deciding the group is safe to skip.
+        for process in psutil.process_iter(["status"]):
+            try:
+                if os.getpgid(process.pid) != pgid:
+                    continue
+                if process.status() != psutil.STATUS_ZOMBIE:
+                    return True
+            except (ProcessLookupError, psutil.NoSuchProcess, OSError):
+                continue
+            except psutil.AccessDenied:
+                return True
+        return False
+
             for pgid in sigtermed_pgids:
-                known_pids = pgid_to_pids.get(pgid, set())
-                if known_pids and not self._live_process_groups_for_pids(known_pids):
+                if not self._process_group_has_live_members(
+                    pgid, pgid_to_pids.get(pgid, set())
+                ):
                     continue

Apply the same helper in the SIGKILL loop.

Also applies to: 675-677

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/managed_process.py` around lines 652 - 659, The SIGTERM/SIGKILL
teardown logic in managed_process.py is incorrectly treating a PGID as finished
when only the snapshotted PIDs are zombie/dead, which can miss live children
forked later in the same group. Update the PGID checks in the shutdown flow
around the existing _live_process_groups_for_pids usage so a group is skipped
only when the process group itself has no live members, not just because
pgid_to_pids contains zombie-only PIDs. Apply the same helper-based live-group
check in both the initial wait loop and the SIGKILL loop, using the existing
sigtermed_pgids, pgid_to_pids, and _live_process_groups_for_pids symbols.

Comment on lines +362 to +457
class TestParentOnlyFirstTeardown:
def test_parent_only_signal_lets_parent_cleanup_child(self, tmp_path):
parent_marker = str(tmp_path / "parent_sigterm")
child_marker = str(tmp_path / "child_cleaned")
ready_file = str(tmp_path / "ready")
child_pid_file = str(tmp_path / "child_pid")
script = "\n".join(
[
"import pathlib, signal, subprocess, sys, time",
f"parent_marker = pathlib.Path({parent_marker!r})",
f"child_marker = pathlib.Path({child_marker!r})",
f"ready = pathlib.Path({ready_file!r})",
f"child_pid_file = pathlib.Path({child_pid_file!r})",
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(300)'])",
"child_pid_file.write_text(str(child.pid))",
"def on_term(*_):",
" parent_marker.touch()",
" child.terminate()",
" child.wait(timeout=5)",
" child_marker.touch()",
" raise SystemExit(0)",
"signal.signal(signal.SIGTERM, on_term)",
"ready.touch()",
"while True:",
" time.sleep(0.1)",
]
)
mp = ManagedProcess(
command=["python3", "-c", script],
timeout=10,
display_output=False,
terminate_all_matching_process_names=False,
terminate_parent_only_first=True,
graceful_shutdown_timeout=5.0,
log_dir=str(tmp_path),
)

child_pid = None
with mp:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and not os.path.exists(ready_file):
time.sleep(0.05)
assert os.path.exists(ready_file)
child_pid = int(open(child_pid_file, encoding="utf-8").read())
assert _pid_alive(child_pid)

assert os.path.exists(parent_marker)
assert os.path.exists(child_marker)
assert child_pid is not None
assert _wait_for_pid_death(child_pid, timeout=5)

def test_parent_only_ignores_zombie_child_process_group(self, tmp_path, caplog):
ready_file = str(tmp_path / "ready")
child_pid_file = str(tmp_path / "child_pid")
script = "\n".join(
[
"import os, pathlib, time",
f"ready = pathlib.Path({ready_file!r})",
f"child_pid_file = pathlib.Path({child_pid_file!r})",
"pid = os.fork()",
"if pid == 0:",
" os.setpgid(0, 0)",
" os._exit(0)",
"child_pid_file.write_text(str(pid))",
"ready.touch()",
"while True:",
" time.sleep(0.1)",
]
)
mp = ManagedProcess(
command=["python3", "-c", script],
timeout=10,
display_output=False,
terminate_all_matching_process_names=False,
terminate_parent_only_first=True,
graceful_shutdown_timeout=0.5,
log_dir=str(tmp_path),
)

with caplog.at_level(logging.WARNING, logger="ManagedProcess"):
with mp:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and not os.path.exists(ready_file):
time.sleep(0.05)
assert os.path.exists(ready_file)
child_pid = int(open(child_pid_file, encoding="utf-8").read())
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
if psutil.Process(child_pid).status() == psutil.STATUS_ZOMBIE:
break
except psutil.NoSuchProcess:
break
time.sleep(0.05)

assert "child process groups are still live" not in caplog.text

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
sed -n '1,90p' tests/utils/test_managed_process_teardown.py
rg -n 'pytestmark|mark\.(timeout|pre_merge|post_merge|nightly|weekly|release|unit|integration|e2e|gpu_[0-9])|time\.sleep|def test_parent_only' tests/utils/test_managed_process_teardown.py

Repository: ai-dynamo/dynamo

Length of output: 3751


Add an explicit timeout to these teardown tests. The module-level pytestmark already covers pre_merge, unit, and gpu_0; the remaining gap is @pytest.mark.timeout(...) for these polling/sleep-heavy tests.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 404-404: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(child_pid_file, encoding="utf-8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)


[warning] 446-446: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(child_pid_file, encoding="utf-8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/test_managed_process_teardown.py` around lines 362 - 457, The
teardown tests in TestParentOnlyFirstTeardown rely on polling and sleep loops,
so add an explicit pytest timeout marker to these new test methods to prevent
them from hanging indefinitely. Apply `@pytest.mark.timeout`(...) consistently to
both test_parent_only_signal_lets_parent_cleanup_child and
test_parent_only_ignores_zombie_child_process_group, using the same style as
other timeout-covered tests in this module.

Sources: Coding guidelines, Path instructions, Learnings

while time.monotonic() < deadline and not os.path.exists(ready_file):
time.sleep(0.05)
assert os.path.exists(ready_file)
child_pid = int(open(child_pid_file, encoding="utf-8").read())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="tests/utils/test_managed_process_teardown.py"
wc -l "$file"
sed -n '380,460p' "$file" | cat -n

Repository: ai-dynamo/dynamo

Length of output: 3817


Wrap both reads in with blocks.
open(...).read() leaves the file handle unmanaged; use a context manager for the two child_pid_file reads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/test_managed_process_teardown.py` at line 405, The two
child_pid_file reads currently use open(...).read() without managing the file
handle; update both reads to use context managers so the file is always closed
properly. Locate the reads around child_pid and child_pid_file and wrap each
open call in a with block, keeping the existing int(...) parsing and behavior
unchanged.

Sources: Coding guidelines, Path instructions

Signed-off-by: mkhadkevich <mkhadkevich@nvidia.com>
@hutm hutm force-pushed the review/gms-v2-latehost-06-runtime-failover-replay branch from 7eda2c0 to faac3c6 Compare July 5, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant