Skip to content

Signals - #52

Open
warquys wants to merge 31 commits into
outfox:mainfrom
warquys:feat/component-signals
Open

warquys wants to merge 31 commits into
outfox:mainfrom
warquys:feat/component-signals

Conversation

@warquys

@warquys warquys commented Aug 22, 2026 •

Copy link
Copy Markdown

Add On method returning a Signale. On as the same signature than Add. Meaning you can specify if you whant to handle relation.

// This an exemple, avoid lambda you can't remove the handler
world.On<Position>().Added   += (EntityRef e, ref Position p) => ...;
world.On<Position>().Removed += (EntityRef e, in  Position p) => ...;

Signale are contained in a Dictionary with for key the type of the compoent tarrget. Then a list do the job to raise every matching (base on the Expression).

World lock are by default on signal to avoid raising other signale will handling one. This also allow to pass a EntityRef.

Open question:

  • Should the Expression follow a patern ? Or give a new Signale each time? Currently the way of storing Expression kind a bad. For exemple: 2 differente Expression matching a same case, it will be raised depending base on the first call of On.
  • Should orphan Signals be added back to the World? Or should we only count alive signal and remove clean of the GC?
    For new we only count alive signal. Orphan Signal get remove of the list of Signal to free up space (like GC imply).
  • Should the Exception expose a method to allow for continuation of the operation? Or finishing the operation should not be an option and it should be done whatever.
    Actually Exception are raise and there is now way to fix up/continue the current Action except retrying.

What is done to do:

  • There is no incorrect information in it. However, I didn't like the way Claude wrote it. I did not rewrites theme. I make a lot of spelling mistakes, and it would take me some time to correct them. So, since the methods are still likely to change, the documentation will be done at the end.

Summary by cubic

Adds component lifecycle signals so subscribers can observe structural changes across entity, batch, spawn, despawn, relation, and link operations.

  • World.On<T>(Match) returns a signal memoized per expression, with AfterAdded and BeforeRemoved handlers; removal handlers receive a RemoveCause.
  • Handlers fire once per operation on index-aligned spans of Entity and component values; AfterAdded values are a writable slice of storage.
  • Structural changes handlers request are deferred until dispatch completes; a nested-drain guard prevents stack overflow during bulk deferred operations.
  • Handler failures surface as SignalException; failures during deferred catch-up are marked Deferred = true.
  • Replacements and tolerated no-op removals remain silent. World.GC drops signals with no subscribers weakly: a signal still held by user code is revived by On<T>(), and the runtime collects signals nobody references.
  • Dispatch runs on a single thread even when a parallel job requested the changes; only the batch path allocates, and it rents its capture buffer.

Benchmarks on both this branch and main measure what having the feature costs when nobody subscribes. Documentation covers events and EntityRef, and tests exercise dispatch under parallel job runners.

Written for commit f7c1a4b. Summary will update on new commits.

View guided diff

@warquys
warquys marked this pull request as draft August 22, 2026 00:30

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 20 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread src/fennecs/World.cs
Comment thread src/fennecs/World.API.cs Outdated
Comment thread src/fennecs/Aspect.CRUD.cs
Comment thread src/fennecs.benchmarks/ECS/SignalBenchmarks.cs
Comment thread src/fennecs/World.Deferred.cs Outdated
Comment thread src/fennecs/World.Signals.cs Outdated
Comment thread src/fennecs.benchmarks/Program.cs
Comment thread doc/docs/Basic/Entities/EntityRef.md Outdated
Comment thread doc/docs/Basic/Entities/EntityRef.md
@warquys

warquys commented Aug 22, 2026 •

Copy link
Copy Markdown
Author

This doesn't provide advanced component tracking; it's just enough to handle major changes. It’s useful when you want to use a physics engine or networking that doesn't integrate seamlessly with ECS.
Otherwise, I would have been forced to use an extension method to do the same job and mark the entity with a flag. Then destroy the entity properly in an other querry.

I would have liked to use a flag system that notifies mark entity and lets the user query them, However, that introduces too many complications. Such as what happens if the user doesn't query, and the need to store the components and entity before destruction. Meaning an other world just holding data before final change.

One issue with Signal is the fact of not knowing what the other handler can do. Like doing a loop of removing adding back component. Or adding a component that an other handler already added.

All of this to say, i look for the simplest solution to feed the lake of notification. There is no problem to fully refuse this draft. I will keep the work for me.

@warquys

warquys commented Aug 22, 2026 •

Copy link
Copy Markdown
Author

And i added an entry to EntityRef, can be remove it's only add this page because i find the need to mention it in the doc of Signal.

warquys added a commit to warquys/fennecs that referenced this pull request Aug 22, 2026
GC_Drops_Signals_Once_Everyone_Has_Unsubscribed test will need a rewrite
base on the anwser of the open question (GC one). Currently it's the
only whay to check. Else it will imply relfection.
@warquys

warquys commented Oct 12, 2026 •

Copy link
Copy Markdown
Author

I forgot to rename the method in the doc 🤡 i will force push !!!

@thygrrr

thygrrr commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Questions:

Should the Expression follow a patern ? Or give a new Signale each time? Currently the way of storing Expression kind a bad. For exemple: 2 differente Expression matching a same case, it will be raised depending base on the first call of On.

This is a classic problem. I suggest each registered listener gets notified on "On".

A serious issue with this is that listeners must not be invoked while the world is in WorldMode.IMMEDIATE otherwise the state can change significantly for the other listeners, and refernece guarantees can't be made.

Should orphan Signals be added back to the World? Or should we only count alive signal and remove clean of the GC?

Orphans should, when the Archetype is forgotten (world calls Archetype.Forget()), become unreachable and be collected. A problem is if whatever registered these listeners still holds on to the listener, the whole Archetype stays reachable; so the event itself might need to be a WeakReference<T> or similar.

Should the Exception expose a method to allow for continuation of the operation?
Exceptions here should stop and be considered fatal. Maybe a later version of fennecs can allow custom exception handling here, but since we have no error handlers, if code fennecs calls on your behalf throws, the operation stops.

Same has always been true for runners.

@thygrrr

thygrrr commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Performance impacts:

P1: With 0 subscribers, this PR still has a noticeable performance impact.
10,000 per-entity add/remove cycles took 4.85 ms before, and 6.71 ms at the PR about 39% slower. This one is not okay.

P3: With batches, and actual handlers, even through these numbers are still small, we see an even stronger 59.6 µs becomes 349.8 µs, that's 600% or so. I think that one is actually understandable. Becuase some work has to be done.

However, The performance impact is quadratic - number of entities X number of handlers.

P1: Therefore I think maybe it is better to delegate this to the user side of things:

  • send them a span of entities and changed components, once
  • in the "single" entity case, send them a span with 1 entity. :)

@thygrrr

thygrrr commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

P1: Removal-handler exceptions can corrupt World state. A throwing handler in a secondary aspect can leave world.Count == 1 while world.All.Count == 0

(it's fine for the operation to fail and stop there, but the world can't be left corrupt)

Also the world.Count can be inconsistent during a handler's processing.

P1: Spawning inside handlers invalidates live references (maybe). Template spawning executes immediately despite the World lock, because there's always logical space to append so we don't need to queue. However, I realized that fennecs can resize the array and then refs are invalid.

This is a 50% fennecs, 50% PR problem. In fact I may need to block spawning due to the array resizes. Oof. Thank you for inadvertently bringing this hard crash bug risk to light. :) I might have to do a fennecs update ASAP.

@thygrrr

thygrrr commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

P1: This PR doesn't compile for me. Probably a rename refactor didn't go trhgou all the files, wasn't hard to fix.

@thygrrr

thygrrr commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Suggestion for the Lifetime of subscriptions:

using var subscription =
    world.SubscribeAdded<Position>(OnPositionAdded);

i.e. a disposable subscription, meaning the world can dispose it, too, but "orphans" don't really exist - they live per component / TypeExpression, not per archetype.

A Batch only signals what actually *changes*. Re-writing a component that Entities already have (via `AddConflict.Replace`) is a value change, not an addition – so it stays silent. Likewise, a tolerated no-op removal (`RemoveConflict.Allow` on an Entity that never had the component) raises nothing.
:::

## Removed... but why?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Love this documentation style, very on-brand.

Comment thread src/fennecs/World.Signals.cs Outdated
Comment thread src/fennecs/World.Signals.cs Outdated
Add `On` method returning a Signale. `On` as the same signature than `Add`. Meaning you can specify if you whant to handle relation.

// This an exemple, avoid lambda you can't remove the handler
world.On<Position>().Added   += (EntityRef e, ref Position p) => ...;
world.On<Position>().Removed += (EntityRef e, in  Position p) => ...;

Signale are contained in a Dictionary with for key the type of the compoent tarrget. Then a list do the job to raise every matching (base on the Expression).

World lock are by default on signal to avoid raising other signale will handling one.

Open question:
- Should the Expression follow a patern ? Or give a new `Signale` each time? Currently the way of storing Expression kind a bad. For 2 differente Expression matching a same case, it will be raised depending base on the first call of `On`.

fix: prevent stack overflow when bulk deferred operations signal
Open question, should the Expcetion expose a method to allow for continutation of the operation? Or finishing the operation should not be an option and it should be donne whatever.
Open question: Should orphan Signals be added back to the World? Or should we only count alive signal and remove clean of the GC?
NB: I call this Event because most ECS use the term of Event, where it's a simple Signal.
GC_Drops_Signals_Once_Everyone_Has_Unsubscribed test will need a rewrite
base on the anwser of the open question (GC one). Currently it's the
only whay to check. Else it will imply relfection.
I run the benchmarks longueur, this to check if the last change on the benchmark change somthing. I use those metric for the doc.
@warquys
warquys force-pushed the feat/component-signals branch from 9c57a1c to 743e265 Compare October 14, 2026 16:26
@warquys

warquys commented Oct 14, 2026

Copy link
Copy Markdown
Author

of course i forgotten rebase copy past commits, anyway force with lease

@warquys

warquys commented Oct 14, 2026 •

Copy link
Copy Markdown
Author

A serious issue with this is that listeners must not be invoked while the world is in WorldMode.IMMEDIATE otherwise the state can change significantly for the other listeners, and refernece guarantees can't be made.

I'm thinking of creating a DisableSignal scope. So it catch up at the end and do not trigger Signal. Case like Remove<Postion> then Add<Position> Acting like a simple value change. However, I am not sure of this idea. There might be cases where the user absolutely wants to trigger the event.

With this solution it's simple to catch all entity moved across archetype allowing for the use of span.
This should also boost performance.

@warquys

warquys commented Oct 14, 2026 •

Copy link
Copy Markdown
Author

P1: This PR doesn't compile for me. Probably a rename refactor didn't go trhgou all the files, wasn't hard to fix.

I badly renamed Add and Remove i only renamed the main project and not the Test... I will fix it up
(Shame on my)

@warquys

warquys commented Oct 14, 2026

Copy link
Copy Markdown
Author

I fixed the syntax and ran all the tests. For some reason I can't identify, some tests failed. Rerunning them fixed the problem...

I suppose my test run exceeded the quota of concurrent active World by running them in parallel, i have no other hint.

@warquys

warquys commented Oct 14, 2026

Copy link
Copy Markdown
Author

I am working right on the Pr, i will continue this weekend.
Probably a full commit to apply your answers (like span); cleaner implementation, signal suspend scope and optimize the checking whether a signal is listening.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@warquys
warquys marked this pull request as ready for review October 16, 2026 17:08
@warquys

warquys commented Oct 16, 2026

Copy link
Copy Markdown
Author

I have simplified the code; I believe it would be possible to improve execution time by using well-defined collection types. Interfaces such as IReadOnlyList do not offer performance gains during enumeration and instead incur the overhead of virtual calls (vcalls).

@warquys
warquys force-pushed the feat/component-signals branch from 245288c to 4dbf8c7 Compare October 16, 2026 17:18
warquys and others added 2 commits October 16, 2026 19:23
…an orphan

GC used to forget idle Signals outright; one kept in a field then accepted
handlers and never fired. Now the registry only lets go of them: the World
keeps a WeakReference per dropped Signal, On<T>() revives the very same
instance while someone still holds it, and subscribing to a kept Signal
re-registers it. Signals nobody holds are collected by the runtime.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SignalOverheadBenchmarks and SignalHotpathBenchmarks use only API that also
exists on main, so the very same file can be run on both trees to measure
what merely having Signals costs when nobody subscribes. Hotpath splits
Batch / Spawn / Despawn at two sizes to tell fixed from per-Entity cost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@warquys
warquys force-pushed the feat/component-signals branch from 4dbf8c7 to f7c1a4b Compare October 16, 2026 17:23
warquys and others added 11 commits October 24, 2026 21:47
Covers plain Remove, Batch removal, and Despawn with the throwing handler
on the Main or on a secondary Aspect. The secondary-Aspect Despawn case
currently fails: Main has already deleted the Entity's rows when the
secondary Aspect's handler throws, leaving world.Count == 1 while
world.All is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EntityTemplate.Spawn ignored the World lock: there is always room to
append, but appending can grow an Archetype's Storages and pull them out
from under live references. A Signal handler spawning into the Archetype
whose span it was handed then wrote into a discarded array; a runner
spawning while iterating had the same hazard.

While locked, the Entities are now minted at once (the caller needs the
handles) and join only the Root; their Components follow as deferred Adds
on catch-up, raising AfterAdded there like any other deferred Add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
world.SubscribeAdded<T>(handler) / SubscribeRemoved<T>(handler) (and their
Match overloads, and the same methods on Signal<T>) return a Subscription
whose Dispose unsubscribes the handler. Lambdas can now be subscribed
without leaving a Signal behind with a handler nobody can remove; once its
last Subscription is disposed the Signal is idle and World.GC lets go of it.

A Subscription lives as long as its owner keeps it, not as long as the
Component: World.GC never drops a Signal that still has one, even when no
Entity stores the Component. It keeps its World, its Signal, the handler
and whether it was an Add or a Remove, and Dispose takes that handler back
off directly, with no captured lambda.

The World tracks the Subscriptions it handed out and disposes them when it
is disposed itself: they report Active == false, and disposing them later
is a no-op. Disposing the World also clears every Signal's handlers
without counting down, so a -= outliving the World is harmless too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
World.Refresh only ever set the per-type watch bits, so a Component type
stayed 'watched' after its last handler left: every Add and Remove of it
kept taking the World Lock for a dispatch that found nobody. Refresh now
recomputes both bits from scratch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Events page still showed the old per-Entity EntityRef handlers. It now
documents span handlers, SubscribeAdded/SubscribeRemoved and their
Subscription, deferred template spawns from handlers, and that a throwing
BeforeRemoved handler leaves the World untouched. The cost section is
re-measured against main, with no subscriber and with 1 and 4 handlers.
EntityRef no longer claims Signal handlers hand it out, and the Glossary
names the Subscribe methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured on 10 000 Entities with nobody subscribed (median of 3000 runs):
DespawnAllWith took 390 us against 320 us on main. Two causes, both from
the SignalException wrapper:
- the try/catch sat inside the CatchUp loop, so every operation paid for
  the exception region; it now lives in ApplyFromSignal, called only for
  operations a handler queued;
- FromSignal grew DeferredOperation by a padding slot; Opcode is now a
  byte, which packs it back to its former size.
Neither is enough alone (370 us and 384 us); together the wave is back
at ~325 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With nobody subscribed, a 10 000-Entity Template spawn took 47 us against
36 us on main (median of 3000 runs). The Signal path's using-Lock, written
inline in World.Spawn, put a try/finally in the method and slowed every
spawn. It now lives in SignalSpawned, never inlined, behind a Signalling
check: back at ~36 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@warquys

warquys commented Oct 24, 2026

Copy link
Copy Markdown
Author

Maybe move away of events to give a stream of entityRef and allow to specify a priority when subscribing.

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.

2 participants