Skip to content

Stop prod skysim aborting every twelve minutes on a mission path check - #11

Merged
yalexx merged 2 commits into
mainfrom
fix/broadphase-node-exhaustion
Jul 29, 2026
Merged

Stop prod skysim aborting every twelve minutes on a mission path check#11
yalexx merged 2 commits into
mainfrom
fix/broadphase-node-exhaustion

Conversation

@yalexx

@yalexx yalexx commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #10.

skyhub-prod-skysim-service has been restarting in a loop, each task ending exitCode 139. Reproduced against the prod image with its real 842-tile set: it dies on the 187th /mission/check, deterministically, both in the container and locally.

Not what it looked like

The backtrace is not a race or a use-after-free:

JPH::QuadTree::AllocateNode           "QuadTree: Out of nodes!" -> std::abort()
JPH::QuadTree::AddBodiesFinalize
JPH::BodyInterface::CreateAndAddBody
skysim::core::create_tile_body        world.cpp:256
skysim::core::World::add_static_tile  world.cpp:285
App::drain_commands                   main.cpp:608

Jolt's broadphase quadtree allocates nodes from a fixed pool and does not return them when a body is removed — they are reclaimed only when the tree is rebuilt, which happens inside PhysicsSystem::Update. These services run --vehicles 0, so the strict barrier is never satisfied, step_world never runs, and Update never happens. Every tile a path check loads leaks nodes until the pool is dry.

Two things made it worse

The eviction was pure churn. stream_tiles() recomputed residency from fleet positions, found an empty fleet, and evicted every tile the path check had just loaded — which the next path check reloaded. That is the +0 -53 line repeating in the logs with no matching +53: there are two call sites and only one of them prints. With no vehicles there is nothing to stream around, so it now leaves residency alone; the set is already bounded by max_resident.

The throttle wasn't throttling. It guarded on tick_index() % 100, and tick_index only advances when the world steps. In a world that never steps it is permanently 0, so "every ~0.125 s" silently meant "every iteration of the loop".

Both call sites now go through apply_tile_plan(), which reclaims the broadphase after a batch. Sharing the path is deliberate — the bookkeeping is not optional and the next caller should not have to remember it.

Verified, not inferred

result
prod image, unmodified dies at request 187
eviction fix only dies at request 195
both fixes survived 1000 path checks

So both halves are load-bearing — stopping the churn alone is not enough.

I initially concluded the opposite from a synthetic probe that drained the tile set to empty each round; optimizing a tree that has just been emptied reclaims nothing, so the probe said OptimizeBroadPhase was useless. It isn't — the probe simply wasn't representative. The numbers above come from the real binary and the real tiles.

The test

Sized by measurement rather than guesswork: 4000 rounds passes in 0.26 s with the fix and aborts after 1.5 s without it. It uses a rolling resident set rather than draining to empty, for the reason above — a drain-to-zero loop exhausts the pool either way and would test nothing.

Worth knowing: it aborts rather than reporting a failure, because Jolt calls std::abort() directly. A regression here looks like a crashed test, not a failed assertion.

Coverage gates hold: lines 85.9%, functions 95.7%, branches 71.7% (up from 71.3%).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added broadphase optimization for reclaiming physics nodes after static tile updates.
    • Improved tile streaming to consistently apply additions, removals, and optimization.
    • Path checks now use the same tile streaming behavior.
  • Bug Fixes

    • Prevented broadphase node exhaustion during repeated tile streaming without physics stepping.
    • Avoided unnecessary tile churn when no vehicles are present.
  • Tests

    • Added regression coverage for repeated static-tile loading and removal scenarios.

… check

skyhub-prod-skysim-service has been restarting in a loop, each task ending
exitCode 139. Reproduced against the prod image with its real tile set: it dies
on the 187th /mission/check, deterministically, in the container and locally.

The backtrace is not a race or a use-after-free:

    JPH::QuadTree::AllocateNode          "QuadTree: Out of nodes!" -> std::abort()
    JPH::QuadTree::AddBodiesFinalize
    JPH::BodyInterface::CreateAndAddBody
    skysim::core::create_tile_body       world.cpp:256
    skysim::core::World::add_static_tile world.cpp:285
    App::drain_commands                  main.cpp:608

Jolt's broadphase quadtree allocates nodes from a fixed pool and does not return
them when a body is removed — they are reclaimed only when the tree is rebuilt,
inside PhysicsSystem::Update. These services run --vehicles 0, so the strict
barrier is never satisfied, step_world never runs, and Update never happens.
Every tile a path check loads therefore leaks nodes until the pool is dry.

Two things made it worse, both fixed here:

stream_tiles() recomputed residency from fleet positions, found an empty fleet,
and evicted every tile the path check had just loaded — which the next path check
then reloaded. That is the +0 -53 line repeating in the logs with no matching
+53: the two call sites are not symmetric and only one of them prints. With no
vehicles there is nothing to stream around, so it now leaves residency alone;
the resident set is already bounded by max_resident.

The throttle guarding that work was `tick_index() % 100`, and tick_index only
advances when the world steps. In a world that never steps it is permanently 0,
so "every ~0.125 s" silently meant "every iteration of the loop".

Both call sites now go through apply_tile_plan(), which reclaims the broadphase
after a batch. Sharing the path is the point: the bookkeeping is not optional and
the next caller should not have to remember it.

Verified end to end, not inferred. Prod image, real 842-tile Plovdiv set:
1000 path checks with the fix versus death at 187 without. The eviction fix alone
is not enough — it still dies at 195 — so both halves are load-bearing.

The regression test is sized by measurement: 4000 rounds passes in 0.26 s with
the fix and aborts after 1.5 s without it. Note it aborts rather than failing,
because Jolt calls std::abort() directly, so a regression shows up as a crashed
test rather than a failed assertion.

Coverage gates hold: lines 85.9%, functions 95.7%, branches 71.7% (up from 71.3%).

Fixes #10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f3a6936a-50c9-48c9-8fea-a96f743cffb0

📥 Commits

Reviewing files that changed from the base of the PR and between ceaf3ce and 6333378.

📒 Files selected for processing (1)
  • src/main.cpp
📝 Walkthrough

Walkthrough

The change adds a public broadphase optimization API, centralizes tile-plan application for streaming and path checks, skips empty-fleet streaming updates, and adds a regression test for repeated static-tile churn without physics stepping.

Changes

Tile streaming broadphase management

Layer / File(s) Summary
Expose broadphase optimization
src/core/world.h, src/core/world.cpp
Adds World::optimize_broadphase() and forwards it to Jolt’s physics system.
Centralize tile plan application
src/main.cpp
Shares tile add/remove bookkeeping across streaming and path checks, optimizes the broadphase after residency changes, and skips streaming updates when no vehicles exist.
Exercise repeated tile churn
tests/test_streamer.cpp
Adds a 4,000-round non-stepping tile streaming test with raycast checks and a final tile-load assertion.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: preventing production aborts during mission path checks.
Linked Issues check ✅ Passed The changes stop empty-fleet tile eviction, route updates through a shared planner, reclaim broadphase nodes, and add a regression test for #10.
Out of Scope Changes check ✅ Passed The changes stay focused on tile streaming, broadphase reclamation, and regression coverage; no unrelated functionality was added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/broadphase-node-exhaustion

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 `@src/main.cpp`:
- Around line 506-510: Update the throttling logic around stream_tiles() to
track the last tick index that was evaluated and return when the current tick
has already been processed, including repeated calls while the world is stalled
at tick 0 or another multiple of 100. Preserve evaluation on the first call for
each tick and retain the existing 100-tick cadence.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 185e6dbd-8087-4fe4-93e4-132ee3c509b9

📥 Commits

Reviewing files that changed from the base of the PR and between 8e32cc1 and ceaf3ce.

📒 Files selected for processing (4)
  • src/core/world.cpp
  • src/core/world.h
  • src/main.cpp
  • tests/test_streamer.cpp

Comment thread src/main.cpp Outdated
…umber

Review caught that the fleet.empty() guard only covers half the problem. A
non-empty fleet whose barrier is stalled — vehicles spawned but their SITL has
not connected yet, which is the normal state for the first few seconds — also
leaves tick_index() frozen. If it happens to be parked on a multiple of 100, the
throttle is off entirely and the strict loop rescans residency every ~50 us, at
roughly 20 kHz.

`tick_index() % 100` reads as "every 100 ticks" but only advances when the world
steps, and this is called whether it stepped or not. Recording which tick was
last evaluated is what turns it into a throttle rather than a coincidence.

Verified unchanged end to end: 1000 path checks against the prod tile set, all 16
tests green, coverage gates hold (lines 85.8%, functions 95.7%, branches 71.4%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yalexx
yalexx merged commit 8251bc2 into main Jul 29, 2026
7 checks passed
@yalexx
yalexx deleted the fix/broadphase-node-exhaustion branch July 29, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

skysim segfaults (exit 139) in a ~12 minute loop on prod; every path check thrashes the tile set

1 participant