Skip to content

Reactive engine fixes - #8

Closed
UnknownJoe796 wants to merge 12 commits into
version-6from
reactive-engine-fixes
Closed

Reactive engine fixes#8
UnknownJoe796 wants to merge 12 commits into
version-6from
reactive-engine-fixes

Conversation

@UnknownJoe796

Copy link
Copy Markdown
Contributor

dead running still under consideration

UnknownJoe796 and others added 8 commits July 6, 2026 23:48
The onLoad callback was guarded by `if (wasLoadingLastTime)` while the flag
only ever became true inside that same block, so onLoad was unreachable dead
code. Invert the guard so onLoad fires on the transition INTO a loading state
and set the flag before invoking the callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…node

Reading `.state` on an unobserved Remember calls `runOnceWhileDead`, which ran
the calculation and its `invoke()` operators registered `rerun` listeners on
every source. Those listeners were never released, so a later source change
fired `rerun` and resurrected the supposedly-dead, zero-listener context into a
permanently-computing graph node, contradicting the documented laziness.

runOnceWhileDead now clears the dependency scratch list and cancels the context
after the throwaway run, releasing every listener it registered. Adds a
`listenerCount` test hook to BaseListenable and a regression test asserting a
dead read leaves the source with zero listeners and does not recompute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A calculation that writes to a signal it also reads synchronously re-invokes its
own startCalculation, recursing until the stack overflows (or livelocking under a
dispatching scheduler) with no diagnostic. Add a per-context `calculating` flag,
checked at the top of startCalculation (which the listener dispatch calls
synchronously even under a dispatcher), and throw a descriptive
ReactiveReentrancyException instead. Writing to unrelated signals a calculation
does not depend on stays legal. Adds tests for both the throwing case and the
legitimate unrelated-write case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fix in b90d368 made runOnceWhileDead() call dependencyBlockStart() + cancel() to
release the throwaway run's listeners, but cancel() over-cancels the context, stranding an
in-flight async load in sharedTest3 (loadCount stuck at 1). A refinement to release only
the dependency listeners did not resolve it either; a correct fix needs more careful
reactive-core design and verification.

Reverting runOnceWhileDead to its baseline, and removing the listenerCount test hook and
the readingDeadStateDoesNotLeakOrResurrect regression test that went with the fix. The
original dead-read listener leak (a real bug) is therefore still open. The fix attempt is
preserved on branch reactive-remember-leak-wip for a follow-up that keeps sharedTest3 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reactive graph is single-threaded by design (unsynchronized ArrayList listener lists
and dependency trackers), but nothing enforced it, so a background-thread mutation would
silently corrupt state. Add ReactiveThreadCheck: an opt-in (off by default) assertion that
records the first mutating thread per BaseListenable and throws a clear IllegalStateException
on a foreign-thread mutation in invokeAllListeners.

It is opt-in with a pluggable currentThread hook because this library is common-only (no
expect/actual for thread identity). Disabled, it is a single boolean read. Consumers enable
it in debug builds via ReactiveThreadCheck.enabled/currentThread. Includes a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When Remember.state was accessed on a dead (no-listener) context, the
runOnceWhileDead() calculation would register listeners on every source
it touched via the normal operator path. These listeners were never
released, keeping sources alive and causing the supposedly-dead context
to recompute whenever any source changed (violating laziness).

The previous fix (cancel() after the dead run) broke sharedTest3 because
the cancellation of the old calculation job deferred asynchronously under
Dispatchers.Unconfined: old-job's invokeOnCompletion fired after the
context had already been re-activated, wiping out the freshly registered
dependencies.

This fix adds a skipDependencyRegistration flag on TypedReactiveContext.
runOnceWhileDead() sets it to true for the duration of the action; all
dependency-registering operators (invoke, awaitNotNull, state, once,
rerunOn, use, async, Deferred.invoke, Flow.invoke) check the flag and
skip addListener/registerDependency/coroutine-launch when set. The dead
read returns the current snapshot value with zero side-effects on the
reactive graph.

Also adds BaseListenable.listenerCount (test-visible) and the regression
test readingDeadStateDoesNotLeakOrResurrect that pins both the no-leak
and no-resurrection invariants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfGVb2vVm8ZKXk6g2bhFXc
Combines the reactive engine fixes with the platform-backed rewrite of the
thread-confinement check, so the code changes land independently of the
explicit-API conversion that follows.

Engine fixes:
- R4: repeated reads of the same dependency in one run no longer add duplicate
  usedDependencies entries.
- B6: TypedReactiveContext.async keyed its cache on dependency values only (two
  same-dep async blocks aliased) and launched on the long-lived scope (stale
  work never cancelled). Cancel the prior Job on rerun, mirroring the Flow
  operator; same stale-launch fix applied to the sibling Deferred.invoke().
- B7: reactiveState {} swallowed CancellationException as notReady, letting a
  cancelled suspending calc write stale state. Rethrow it.
- B8: delete the false "no dependencies -> auto-cancelled" KDoc claim.

async identity:
- async() now takes an explicit `identity: String` and keys its cache on
  setOf(identity, *dependencies) rather than deriving call-site identity from
  action::class.

Thread confinement:
- Replace the pluggable ReactiveThreadCheck.currentThread hook and the
  ThreadConfinementGuard class with an `internal expect fun
  currentReactiveThread()` plus a `checkThreadConfinement(owningThread)`
  function whose result callers store back, avoiding a per-node allocation.
- Add jvm (Thread.currentThread), ios (NSThread.currentThread) and js (null,
  no shared-memory threads) actuals.

jvmTest 113 tests green; JS + iOS compile green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UHkALCCi6UrsGba6wUGWp
@UnknownJoe796
UnknownJoe796 requested a review from iHoonter July 28, 2026 18:49
iHoonter
iHoonter previously approved these changes Jul 28, 2026
@iHoonter

iHoonter commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Approved pending the ReactiveRentryException logic is hardened or removed for now

@iHoonter iHoonter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Added a few comments for things that need adjustment.

Comment thread src/commonMain/kotlin/com/lightningkite/reactive/core/Remember.kt Outdated
Comment thread src/commonMain/kotlin/com/lightningkite/reactive/core/ReactiveState.kt Outdated
Comment thread src/commonMain/kotlin/com/lightningkite/reactive/extensions/helpers.kt Outdated
Backported from reactive-api-split so the two branches agree on behavior:

- ReactiveState.ready compares the sentinels with != rather than !is. The
  sentinels are objects, so equality is the intended check.
- Remember.activate no longer overwrites notActive with notReady when
  useLastWhileLoading is set - that is the whole point of the flag.
- onNextSuccess routes both the listener and the post-subscribe read through one
  `perform` guarded by `acted`, instead of duplicating the body and reconciling
  afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GA6LAUN9Lpo5hyK3KVJteJ
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