Skip to content

Commit ba2be74

Browse files
alexyakuninclaude
andcommitted
fix(ts/fusion): kernel2 invalidation-path robustness (K4/K17/K12/K10/K9)
Port the C# Computed invalidation contracts into the TS kernel: - K4+K17: Computed.invalidate() never throws. Own onInvalidated handlers fire first (each isolated via EventHandlerSet.triggerSafe + error log), then dependants propagate unconditionally (per-dependant try/catch), then unregister — all in a finally so the cascade always completes. This also keeps a ComputedState update loop alive when a handler throws. - K12: replace the public onInvalidated EventHandlerSet field with an onInvalidated(handler) method that invokes the handler immediately when the computed is already invalidated (C# add-accessor parity); the handler set is now private. Updated call sites in fusion-rpc/fusion-hub.ts and the fusion tests. - K10: whenInvalidated(abortSignal) stores the abort listener so both sides clean each other up (invalidation removes the abort listener; abort removes the invalidation handler); pre-checks abortSignal.aborted and rejects immediately with signal.reason ?? new Error('Operation cancelled.'). - K9: Computed.update() runs the renewer with the compute context cleared (computeContextKey = undefined), the TS analog of C# BeginIsolation, so update()/state.update()/recompute() never records a dependency edge. Adds kernel2-invalidation.test.ts covering all five items. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDiBQM5rnhyBG1asuP1kDi
1 parent 636ef08 commit ba2be74

6 files changed

Lines changed: 287 additions & 34 deletions

File tree

docs/plans/ts-port-audit.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ Confidence: confirmed (mechanism fully traced; a by-design JS limitation, but si
7878

7979
### K4. `invalidate()` can throw and aborts the invalidation cascade midway
8080

81+
Status: **closed** — fixed 2026-07-15 (batch kernel2).
82+
8183
Confidence: confirmed.
8284

8385
- TS: `computed.ts:184-193` — dependant propagation and `onInvalidated.trigger()` run with no try/catch (`events.ts:19-21` doesn't isolate handlers either). A throwing handler propagates up: remaining dependants are never invalidated, `_unregister` is skipped (an Invalidated computed stays registered), and the exception surfaces to whoever called `invalidate()` — e.g. out of `MutableState.set()`, or inside `ComputedState._updateCycle`, whose outer catch (`computed-state.ts:150-154`) logs "UpdateCycle failed and stopped" and **exits the loop permanently**.
@@ -132,6 +134,8 @@ Confidence: confirmed.
132134

133135
### K9. `update()` is not isolated — renewal registers a dependency in the ambient compute context
134136

137+
Status: **closed** — fixed 2026-07-15 (batch kernel2).
138+
135139
Confidence: confirmed.
136140

137141
- TS: `Computed.update()``_renewer()` (`computed.ts:106-114`) → `ComputeFunction.invoke`, which falls back to `AsyncContext.current` (`compute-function.ts:55-58`) and captures the produced computed into that ambient context (`compute-function.ts:116`).
@@ -142,6 +146,8 @@ Confidence: confirmed.
142146

143147
### K10. `whenInvalidated(abortSignal)` leaks one abort listener per call on long-lived signals; never settles on an already-aborted signal
144148

149+
Status: **closed** — fixed 2026-07-15 (batch kernel2).
150+
145151
Confidence: confirmed.
146152

147153
- TS: `computed.ts:196-216` — the `'abort'` listener (added `{ once: true }`) is never removed when the promise resolves via invalidation. `ComputedState._updateCycle` passes the same `disposeSignal` every iteration (`computed-state.ts:139`), so listeners accumulate one per update. Also: when the signal is *already aborted*, the listener is skipped and the returned promise may never settle (feeds S5); and `ps.reject(abortSignal.reason)` can reject with `undefined`.
@@ -162,6 +168,8 @@ Confidence: confirmed.
162168

163169
### K12. `onInvalidated` is a public raw handler set — a handler added after invalidation never fires (C#: fires immediately)
164170

171+
Status: **closed** — fixed 2026-07-15 (batch kernel2; `onInvalidated(handler)` is now a method with immediate fire on already-invalidated computeds).
172+
165173
Confidence: confirmed.
166174

167175
- TS: `computed.ts:51, 190-191` — after `trigger()` the set is cleared; `EventHandlerSet.add` on an already-invalidated computed stores a handler that can never fire. Only `whenInvalidated` has the state pre-check.
@@ -208,6 +216,8 @@ Confidence: confirmed. `computed-registry.ts:25-29` vs C# `ComputedRegistry.cs:1
208216

209217
### K17. Invalidation ordering differs (low)
210218

219+
Status: **closed** — fixed 2026-07-15 (batch kernel2, folded into the K4 rework as planned).
220+
211221
Confidence: confirmed. TS notifies dependants before the computed's own `onInvalidated` handlers (`computed.ts:184-191`); C# fires own handlers first, dependants in `finally` (`Computed.cs:303-318`). Observable to handlers that inspect dependants' state.
212222

213223
- **Recommended:** fold into the K4 rework (its recommended shape already fires own handlers first, dependants after, both exception-isolated).

ts/packages/core/src/events.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export class EventHandlerSet<T> {
2525
handler(arg);
2626
}
2727

28+
// Fires every handler once, isolating each so one that throws can't stop the rest.
29+
triggerSafe(arg: T, onError: (error: unknown) => void): void {
30+
for (const handler of [...this._handlers]) {
31+
try {
32+
handler(arg);
33+
} catch (error) {
34+
onError(error);
35+
}
36+
}
37+
}
38+
2839
clear(): void {
2940
this._handlers.clear();
3041
}

ts/packages/fusion-rpc/src/fusion-hub.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export class FusionHub extends RpcHub {
183183

184184
// Wire invalidation → send $sys-c.Invalidate to the client
185185
if (context !== undefined) {
186-
computed.onInvalidated.add(() => {
186+
computed.onInvalidated(() => {
187187
const msg = serializeMessage({
188188
Method: FUSION_INVALIDATE_METHOD,
189189
RelatedId: context.callId,

ts/packages/fusion/src/computed.ts

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import {
99
import type { ComputedInput } from './computed-input.js';
1010
import { ComputedRegistry } from './computed-registry.js';
1111
import { ComputeContext, computeContextKey } from './compute-context.js';
12+
import { getLogs } from './logging.js';
1213
import type { State } from './state.js';
1314

15+
const { errorLog } = getLogs('Computed');
16+
1417
let _nextVersion = 0;
1518

1619
export const enum ConsistencyState {
@@ -48,7 +51,7 @@ export class Computed<T> implements IResult<T> {
4851
private _invalidatePending = false;
4952
private _dependencies = new Set<Computed<unknown>>();
5053
private _dependants = new Map<number, WeakRef<Computed<unknown>>>();
51-
readonly onInvalidated = new EventHandlerSet<void>();
54+
private _onInvalidated = new EventHandlerSet<void>();
5255
readonly _renewer: (() => Computed<T> | Promise<Computed<T>>) | undefined;
5356

5457
constructor(
@@ -107,7 +110,17 @@ export class Computed<T> implements IResult<T> {
107110
if (this._state === ConsistencyState.Consistent) return this;
108111
const latest = this._latest();
109112
if (latest?.isConsistent) return latest;
110-
if (this._renewer !== undefined) return this._renewer();
113+
if (this._renewer !== undefined) {
114+
const renewer = this._renewer;
115+
// Run the renewer with the compute context cleared so renewal never
116+
// records a dependency edge in the ambient computation — TS analog of
117+
// C# Computed.UpdateUntyped's BeginIsolation.
118+
const isolated = (AsyncContext.current ?? AsyncContext.empty).with(
119+
computeContextKey,
120+
undefined
121+
);
122+
return isolated.run(renewer);
123+
}
111124
throw new Error(
112125
'Cannot recompute: Computed is invalidated and has no renewer.'
113126
);
@@ -166,6 +179,8 @@ export class Computed<T> implements IResult<T> {
166179
ComputedRegistry.unregister(this as Computed<unknown>);
167180
}
168181

182+
// Never throws — mirrors .NET Computed.Invalidate: own handlers fire first
183+
// (each isolated), then dependants propagate unconditionally, then unregister.
169184
invalidate(): void {
170185
if (this._state === ConsistencyState.Invalidated)
171186
return;
@@ -176,42 +191,65 @@ export class Computed<T> implements IResult<T> {
176191
return;
177192
}
178193
this._state = ConsistencyState.Invalidated;
179-
180-
// Unlink from each dependency's dependants map, then clear forward references
181-
for (const dependency of this._dependencies)
182-
dependency._dependants.delete(this._version);
183-
this._dependencies.clear();
184-
185-
// Notify dependants via WeakRef backward references
186-
for (const [, ref] of this._dependants) {
187-
const dependant = ref.deref();
188-
if (dependant != null) dependant.invalidate();
194+
try {
195+
this._onInvalidated.triggerSafe(undefined, e =>
196+
errorLog?.log('onInvalidated handler failed', e)
197+
);
198+
this._onInvalidated.clear();
199+
} finally {
200+
for (const dependency of this._dependencies)
201+
dependency._dependants.delete(this._version);
202+
this._dependencies.clear();
203+
204+
for (const [, ref] of this._dependants) {
205+
const dependant = ref.deref();
206+
if (dependant != null) {
207+
try {
208+
dependant.invalidate();
209+
} catch (e) {
210+
errorLog?.log('Error while invalidating dependant', e);
211+
}
212+
}
213+
}
214+
this._dependants.clear();
215+
216+
this._unregister();
189217
}
190-
this._dependants.clear();
191-
192-
this.onInvalidated.trigger();
193-
this.onInvalidated.clear();
218+
}
194219

195-
this._unregister();
220+
onInvalidated(handler: () => void): void {
221+
if (this._state === ConsistencyState.Invalidated) {
222+
handler();
223+
return;
224+
}
225+
this._onInvalidated.add(handler);
196226
}
197227

198228
whenInvalidated(abortSignal?: AbortSignal): Promise<void> {
199229
if (this._state === ConsistencyState.Invalidated)
200230
return Promise.resolve();
231+
if (abortSignal?.aborted)
232+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- reason falls back to an Error; abortSignal.reason mirrors throwIfAborted()
233+
return Promise.reject(
234+
abortSignal.reason ?? new Error('Operation cancelled.')
235+
);
201236

202237
const ps = new PromiseSource<void>();
203-
const handler = () => ps.resolve(undefined);
204-
this.onInvalidated.add(handler);
205-
206-
if (abortSignal !== undefined && !abortSignal.aborted) {
207-
abortSignal.addEventListener(
208-
'abort',
209-
() => {
210-
this.onInvalidated.remove(handler);
211-
ps.reject(abortSignal.reason);
212-
},
213-
{ once: true }
214-
);
238+
let onAbort: (() => void) | undefined;
239+
const onInvalidated = () => {
240+
if (onAbort !== undefined)
241+
abortSignal!.removeEventListener('abort', onAbort);
242+
ps.resolve(undefined);
243+
};
244+
this._onInvalidated.add(onInvalidated);
245+
if (abortSignal !== undefined) {
246+
onAbort = () => {
247+
this._onInvalidated.remove(onInvalidated);
248+
ps.reject(
249+
abortSignal.reason ?? new Error('Operation cancelled.')
250+
);
251+
};
252+
abortSignal.addEventListener('abort', onAbort, { once: true });
215253
}
216254

217255
return ps;

ts/packages/fusion/tests/computed.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('Computed', () => {
6060
const c = new Computed<number>(makeKey('get', 1));
6161
c.setOutput(42);
6262
let count = 0;
63-
c.onInvalidated.add(() => count++);
63+
c.onInvalidated(() => count++);
6464
c.invalidate();
6565
c.invalidate();
6666
expect(count).toBe(1);
@@ -71,7 +71,7 @@ describe('Computed', () => {
7171
expect(c.state).toBe(ConsistencyState.Computing);
7272

7373
let invalidatedCount = 0;
74-
c.onInvalidated.add(() => invalidatedCount++);
74+
c.onInvalidated(() => invalidatedCount++);
7575

7676
// Invalidate while still Computing — should defer, not throw, not transition yet.
7777
c.invalidate();
@@ -88,7 +88,7 @@ describe('Computed', () => {
8888
it('should not double-invalidate when pending plus a later invalidate', () => {
8989
const c = new Computed<number>(makeKey('get', 1));
9090
let invalidatedCount = 0;
91-
c.onInvalidated.add(() => invalidatedCount++);
91+
c.onInvalidated(() => invalidatedCount++);
9292
c.invalidate(); // deferred
9393
c.setOutput(42); // applies pending invalidate
9494
c.invalidate(); // already invalidated — no-op
@@ -100,7 +100,7 @@ describe('Computed', () => {
100100
const c = new Computed<number>(makeKey('get', 1));
101101
c.setOutput(42);
102102
let fired = false;
103-
c.onInvalidated.add(() => {
103+
c.onInvalidated(() => {
104104
fired = true;
105105
});
106106
c.invalidate();

0 commit comments

Comments
 (0)