fix(floyd-warshall): cycle guard in path reconstruction (hang + OOM) - #12
Conversation
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.
PR SummaryMedium Risk Overview A regression test corrupts the next-hop table into a 10↔20 cycle toward goal 40 and asserts Package version in Reviewed by Cursor Bugbot for commit 0b007ab. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}
| .{ | ||
| .name = .zig_utils, | ||
| .version = "0.7.0", | ||
| .version = "0.7.1", |
There was a problem hiding this comment.
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.
Bug
setPathWithMapping/setPathWithMappingUnmanagedwalk the next-hop chain fromu_nodetov_node, appending each node, and only bail on a dead-end (next == INF). If thenextmatrix is internally inconsistent and contains a cycle that never reaches the goal, the loop runs forever, growing the pathArrayListwithout 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.getPathlooping withensureTotalCapacitygrowing every iteration.Fix
A real shortest path visits each node at most once, so cap the loop at
sizehops; 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
nextand assertssetPathWithMappingUnmanagedterminates witherror.NoPathFoundinstead of hanging.zig build testgreen.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.