Skip to content

fix(floyd-warshall): cycle guard in path reconstruction (hang + OOM) - #12

Merged
apotema merged 1 commit into
mainfrom
fix/fw-path-cycle-guard
Jun 18, 2026
Merged

apotema merged 1 commit into
mainfrom
fix/fw-path-cycle-guard

Conversation

@apotema

@apotema apotema commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Bug

setPathWithMapping/setPathWithMappingUnmanaged walk the next-hop chain from u_node to v_node, appending each node, and only bail on a dead-end (next == INF). If the next matrix is internally inconsistent and contains a cycle that never reaches the goal, the loop runs forever, growing the path ArrayList without bound → a hard hang + unbounded allocation → OOM.

Surfaced loading a 1000-worker flying-platform colony: after a save/load, some entity's repath hit a cyclic next-hop and the game froze while RSS climbed ~450 MB/s until the machine OOM'd. A stack sample pinned it to FloydWarshall.getPath looping with ensureTotalCapacity growing every iteration.

Fix

A real shortest path visits each node at most once, so cap the loop at size hops; longer ⇒ a cycle ⇒ return error.NoPathFound (callers already treat that as 'no route' — graceful + recoverable, the entity just doesn't navigate). Applied to both the managed and unmanaged variants.

Regression test injects a 2-node cycle into next and asserts setPathWithMappingUnmanaged terminates with error.NoPathFound instead of hanging. zig build test green.

Validated end-to-end: with the fix, loading the 1000-worker colony goes from a permanent freeze to smooth ~100 FPS post-load.

version → 0.7.1.

setPathWithMapping{,Unmanaged} followed the next-hop chain with only a
dead-end (INF) guard, so an inconsistent `next` matrix containing a cycle
that never reaches the goal looped FOREVER, growing the path list unbounded
— a hard hang + OOM. Surfaced loading a 1000-worker flying-platform colony:
some entity's post-load repath hit a cyclic next-hop and the game froze
while RSS climbed ~450 MB/s until the machine OOM'd.

A real shortest path visits each node at most once, so cap the loop at
`size` hops; anything longer is a cycle → return error.NoPathFound (the
caller already treats that as 'no route', graceful + recoverable). Adds a
regression test that injects a 2-node cycle and asserts termination.

Bumps version to 0.7.1.
@cursor

cursor Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
The change is small and localized, but it alters runtime behavior on corrupted graph state (infinite loop → error), which is the intended fix for production hang/OOM scenarios; the non-optimized floyd_warshall.zig path is unchanged.

Overview
Path reconstruction in FloydWarshallSimd no longer loops forever when the next matrix has a cycle that never reaches the destination. Both setPathWithMapping and setPathWithMappingUnmanaged now count hops and return error.NoPathFound after more than size steps (same semantics callers already use for missing routes).

A regression test corrupts the next-hop table into a 10↔20 cycle toward goal 40 and asserts setPathWithMappingUnmanaged returns NoPathFound instead of hanging.

Package version in build.zig.zon is bumped to 0.7.1.

Reviewed by Cursor Bugbot for commit 0b007ab. Bugbot is set up for automated code reviews on this repo. Configure here.

@apotema
apotema merged commit d40c8a8 into main Jun 18, 2026
2 checks passed
@apotema
apotema deleted the fix/fw-path-cycle-guard branch June 18, 2026 13:48

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a cycle guard to the Floyd-Warshall path reconstruction functions (setPathWithMapping and setPathWithMappingUnmanaged) to prevent infinite loops and out-of-memory errors when encountering corrupt or cyclic next-hop matrices, accompanied by a corresponding unit test. Feedback suggests tightening the cycle guard condition from steps > self.size to steps >= self.size for a more accurate bound. Additionally, it is recommended to remove the accidentally committed zig-pkg/ directory containing the zspec dependency source code to avoid repository bloat.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +160 to 176
var steps: usize = 0;
const max_steps: usize = self.size; // a shortest path visits each node at most once
while (current != v_node) {
// Cycle / corrupt next-hop guard: without this, an inconsistent
// `next` matrix (a cycle that never reaches `v_node`) loops
// forever, growing `path_list` unbounded — a hard hang + OOM.
// A real shortest path is at most `size` hops, so anything
// longer is a cycle. Surfaced as a permanent freeze + memory
// blow-up loading a 1000-worker flying-platform colony.
if (steps > max_steps) return error.NoPathFound;
try path_list.append(current);
current = self.nextWithMapping(current, v_node);
if (current == INF) {
return error.NoPathFound;
}
steps += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The cycle guard condition steps > max_steps (where max_steps = self.size) allows the path to grow larger than the total number of nodes in the graph before bailing out. Since a valid simple shortest path can visit each node at most once, it can have at most self.size - 1 edges (and thus at most self.size - 1 iterations of the loop).

Checking steps >= self.size is a tighter and more correct bound that prevents appending redundant nodes to the path list and avoids the need for the intermediate max_steps constant.

            var steps: usize = 0;
            while (current != v_node) {
                // Cycle / corrupt next-hop guard: without this, an inconsistent
                // `next` matrix (a cycle that never reaches `v_node`) loops
                // forever, growing `path_list` unbounded — a hard hang + OOM.
                // A real shortest path is at most `size` hops, so anything
                // longer is a cycle. Surfaced as a permanent freeze + memory
                // blow-up loading a 1000-worker flying-platform colony.
                if (steps >= self.size) return error.NoPathFound;
                try path_list.append(current);
                current = self.nextWithMapping(current, v_node);
                if (current == INF) {
                    return error.NoPathFound;
                }
                steps += 1;
            }

Comment on lines +184 to 196
var steps: usize = 0;
const max_steps: usize = self.size; // a shortest path visits each node at most once
while (current != v_node) {
// See `setPathWithMapping` — guards an inconsistent `next`
// matrix cycle from looping forever (hang + unbounded alloc).
if (steps > max_steps) return error.NoPathFound;
try path_list.append(allocator, current);
current = self.nextWithMapping(current, v_node);
if (current == INF) {
return error.NoPathFound;
}
steps += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similarly to setPathWithMapping, the cycle guard condition can be tightened to steps >= self.size to prevent unnecessary appends and avoid the intermediate max_steps constant.

            var steps: usize = 0;
            while (current != v_node) {
                // See `setPathWithMapping` — guards an inconsistent `next`
                // matrix cycle from looping forever (hang + unbounded alloc).
                if (steps >= self.size) return error.NoPathFound;
                try path_list.append(allocator, current);
                current = self.nextWithMapping(current, v_node);
                if (current == INF) {
                    return error.NoPathFound;
                }
                steps += 1;
            }

Comment thread build.zig.zon
.{
.name = .zig_utils,
.version = "0.7.0",
.version = "0.7.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The zig-pkg/ directory containing the source code of the zspec dependency seems to have been accidentally committed to the repository. Since zspec is already declared as a package dependency in build.zig.zon and is automatically fetched by the Zig package manager, committing its source code under zig-pkg/ is redundant and bloats the repository.

Please remove the zig-pkg/ directory from the repository and add it to your .gitignore file to prevent it from being committed in the future.

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.

1 participant