fix: prevent effect tree of batches from interfering with each other (#18508)

While working on another Svelte feature I noticed a bug that boils down
to batches interfering with each other through the effect tree: If batch
A schedules an effect, it is walked up to the root (possibly). Now if in
the meantime batch B also wants to schedule effects, it can have
unintended consequences.

Normally this does not happen, since it's extremely hard to run into
this situation. There's basically two cases: Either an unfortunate
timing of microtasks, or during flushing effects are scheduling new
effects which messes with a `#commit()` right after (the test case does
this).

To fix this we now defer walking up the tree until the batch is actually
processed. That way we set + unset the markers on the branches
synchronously so there's no chance of another batch interfering.
main
Simon H 1 day ago committed by GitHub
parent d16099ef0f
commit ce89035ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent effect tree of batches from interfering with each other

@ -22,7 +22,6 @@ import {
import { async_mode_flag } from '../../flags/index.js'; import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js'; import { deferred, define_property, includes } from '../../shared/utils.js';
import { import {
active_effect,
active_reaction, active_reaction,
get, get,
increment_write_version, increment_write_version,
@ -38,7 +37,6 @@ import { eager_effect, teardown, unlink_effect } from './effects.js';
import { defer_effect } from './utils.js'; import { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js'; import { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js'; import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js'; import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js'; import { log_effect_tree } from '../dev/debug.js';
import { OBSOLETE } from './deriveds.js'; import { OBSOLETE } from './deriveds.js';
@ -160,10 +158,17 @@ export class Batch {
#deferred = null; #deferred = null;
/** /**
* The root effects that need to be flushed * Effects that were scheduled in this batch but not yet 'resolved' into the
* root effects that need to be flushed. Resolving the upwards traversal that
* marks the path to each effect on the shared effect tree (see #resolve) is
* deferred until the batch is processed, so that the markers are created and
* consumed within a single traversal. Scheduling into other batches (which can
* happen concurrently, e.g. while a batch is committed) can therefore never
* observe (and be confused by) this batch's markers.
* May contain duplicates deduplication happens during resolving
* @type {Effect[]} * @type {Effect[]}
*/ */
#roots = []; #scheduled = [];
/** /**
* Effects created while this batch was active. * Effects created while this batch was active.
@ -273,13 +278,56 @@ export class Batch {
this.#unskipped_branches.add(effect); this.#unskipped_branches.add(effect);
} }
#process() { /**
this.#started = true; * Convert the effects that were scheduled in this batch into the root effects
* that need to be traversed, marking the path to each effect (by clearing the
* `CLEAN` flag on ancestor branches) so that the traversal can find them.
* This happens right before traversal rather than at scheduling time, so that
* the markers left on the (shared) effect tree are created and consumed within
* a single traversal scheduling into other batches can never observe them
* @returns {Effect[]}
*/
#resolve() {
/** @type {Effect[]} */
var roots = [];
if (flush_count++ > 1000) { for (const effect of this.#scheduled) {
this.#unlink(); // skip effects that are destroyed, or that already ran (e.g. because
infinite_loop_guard(); // they were reached by the traversal that preceded a drain iteration,
// or because they were scheduled twice)
if ((effect.f & DESTROYED) !== 0 || (effect.f & (DIRTY | MAYBE_DIRTY)) === 0) continue;
var e = effect;
var covered = false;
while (e.parent !== null) {
e = e.parent;
var flags = e.f;
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// the path to the root was already marked, meaning the
// root was already collected — nothing left to do
covered = true;
break;
}
e.f ^= CLEAN;
}
}
if (!covered) {
roots.push(e);
} }
}
this.#scheduled = [];
return roots;
}
#process() {
this.#started = true;
if (DEV) { if (DEV) {
// track all the values that were updated during this flush, // track all the values that were updated during this flush,
@ -304,9 +352,6 @@ export class Batch {
this.schedule(e); this.schedule(e);
} }
const roots = this.#roots;
this.#roots = [];
this.apply(); this.apply();
/** @type {Effect[]} */ /** @type {Effect[]} */
@ -321,7 +366,16 @@ export class Batch {
*/ */
var updates = (legacy_updates = []); var updates = (legacy_updates = []);
for (const root of roots) { // Effects can be scheduled during traversal (e.g. because a parent each/await/etc
// block updated an internal source, or because an effect invalidated itself)
// hence we loop until there are no more scheduled effects.
while (this.#scheduled.length > 0) {
if (flush_count++ > 1000) {
this.#unlink();
infinite_loop_guard(); // TODO try to reset_all() here?
}
for (const root of this.#resolve()) {
try { try {
this.#traverse(root, effects, render_effects); this.#traverse(root, effects, render_effects);
} catch (e) { } catch (e) {
@ -335,6 +389,7 @@ export class Batch {
throw e; throw e;
} }
} }
}
// any writes should take effect in a subsequent batch // any writes should take effect in a subsequent batch
current_batch = null; current_batch = null;
@ -393,7 +448,7 @@ export class Batch {
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
if (this.#pending === 0 && (this.#roots.length === 0 || next_batch !== null)) { if (this.#pending === 0 && (this.#scheduled.length === 0 || next_batch !== null)) {
this.#unlink(); this.#unlink();
// Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around,
@ -408,12 +463,15 @@ export class Batch {
} }
// Edge case: During traversal new branches might create effects that run immediately and set state, // Edge case: During traversal new branches might create effects that run immediately and set state,
// causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // causing an effect to be scheduled again. We need to traverse the current batch
// once more in that case - most of the time this will just clean up dirty branches. // once more in that case - most of the time this will just clean up dirty branches.
if (this.#roots.length > 0) { if (this.#scheduled.length > 0) {
if (next_batch !== null) { if (next_batch !== null) {
const batch = next_batch; for (const e of this.#scheduled) {
batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); next_batch.#scheduled.push(e);
}
this.#scheduled = [];
} else { } else {
next_batch = this; next_batch = this;
} }
@ -712,7 +770,7 @@ export class Batch {
// The microtask queue can contain the batch already scheduled to run right // The microtask queue can contain the batch already scheduled to run right
// after this one is finished, so throwing the invariant would be wrong here. // after this one is finished, so throwing the invariant would be wrong here.
if (DEV && !batch.#decrement_queued) { if (DEV && !batch.#decrement_queued) {
invariant(batch.#roots.length === 0, 'Batch has scheduled roots'); invariant(batch.#scheduled.length === 0, 'Batch has scheduled effects');
} }
// A batch was unskipped in a later batch -> tell prior batches to unskip it, too // A batch was unskipped in a later batch -> tell prior batches to unskip it, too
@ -768,14 +826,12 @@ export class Batch {
// Only apply and traverse when we know we triggered async work with marking the effects // Only apply and traverse when we know we triggered async work with marking the effects
// and know this won't run anyway right afterwards // and know this won't run anyway right afterwards
if (batch.#roots.length > 0 && !batch.#decrement_queued) { if (batch.#scheduled.length > 0 && !batch.#decrement_queued) {
batch.apply(); batch.apply();
for (var root of batch.#roots) { for (var root of batch.#resolve()) {
batch.#traverse(root, [], []); batch.#traverse(root, [], []);
} }
batch.#roots = [];
} }
batch.deactivate(); batch.deactivate();
@ -939,43 +995,7 @@ export class Batch {
return; return;
} }
var e = effect; this.#scheduled.push(effect);
while (e.parent !== null) {
e = e.parent;
var flags = e.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && e === active_effect) {
if (async_mode_flag) return;
// in sync mode, render effects run during traversal. in an extreme edge case
// — namely that we're setting a value inside a derived read during traversal —
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out. we also shouldn't bail out if we're
// updating a store inside a `$:`, since this might invalidate
// effects that were already visited
if (
(active_reaction === null || (active_reaction.f & DERIVED) === 0) &&
!legacy_is_updating_store
) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
e.f ^= CLEAN;
}
}
this.#roots.push(e);
} }
#unlink() { #unlink() {

@ -0,0 +1,41 @@
import { tick } from 'svelte';
import { test } from '../../test';
const buttons = '<button>a</button> <button>b</button> <button>shift</button> <button>pop</button>';
export default test({
async test({ assert, target }) {
await tick();
const [a, b, , pop] = target.querySelectorAll('button');
const shift = target.querySelectorAll('button')[2];
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>a</p><p>aa</p><p>1</p>`);
// start two independent batches, both blocked on their awaited expression
a.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>a</p><p>aa</p><p>1</p>`);
b.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>a</p><p>aa</p><p>1</p>`);
// resolve the newer (b) batch first. Committing it must not commit the
// still-pending `a` batch, whose async work has not completed — `a` must
// still read 'a', and the unrelated `c` update must not be blocked
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>b</p><p>ab</p><p>2</p>`);
// stale promise from the `a` batch's first run — resolving it does nothing
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>a</p><p>b</p><p>ab</p><p>2</p>`);
// the `a` batch's re-run await ('bb') resolves — everything is committed
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, `${buttons} <p>b</p><p>b</p><p>bb</p><p>2</p>`);
}
});

@ -0,0 +1,38 @@
<script>
let a = $state('a');
let b = $state('a');
let c = $state(0);
let n = 0;
let queued = [];
let first = true;
function push(v) {
if (first) {
first = false;
return v;
}
return new Promise((resolve) => {
queued.push(() => resolve(v));
});
}
// when a batch changing `b` commits, this writes `c` during that batch's
// flush phase — the write lands in a freshly-created batch
$effect(() => {
b;
c = ++n;
});
</script>
<button onclick={() => a = 'b'}>a</button>
<button onclick={() => b = 'b'}>b</button>
<button onclick={() => queued.shift()?.()}>shift</button>
<button onclick={() => queued.pop()?.()}>pop</button>
<p>{a}</p>
<p>{b}</p>
<p>{await push(a + b)}</p>
<p>{c}</p>
Loading…
Cancel
Save