Skip to content

fix(platform-ext): wait for the instance flock before reporting a stop - #987

Merged
SandyChapman merged 1 commit into
mainfrom
fix-stop-instance-lock-race/schapman
Jul 30, 2026
Merged

fix(platform-ext): wait for the instance flock before reporting a stop#987
SandyChapman merged 1 commit into
mainfrom
fix-stop-instance-lock-race/schapman

Conversation

@SandyChapman

@SandyChapman SandyChapman commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

stop_instance returned as soon as the descriptor PID was dead. But a process exiting and its flock being released are not the same instant — the kernel drops the lock while closing fds during teardown, and any process that inherited the fd keeps it held until it is gone too. Callers treat a successful stop as "the scope is free now" and probe with is_instance_alive immediately, so they raced that teardown.

Why now

This is what has been failing test_daemonize_services_spawns_child_that_becomes_ready intermittently on main, with assert not True at the post-stop liveness check:

run commit
30513855110 d47846975 test_daemonize_services_spawns_child_that_becomes_ready
30509586539 bef8f45a2 test_daemonize_services_spawns_child_that_becomes_ready

(The Python integration tests job has been red on several recent main runs. The second recurring failure — the docker-backend observe-wait test — is also fixed here; see below.)

The fix

  • _sweep_orphans escalated to SIGKILL and returned without reaping the survivors, so it could hand back control while a killed child still held the lock. It now waits on the processes it killed, and warns about any that outlive the wait.
  • stop_instance now holds its own post-condition: it polls until the flock is actually free (bounded by _LOCK_RELEASE_TIMEOUT = 5.0) before removing the descriptor. If something outside the sweep still holds it, that is logged as a warning rather than turned into a failure — the PID we targeted did exit, and the old behaviour was to say nothing at all.

Also here: the docker-backend observe-wait flake (folded in from #989)

These two PRs blocked each other, so #989 is closed and its commit lives here.

PR fixes was failing on
this one test_daemonize_services_spawns_child_that_becomes_ready the docker flake — deterministic, 4/4 runs (3.42 / 3.84 / 4.39 / 5.29s)
#989 test_never_deployment_outlives_observe_wait_then_succeeds the daemon-lifecycle flake

Each fixed the other's blocker, so neither could go green alone.

test_never_deployment_outlives_observe_wait_then_succeeds asserts create_deployment returns within observe_timeout + 2.0s, and pre-pulls alpine:3.20 with a comment claiming this keeps the pull out of the timed window. It does not: the backend is built with pull_images=True, and create_deployment pulls unconditionally rather than only when the image is missing locally. The warm-up avoids re-downloading layers, not the registry round-trip.

A fully cached alpine:3.20 pull measures ~1.7s, leaving the test at 2.85s against a 3.0s budget on a fast machine with a warm cache. Building that one backend with pull_images=False moves it to 1.18s:

run 1 run 2 run 3 budget
before 2.86s 2.84s 2.83s 3.0s
after 1.18s 1.18s 1.18s 3.0s

Already validated in CI on #989's run, where the docker test passed and dropped off the slowest-25 durations. Only the helper used by that one test changes; the other three tests in the file keep pull_images=True.

Combining a production fix with an unrelated test-only change in one PR is not a good default, and the added commits are self-contained if you would rather review them separately.

Tests

  • test_stop_instance_releases_lock_held_by_surviving_child — the regression guard. Verified it fails against the old code with the same assert not True / is_instance_alive signature seen in CI, and passes with the fix (5/5 runs).

    The lock holder is deliberately outside the parent's process tree. A first attempt used a SIGTERM-ignoring child inside the tree, which was faithful to the CI shape but passed against the unfixed code locally — the post-SIGKILL window is scheduler-dependent and sub-millisecond on a fast machine. An independent holder makes the window fixed, and models the case the sweep genuinely cannot reach: a lock-inheriting process spawned after the child snapshot was taken.

  • test_wait_for_lock_release_blocks_until_holder_exits / ..._times_out_while_held — unit coverage for the new helper.

  • test_warns_when_child_survives_sigkill — covers the new post-SIGKILL warning branch in _sweep_orphans, which TestSweepOrphans did not reach (raised by CodeRabbit). Nothing survives SIGKILL on demand — the real trigger is a child wedged in uninterruptible sleep — so wait_procs is stubbed while the terminate and kill calls stay real. It asserts wait_procs is called twice, so a single-wait implementation cannot satisfy it: verified that against a deliberately single-wait build, where the warning still fires but the test fails on call_count.

test_daemon_lifecycle.py + test_services_process.py: 77 passed. test_docker_backend.py: 4 passed. ruff check / ruff format clean; lint-python-types and lint-sdk-vendored exit 0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved instance stopping by confirming the scope lock is actually released before reporting success.
    • Enhanced forced-stop cleanup to handle delays after SIGKILL and warn if processes remain unreaped.
    • Added a bounded wait to prevent indefinite polling while confirming lock-release.
  • Tests

    • Added integration tests validating lock-release behavior, timeout handling, and forced-stop correctness when another process holds the lock.
    • Expanded regression coverage for orphan sweeping and warning behavior when children appear alive after SIGKILL.
    • Updated Docker integration timing to account for image pulling happening outside the observe window.

@SandyChapman
SandyChapman requested review from a team as code owners July 30, 2026 14:05
@github-actions github-actions Bot added the fix label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The process stop lifecycle now waits for inherited flock release after orphan cleanup, with timeout and regression tests. SIGKILL cleanup warns about unreaped children. Docker observation timing tests exclude image pulling from the measured window.

Changes

Instance lock lifecycle

Layer / File(s) Summary
SIGKILL orphan reaping
packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py, packages/nemo_platform_ext/tests/cli/commands/test_services_process.py
_sweep_orphans waits for successfully killed processes and warns when any remain unreaped, with coverage for the warning path.
Stop completion lock synchronization
packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py, packages/nemo_platform_ext/tests/local/test_daemon_lifecycle.py
stop_instance waits for flock release before descriptor removal, with bounded timeout behavior and lifecycle tests for held locks.
Docker observe timing setup
plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
The timeout-test backend disables image pulling during the timed observation window, and its execution duration and timing thresholds are adjusted.

Sequence Diagram(s)

sequenceDiagram
  participant stop_instance
  participant _sweep_orphans
  participant InstanceProcess
  participant InstanceFlock
  stop_instance->>_sweep_orphans: reap orphan processes
  _sweep_orphans->>InstanceProcess: SIGKILL tracked children
  _sweep_orphans->>InstanceProcess: wait for killed children
  stop_instance->>InstanceFlock: poll for release
  InstanceProcess-->>InstanceFlock: release inherited flock
  InstanceFlock-->>stop_instance: report lock free
  stop_instance->>stop_instance: remove descriptor and return
Loading

Possibly related PRs

Suggested labels: test

Suggested reviewers: benmccown, tylersbray

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: stop now waits for the instance flock before reporting success.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-stop-instance-lock-race/schapman

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: 1

🧹 Nitpick comments (1)
packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py (1)

692-717: 🩺 Stability & Availability | 🔵 Trivial

Gating and logic look correct.

_wait_for_lock_release is only invoked on the success path (never in the early-return PermissionError/SIGKILL-timeout branches), matching the regression test's expectations. One note: worst case, stop_instance can now block for SIGTERM timeout + SIGKILL wait + up to 2×_sweep_orphans timeout (10s default) + _LOCK_RELEASE_TIMEOUT (5s) — worth keeping in mind for any caller-side timeout budgets (e.g. CLI/RPC callers of stop_instance).

🤖 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 `@packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py` around
lines 692 - 717, Keep the current success-path gating and _wait_for_lock_release
behavior unchanged. Review callers of stop_instance, especially CLI/RPC entry
points, and increase or otherwise align their timeout budgets to accommodate the
possible SIGTERM, SIGKILL, orphan-sweep, and lock-release wait durations.
🤖 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 `@packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py`:
- Around line 598-610: Add a test case to TestSweepOrphans that exercises the
kill_sent path with psutil.wait_procs returning unreaped children, using the
configured timeout and asserting logger.warning reports the remaining child PID
after SIGKILL. Keep the existing escalation behavior and other test cases
unchanged.

---

Nitpick comments:
In `@packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py`:
- Around line 692-717: Keep the current success-path gating and
_wait_for_lock_release behavior unchanged. Review callers of stop_instance,
especially CLI/RPC entry points, and increase or otherwise align their timeout
budgets to accommodate the possible SIGTERM, SIGKILL, orphan-sweep, and
lock-release wait durations.
🪄 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: 1b923dc8-59ee-4c74-a244-3a1d14145d83

📥 Commits

Reviewing files that changed from the base of the PR and between 434c0db and b53b618.

⛔ Files ignored due to path filters (2)
  • sdk/python/nemo-platform/src/nemo_platform/local/process.py is excluded by !sdk/**
  • sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/local/test_daemon_lifecycle.py is excluded by !sdk/**
📒 Files selected for processing (2)
  • packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py
  • packages/nemo_platform_ext/tests/local/test_daemon_lifecycle.py

Comment thread packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 28228/36064 78.3% 62.7%
Integration Tests 16776/34782 48.2% 20.8%

SandyChapman added a commit that referenced this pull request Jul 30, 2026
… window

test_never_deployment_outlives_observe_wait_then_succeeds asserts that
create_deployment returns within observe_timeout + 2.0s. It pre-pulls
alpine:3.20 with a comment saying this keeps an uncached pull out of the
timed window, but that is not what happens: the backend is built with
pull_images=True, and create_deployment pulls unconditionally rather than
only when the image is missing locally. The warm-up avoids re-downloading
layers; it does nothing about the registry round-trip, which still lands
inside the measurement.

A fully cached alpine:3.20 pull measures ~1.7s locally, so the unfixed
test runs at 2.85s against a 3.0s budget on a fast machine with a warm
cache -- 0.15s of margin, all of it hostage to Docker Hub latency. CI has
been tipping over it on main and on unrelated branches:

  run 30554298719 (main)                      4.39s
  run 30550043391 (PR #987)                   3.84s
  run 30549563915 (experimentalist-run-progress) 3.42s

Build the backend with pull_images=False so the test's own pre-pull is
what puts the image on the host, and the timed window covers container
create, start, and the observe wait -- what the assertion is actually
about. Measured 1.18s across three runs afterwards, stable, with 1.82s of
headroom.

This only changes the one helper used by that test; the other three tests
in the file keep the default pull_images=True.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Sandy Chapman <schapman@nvidia.com>
SandyChapman added a commit that referenced this pull request Jul 30, 2026
The sweep now warns when a child outlives the post-SIGKILL wait, but
TestSweepOrphans only reached the escalation path -- the warning branch
itself was uncovered.

Nothing survives SIGKILL on demand. The real trigger is a process wedged
in uninterruptible sleep, which a test cannot arrange, so `wait_procs` is
stubbed to keep reporting the child as alive. The terminate and kill calls
stay real; only the observation is simulated.

Verified the test is a genuine guard: with the `logger.warning` call
removed it fails on the empty caplog, and passes with it restored.

Raised by CodeRabbit on #987.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline 97394dc7bf
git show --format=fuller --find-renames --find-copies 97394dc7bf -- \
  packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py \
  packages/nemo_platform_ext/tests/local/test_daemon_lifecycle.py

ast-grep outline packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py \
  --items all --type function --match 'sweep_orphans'
ast-grep outline packages/nemo_platform_ext/tests/local/test_daemon_lifecycle.py \
  --items all --type class,function --match 'SweepOrphans|sweep_orphans'

Length of output: 1221


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git diff-tree --no-commit-id --name-status -r 97394dc7bf
git show --format= --find-renames --find-copies 97394dc7bf -- \
  ':(glob)**/test_services_process.py'

sed -n '550,640p' packages/nemo_platform_ext/src/nemo_platform_ext/local/process.py

fd -a 'test_services_process\.py$' . -x sh -c '
  printf "\n=== %s ===\n" "$1"
  ast-grep outline "$1" --items all --type class,function --match "SweepOrphans|sweep_orphans"
' sh {}

Length of output: 9343


@SandyChapman: Confirmed. The test drives escalation, sends a real SIGKILL, simulates only the unreaped observation, and asserts the warning. No issue.


✏️ Learnings added
Learnt from: SandyChapman
URL: https://github.com/NVIDIA-NeMo/nemo-platform/pull/987

Timestamp: 2026-07-30T16:28:18.999Z
Learning: In `packages/nemo_platform_ext/tests/cli/commands/test_services_process.py`, `TestSweepOrphans.test_warns_when_child_survives_sigkill` must simulate a post-SIGKILL survivor by stubbing `process_module.psutil.wait_procs`; reliably creating a real process that survives SIGKILL requires uninterruptible sleep and is not testable on demand. The signal calls remain real and the test must assert the warning via `caplog`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@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: 1

🤖 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 `@packages/nemo_platform_ext/tests/cli/commands/test_services_process.py`:
- Around line 871-878: Update the _sweep_orphans test to mock psutil.wait_procs
with a two-item side_effect representing both wait phases, then capture the mock
and assert it was called twice. Preserve the existing killed-process and
warning-log assertions while ensuring the test verifies the post-SIGKILL wait
occurs.
🪄 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: 35eba9ef-a111-4785-8e7c-484417983f28

📥 Commits

Reviewing files that changed from the base of the PR and between 1d66b01 and 97394dc.

⛔ Files ignored due to path filters (1)
  • sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_services_process.py is excluded by !sdk/**
📒 Files selected for processing (1)
  • packages/nemo_platform_ext/tests/cli/commands/test_services_process.py

SandyChapman added a commit that referenced this pull request Jul 30, 2026
The mock returned the same value for every call, so the test passed even
against an implementation that logged after a single wait_procs -- it did
not actually guard the post-SIGKILL wait this PR introduces.

Use a two-item side_effect and assert wait_procs is called twice. Verified
against a single-wait implementation: the warning still fires, but the test
now fails on call_count.

Raised by CodeRabbit on #987.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Sandy Chapman <schapman@nvidia.com>
`stop_instance` returned as soon as the descriptor PID was dead, but a process
exiting and its flock being released are not the same instant — the kernel drops
the lock while closing fds during teardown, and any process that inherited the fd
keeps it held until it is gone too. Callers treat a successful stop as "the scope
is free now" and probe with `is_instance_alive` immediately, so they raced that
teardown.

This is what has been failing `test_daemonize_services_spawns_child_that_becomes_ready`
intermittently on main (`assert not True` at the post-stop liveness check); it went
red on d478469 and bef8f45 among others.

Two fixes:

- `_sweep_orphans` escalated to SIGKILL and returned without reaping the survivors,
  so it could hand back control while a killed child still held the lock. It now
  waits on the processes it killed and warns about any that outlive the wait.
- `stop_instance` now holds its own post-condition: it polls until the flock is
  actually free (bounded by `_LOCK_RELEASE_TIMEOUT`) before removing the descriptor,
  and warns rather than failing if something outside the sweep still holds it.

The new integration test fails against the old code with the same `assert not True`
signature seen in CI, and passes with the fix. Unit tests cover `_wait_for_lock_release`
directly and both wait phases of `_sweep_orphans`.

Also unflakes the second integration test that has been red on main,
`test_never_deployment_outlives_observe_wait_then_succeeds`. Two problems, neither
of them timing jitter:

- `create_deployment` pulls the image unconditionally rather than only when it is
  missing, so a warm cache did not keep Docker Hub latency out of the timed window.
  The backend is now built with `pull_images=False` and the test pre-pulls.
- The assertion could not distinguish the two outcomes it was meant to separate.
  With a 5s job and a 1s observe wait, "returned during the observe wait" and
  "blocked until the job exited" were only 4s apart, less than the cost of
  container create/start on a contended CI daemon (~3s observed). The job now runs
  20s, so blocking for it is unmistakable, and the bound is deliberately loose —
  the STARTING assertions are what pin the real behaviour.

Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@SandyChapman
SandyChapman force-pushed the fix-stop-instance-lock-race/schapman branch from c5af9fe to c8244c8 Compare July 30, 2026 17:00

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

LGTM

@SandyChapman
SandyChapman added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit 9b0f9ad Jul 30, 2026
56 checks passed
@SandyChapman
SandyChapman deleted the fix-stop-instance-lock-race/schapman branch July 30, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants