Skip to content

feat: own the dynamic ChildrenComponent lifecycle (no MAX_CHILDREN cap) - #797

Merged
apotema merged 3 commits into
mainfrom
feat/dynamic-children
Jul 24, 2026
Merged

apotema merged 3 commits into
mainfrom
feat/dynamic-children

Conversation

@apotema

@apotema apotema commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

What

Pairs with labelle-core#65 (dynamic ChildrenComponent — ArrayList-backed, no MAX_CHILDREN). The ECS backends store components by value and run no destructors, so the engine — which exclusively manages the hierarchy — owns the child-list heap lifecycle at every choke point:

  • setParent / nested-prefab spawn thread game.allocator into addChild.
  • destroyEntity copies child ids onto an independent allocation before the cascade — the old shallow struct snapshot (children_comp.*) would now alias, and outlive the free of, the heap-backed list — then frees the entity's own list (re-fetched, since the cascade can relocate the pool).
  • destroyEntityOnly frees the dying entity's list (its children stay alive).
  • resetEcsBackend and World.deinit free every ChildrenComponent before the ECS wipe drops them by value. World now retains its allocator for the teardown free.

Removes the silent-drop-past-16 orphaning of slot-heavy rooms/workstations (consumer #657).

Verification

The engine test suite runs under the DebugAllocator's leak detector. A first cut that missed the World.deinit free was caught by the prefab-with-children tests; with all choke points covered the full suite passes leak-free. #701's repeated-destroy test is re-pinned to the dynamic model (no more MAX_CHILDREN).

Depends on

labelle-core#65 — merge that first. This engine build compiles against core's new addChild(allocator, …) / deinit(allocator) API; CI here is red until core main carries the change.

https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Pairs with labelle-core's dynamic `ChildrenComponent` (ArrayList-backed, no
`MAX_CHILDREN`). Since the ECS backends store components by value and run no
destructors, the engine — which exclusively manages the hierarchy — owns the
child-list heap lifecycle at every choke point:

- `setParent` / nested-prefab spawn thread `game.allocator` into `addChild`.
- `destroyEntity` copies child ids onto an INDEPENDENT allocation before the
  cascade (the old shallow struct snapshot would now alias — and outlive the
  free of — the heap-backed list), then frees the entity's own list.
- `destroyEntityOnly` frees the dying entity's list (children stay alive).
- `resetEcsBackend` and `World.deinit` free every `ChildrenComponent` before
  the ECS wipe drops them by value. `World` now retains its allocator for the
  teardown free.

Removes the silent-drop-past-16 orphaning of slot-heavy rooms/workstations
(#657). Verified leak-free: the engine test suite runs under the
DebugAllocator's leak detector — a first cut that missed the `World.deinit`
free was caught by the prefab-with-children tests. `#701`'s
repeated-destroy test is re-pinned to the dynamic model.

Requires labelle-core with dynamic `ChildrenComponent`.

Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: df48b9ed-5e5c-4ea2-9b92-f525d25d6b1a

📥 Commits

Reviewing files that changed from the base of the PR and between 4bb1b72 and 0c73ad0.

📒 Files selected for processing (2)
  • src/game/components_mixin.zig
  • test/destroy_unlink_test.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/destroy_unlink_test.zig

📝 Walkthrough

Walkthrough

The change propagates the world allocator into child-list creation and explicitly deinitializes Children component allocations during component replacement, entity destruction, ECS resets, and world shutdown. Recursive child destruction now uses an allocated snapshot, with regression coverage for component operations and repeated destruction.

Changes

Children allocation lifecycle

Layer / File(s) Summary
Allocator propagation
src/game.zig, src/game/components_mixin.zig, src/jsonc/scene_loader/nested_spawn.zig
World retains the caller-provided allocator, and parent-child registration passes it to Children.addChild in existing and newly created components.
Component and entity cleanup
src/game/components_mixin.zig, src/game/entity_mixin.zig
Component replacement and removal deinitialize existing Children storage. Recursive destruction copies child IDs into a temporary allocation and deinitializes Children storage before ECS removal.
World lifecycle cleanup and validation
src/game/world_mixin.zig, src/game.zig, test/destroy_unlink_test.zig
ECS reset and world shutdown deinitialize surviving Children components. Tests cover direct component operations and 40 repeated child-destroy cycles.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: managing the dynamic ChildrenComponent lifecycle and removing the MAX_CHILDREN limit.
Description check ✅ Passed The description is directly related to the code changes and accurately describes the lifecycle, leak-fix, and test updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dynamic-children

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

@apotema

apotema commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@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/game/entity_mixin.zig`:
- Around line 242-245: Update the child iteration in destroyEntity to check each
snapshot entry is still live before recursively destroying it, so hooks that
already destroyed a sibling are skipped. Perform the liveness check immediately
before destroyEntity and preserve identity semantics when ECS IDs may be
recycled during hooks, avoiding destruction of a recycled unrelated entity.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5312c42c-f37a-4cb3-9f66-e2cf27ae09cd

📥 Commits

Reviewing files that changed from the base of the PR and between e995a28 and e8efe7e.

📒 Files selected for processing (6)
  • src/game.zig
  • src/game/components_mixin.zig
  • src/game/entity_mixin.zig
  • src/game/world_mixin.zig
  • src/jsonc/scene_loader/nested_spawn.zig
  • test/destroy_unlink_test.zig

Comment thread src/game/entity_mixin.zig
Comment on lines +242 to 245
const kids = self.allocator.dupe(Entity, children_comp.getChildren()) catch @panic("OOM: destroyEntity children snapshot");
defer self.allocator.free(kids);
for (kids) |child| {
destroyEntity(self, child);

Copy link
Copy Markdown

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

Skip children already destroyed during the cascade.

Line 244 can re-destroy a later snapshot entry when an earlier child’s entity_destroyed hook destroys that sibling. This can hit a dead or recycled ECS ID and corrupt an unrelated entity. Check liveness before recursion; preserve identity semantics if IDs can be recycled during hooks.

Proposed minimum guard
 for (kids) |child| {
+    if (!self.ecs_backend.entityExists(child)) continue;
     destroyEntity(self, child);
 }
📝 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
const kids = self.allocator.dupe(Entity, children_comp.getChildren()) catch @panic("OOM: destroyEntity children snapshot");
defer self.allocator.free(kids);
for (kids) |child| {
destroyEntity(self, child);
const kids = self.allocator.dupe(Entity, children_comp.getChildren()) catch `@panic`("OOM: destroyEntity children snapshot");
defer self.allocator.free(kids);
for (kids) |child| {
if (!self.ecs_backend.entityExists(child)) continue;
destroyEntity(self, child);
🤖 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 `@src/game/entity_mixin.zig` around lines 242 - 245, Update the child iteration
in destroyEntity to check each snapshot entry is still live before recursively
destroying it, so hooks that already destroyed a sibling are skipped. Perform
the liveness check immediately before destroyEntity and preserve identity
semantics when ECS IDs may be recycled during hooks, avoiding destruction of a
recycled unrelated entity.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8efe7e45e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/game/entity_mixin.zig Outdated
// backend drops the component by value (the ECS runs no
// destructor). Re-fetch: the cascade above may have relocated the
// Children pool under any pointer held before it.
if (self.ecs_backend.getComponent(entity, Children)) |cc| cc.deinit(self.allocator);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep Children alive through preview destroy telemetry

In preview runs, destroying an entity that still has children now frees the Children backing list before emitEntityDestroyed, while the next comment preserves the contract that preview consumers can still introspect the entity before the backend destroy. In that scenario the entity still has a Children component, but getChildren would return a slice into the freed ArrayList allocation, so editor-side inspection can see dangling memory or crash; move this deinit until after the preview notification but before ecs_backend.destroyEntity.

Useful? React with 👍 / 👎.

`destroyEntity` freed the entity's own `Children` list right after the
child cascade — but that's BEFORE `emitEntityDestroyed`, and the preview
contract lets an editor-side consumer still introspect the entity (via a
`getComponent`/`getChildren`-style API) during that telemetry. Freeing first
handed such a consumer a slice into a freed `ArrayList` (UAF / crash).

Move the free to just before `ecs_backend.destroyEntity` — after the preview
emit and the parent-unlink — so `getChildren` stays valid through the
notification. (`destroyEntityOnly` already frees after its top-of-fn preview
emit, so it was fine.)

Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bb1b72b52

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


if (self.ecs_backend.getComponent(parent_entity, Children)) |children_comp| {
children_comp.addChild(child);
children_comp.addChild(self.allocator, child);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deinit Children in generic removal paths

Because this line now allocates the Children backing list with self.allocator, the public generic component paths are still an unhandled drop path: removeComponent(parent, Game.ChildrenComp) and overwriting it through setComponent/addComponent still go straight to the ECS, which drops the old component by value and never reaches the new destroy/reset cc.deinit calls. Any game or tooling code that strips or replaces ChildrenComp directly will leak that entity's child-list allocation; special-case Children in those generic paths or make direct removal inaccessible.

Useful? React with 👍 / 👎.

`ChildrenComponent` owns a heap list; the engine frees it at its hierarchy
choke points (setParent/destroy/reset), but the public GENERIC component API
bypasses those — `removeComponent(e, ChildrenComp)`, or an `addComponent` /
`setComponent` that overwrites an existing `ChildrenComponent`, dropped the
old value by value and leaked its ArrayList.

Special-case `Children` in `addComponent` / `setComponent` / `removeComponent`
to `deinit` the existing list before the by-value drop. These guards only fire
on direct external manipulation — the engine's own `setParent` goes through
`ecs_backend.addComponent` on the no-existing branch, so no double-free.

New test drives all three generic paths on a 20-child parent under
`testing.allocator` (leak = fail).

Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
@apotema

apotema commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@apotema

apotema commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Merging on green (CI CLEAN, CodeRabbit pass). codex's round-3 inline is a stale re-post of the generic-removal P2 anchored to the addChild line — commit 0c73ad0 implements exactly what it asks: addComponent / setComponent / removeComponent now deinit an existing Children before the by-value drop, and the new test #797: direct removeComponent / set / add of Children don't leak the list drives all three generic paths on a 20-child parent under testing.allocator (leak = fail), passing. Third review round complete.

@apotema
apotema merged commit 16bb0a7 into main Jul 24, 2026
3 checks passed
@apotema
apotema deleted the feat/dynamic-children branch July 24, 2026 04:04
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 0c73ad068b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

apotema added a commit that referenced this pull request Jul 24, 2026
…797)

Engine owns the child-list heap lifecycle for core's ArrayList-backed
ChildrenComponent (no MAX_CHILDREN) at every choke point: setParent /
destroy(+Only) / resetEcsBackend / World.deinit, plus the generic
add/set/removeComponent paths. Requires labelle-core >= v1.27.0.

Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
apotema added a commit that referenced this pull request Jul 24, 2026
…797)

Engine owns the child-list heap lifecycle for core's ArrayList-backed
ChildrenComponent (no MAX_CHILDREN) at every choke point: setParent /
destroy(+Only) / resetEcsBackend / World.deinit, plus the generic
add/set/removeComponent paths. Requires labelle-core >= v1.27.0.

(.version was stale at 2.4.0 while tags ran to v2.7.1; realigned to the tag.)

Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
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