Skip to content

Commit 31be242

Browse files
authored
Merge pull request #2760 from spokodev/fix/scheduler-zombie-heap-migration
fix(signals): migrate height-adjust heap entries in markDisposal to keep zombie flag and queue in sync
2 parents d8921ac + e141459 commit 31be242

3 files changed

Lines changed: 144 additions & 5 deletions

File tree

.changeset/brave-heaps-refuse.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
fix scheduler livelock after disposing a subtree with a stale height-adjust heap entry

packages/solid-signals/src/core/owner.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
defaultContext,
55
REACTIVE_DISPOSED,
66
REACTIVE_IN_HEAP,
7+
REACTIVE_IN_HEAP_HEIGHT,
78
REACTIVE_ZOMBIE
89
} from "./constants.js";
910
import {
@@ -16,7 +17,7 @@ import {
1617
} from "./core.js";
1718
import { clearSignals, DEV, emitDiagnostic } from "./dev.js";
1819
import { unlinkSubs } from "./graph.js";
19-
import { deleteFromHeap, insertIntoHeap } from "./heap.js";
20+
import { deleteFromHeap, insertIntoHeap, insertIntoHeapHeight } from "./heap.js";
2021
import { dirtyQueue, globalQueue, zombieQueue } from "./scheduler.js";
2122
import type { Computed, Disposable, Owner, Root } from "./types.js";
2223

@@ -25,10 +26,20 @@ const PENDING_OWNER = {} as Owner; // Dummy owner to trigger store's read() path
2526
export function markDisposal(el: Owner): void {
2627
let child = el._firstChild;
2728
while (child) {
28-
(child as Computed<unknown>)._flags |= REACTIVE_ZOMBIE;
29-
if ((child as Computed<unknown>)._flags & REACTIVE_IN_HEAP) {
30-
deleteFromHeap(child as Computed<unknown>, dirtyQueue);
31-
insertIntoHeap(child as Computed<unknown>, zombieQueue);
29+
const flags = (child as Computed<unknown>)._flags;
30+
(child as Computed<unknown>)._flags = flags | REACTIVE_ZOMBIE;
31+
// migrate height-adjust entries too, not just recompute entries: every
32+
// `deleteFromHeap` call site picks the queue from the zombie flag, so a
33+
// node left physically linked in `dirtyQueue` after being zombified gets
34+
// unlinked from the wrong queue on dispose, corrupting the bucket and
35+
// livelocking the next `runHeap` that reaches it (#2759)
36+
if (flags & (REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT)) {
37+
deleteFromHeap(
38+
child as Computed<unknown>,
39+
flags & REACTIVE_ZOMBIE ? zombieQueue : dirtyQueue
40+
);
41+
if (flags & REACTIVE_IN_HEAP) insertIntoHeap(child as Computed<unknown>, zombieQueue);
42+
else insertIntoHeapHeight(child as Computed<unknown>, zombieQueue);
3243
}
3344
markDisposal(child);
3445
child = child._nextSibling;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect, it } from "vitest";
2+
import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js";
3+
import { REACTIVE_IN_HEAP, REACTIVE_IN_HEAP_HEIGHT } from "../src/core/constants.js";
4+
import { dirtyQueue, zombieQueue } from "../src/core/scheduler.js";
5+
import type { Computed } from "../src/core/types.js";
6+
7+
/** A chain of pass-through memos, standing in for a deep derived-data pipeline. */
8+
function chain<T>(src: () => T, n: number): () => T {
9+
let cur = src;
10+
for (let i = 0; i < n; i++) {
11+
const prev = cur;
12+
cur = createMemo(() => prev());
13+
}
14+
return cur;
15+
}
16+
17+
/**
18+
* Every node physically linked in a heap must carry an in-heap flag, because
19+
* `runHeap` makes no progress on a bucket head that `deleteFromHeap` refuses
20+
* to unlink (it early-returns on the flag check), and every `deleteFromHeap`
21+
* call site picks the queue from the node's flags.
22+
*/
23+
function entriesWithoutInHeapFlags(heap: typeof dirtyQueue): number[] {
24+
const corrupted: number[] = [];
25+
for (let height = 0; height < heap._heap.length; height++) {
26+
for (
27+
let el: Computed<unknown> | undefined = heap._heap[height];
28+
el !== undefined;
29+
el = el._nextHeap
30+
) {
31+
if (!(el._flags & (REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT))) {
32+
corrupted.push(el._flags);
33+
}
34+
}
35+
}
36+
return corrupted;
37+
}
38+
39+
/**
40+
* @see https://github.com/solidjs/solid/issues/2759
41+
*/
42+
it("disposing a subtree with a stale height-adjust entry does not corrupt the dirty queue", () => {
43+
const [open, setOpen] = createSignal(true);
44+
let setRows!: (v: number[] | null) => void;
45+
let view: () => unknown = () => null;
46+
let dispose!: () => void;
47+
48+
createRoot(d => {
49+
dispose = d;
50+
// keeps the unmount recompute at a low height, so the flush that disposes
51+
// the subtree never advances the cursor up to the stale entry's bucket
52+
const gate = chain(() => open(), 4);
53+
const appView = createMemo(() => {
54+
if (!gate()) return null;
55+
56+
// ~<Inner>: an async data source that lands after mount
57+
const [rows, set] = createSignal<number[] | null>(null);
58+
setRows = set;
59+
60+
// parks the "condition value" memo at a height above everything the
61+
// unmount flush will visit
62+
const tall = chain(
63+
createMemo(() => 1),
64+
12
65+
);
66+
const deepA = chain(
67+
createMemo(() => 1),
68+
20
69+
);
70+
const deepB = chain(() => rows(), 45);
71+
72+
// mirrors <Show when={expr}>: the compiler wraps the `when` expression
73+
// in a memo that is created lazily inside (and owned by) Show's
74+
// "condition value" memo, so the parent's height never tracks the
75+
// child's height
76+
let expr: (() => unknown) | undefined;
77+
const conditionValue = createMemo(() => {
78+
tall();
79+
expr ??= createMemo(() => (rows() === null ? deepA() : deepB()) && true);
80+
return expr();
81+
});
82+
const condition = createMemo(() => conditionValue(), {
83+
equals: (a, b) => !a === !b
84+
});
85+
const value = createMemo(() => (condition() ? "inner ready" : null));
86+
return value();
87+
});
88+
view = appView;
89+
createRenderEffect(appView, () => undefined);
90+
});
91+
92+
flush();
93+
expect(view()).toBe("inner ready");
94+
95+
// The async data lands: the condition expression memo recomputes, its
96+
// height grows past the already-advanced cursor while its value stays the
97+
// same, so its subscriber is re-inserted for height adjustment at a stale
98+
// height the cursor has already passed and survives the flush linked in
99+
// `dirtyQueue` with only `REACTIVE_IN_HEAP_HEIGHT` set.
100+
setRows([1, 2, 3]);
101+
flush();
102+
103+
// Unmount: `markDisposal` zombifies the subtree and the pending disposal
104+
// commits. The height-only entry must migrate queues together with its
105+
// zombie flag, otherwise the commit deletes it from the queue its flag
106+
// names (`zombieQueue`) while it is physically linked in `dirtyQueue`.
107+
setOpen(false);
108+
flush();
109+
110+
expect(entriesWithoutInHeapFlags(dirtyQueue)).toEqual([]);
111+
expect(entriesWithoutInHeapFlags(zombieQueue)).toEqual([]);
112+
113+
// Remount: on corrupted state this flush livelocks in `runHeap`, pinning
114+
// the thread forever.
115+
setOpen(true);
116+
flush();
117+
setRows([1, 2, 3]);
118+
flush();
119+
expect(view()).toBe("inner ready");
120+
121+
dispose();
122+
flush();
123+
});

0 commit comments

Comments
 (0)