Skip to content

Fix intent log browser freeze on save - #7

Merged
alecgeatches merged 2 commits into
trunkfrom
fix/intent-log-save-loop
Aug 12, 2026
Merged

alecgeatches merged 2 commits into
trunkfrom
fix/intent-log-save-loop

Conversation

@alecgeatches

@alecgeatches alecgeatches commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Previously when a block editor change arrived during a save or autosave, flushSyncIds() would reschedule itself with:

Promise.resolve().then( flushSyncIds )

This runs as a microtask, which then the browser runs next, then reschedules itself, and runs again, refilling the microtask queue indefinitely and not allowing the post save state to resolve:

Click to expand flushSyncIds loop
<anonymous> (http://localhost:8889/wp-content/plugins/gutenberg/packages/data/src/registry.ts#198)
select2 (http://localhost:8889/wp-content/plugins/gutenberg/packages/data/src/select.ts#32)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#139)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)
schedule (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#217)
flushSyncIds (http://localhost:8889/wp-content/plugins/gutenberg-sync-engines/includes/engines/intent-log/sync-id.js?ver=1786471856#145)

Instead, on retry use a setTimeout() to schedule a macro task that allows the post state to resolve. Also added a minor delay (100ms) as it doesn't appear to have an inherent risk to the design and gives more time for state to settle.

@alecgeatches
alecgeatches merged commit f636140 into trunk Aug 12, 2026
3 checks passed
chriszarate added a commit that referenced this pull request Aug 15, 2026
…apability gaps

Adapt the matrix runner to the trunk reorganization: the tests site now
comes from its own single-site config (.wp-env.tests.json — cli/wordpress
services, WP_ENV_PORT, a -tests- work-dir variant), and websocket combos
displace the dev env's auto-started 8787 daemon for the run's duration
(it serves the dev database; npm run env start brings it back).

de-rtc joins the default engine matrix (9 combos). Its lanes initially
reported reproducible divergence on every seed — all of it the DOCUMENTED
no-title-sync gap (docs/engine-comparison.md: title edits stay local), so
the runner now carries an ENGINE_CAPABILITIES map and disables title
actions for engines that cannot sync them; untouched titles stay
converged, so the oracle is unchanged. With the gap excluded, de-rtc
passes 3 seeds × 12 steps on both http-polling and websocket.

Re-verification on the rebased branch: intent-log/websocket 3/3 (the
duplicate-delivery fix holds; the final-save hang is gone — fixed
upstream by the sync-ID flush retry in #7). One open finding remains:
intent-log/http-long-polling seed 1 still diverges reproducibly at
step 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chriszarate added a commit that referenced this pull request Aug 16, 2026
…date-loss bug it found (#13)

* Add the engine × transport RTC fuzzer (npm run fuzz)

Adapt the Gutenberg RTC browser fuzzing pipeline (danluu/gutenberg
try/fuzz) to this plugin's pluggable architecture. tests/fuzzer/ holds the
core that finds bugs — a seeded, bounded-action-grammar Playwright spec
with per-step convergence, block-validity, and save/reload persistence
oracles, plus HTTP-only transient sync-fault injection — and a matrix
runner that sweeps it across every engine × transport combination.

Per combo the runner flips wp_sync_engine and the transport option via
wp-cli on the tests site, wipes wp_sync_storage rooms so no combo inherits
another engine's room lineage, and (for websocket combos) runs the
wp collaboration sync-server daemon through the generated compose file
with the port published and the daemon bound to 0.0.0.0, restarted per
combo because a long-running PHP process caches options at boot. Failing
seeds are rechecked once with traces on to split reproducible failures
from flakes; reproducible ones are grouped by normalized signature with a
replay command in summary.md.

The deliberate deltas from upstream are documented in the README: the
campaign/triage machinery (tmux supervisors, LLM tiers, novelty monitors,
operation ledger) is dropped; engine-neutral oracles replace the fork's
_crdt_document assertions; discovery over websocket waits on the
awareness UI because the fixture's wp-sync HTTP response wait never fires
when sync rides WS frames.

Smoke-tested across the full 6-combo matrix (1 seed × 4 steps): 5 lanes
green, and the sweep immediately surfaced a reproducible finding — under
intent-log/websocket a block insert fails to propagate to the peer while
the identical seed passes on both HTTP transports (fuzzer artifacts hold
the traces).

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

* Fix intent-log update loss over websocket: duplicate row delivery desynced the replica

Found by the engine × transport fuzzer on its first sweep (intent-log/
websocket, seed 1): one client's block insert never reached its peer while
the identical seed passed over both HTTP transports.

WebSocket frame capture showed the server voiding the intent with
disposition invalid-payload — its baseSeq was above the server's log head.
The head inflation came from duplicate delivery: the daemon answers a
client's frame from the CLIENT-supplied after cursor, but a broadcast
(triggered by the other socket's message) had already pushed newer rows
and advanced the socket's server-side cursor while that frame was in
flight. The overlapping windows redelivered a row, and the session layer
appends every received row at the replica cursor (the frozen core's
clientReceive contract is positional), so the duplicate counted as a new
log entry. From then on every local intent based above the server head
and voided — silent, permanent divergence. Yjs is immune (CRDT updates
are idempotent), which is why only the intent-log lane failed.

Two-layer fix:

- The daemon clamps each room request's after cursor to the socket's
  server-side cursor, making per-socket delivery monotonic. Reconnects
  still resync client-driven: a new socket subscribes at cursor 0, so the
  client's own cursor is honored.
- The intent-log session codec now tracks absorbed rows by intentId
  (unique per log entry) and skips redelivered intent and proposal rows —
  defense in depth against any transport that redelivers. The set resets
  on a horizon-reset re-bootstrap, where post-checkpoint rows are new to
  the rebuilt replica.

The new session test redelivers an accepted row and then authors: without
the guard the follow-up intent is voided; with it the sessions converge.
Verified end to end: the previously ~50%-failing two-user repro passed
10/10 against the fixed daemon, and the fuzz matrix (3 seeds × 12 steps
× 6 combos) shows the original signature gone with yjs-server/websocket
unaffected. Jest 340 passed.

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

* Fuzzer: target the split tests wp-env, sweep de-rtc, respect engine capability gaps

Adapt the matrix runner to the trunk reorganization: the tests site now
comes from its own single-site config (.wp-env.tests.json — cli/wordpress
services, WP_ENV_PORT, a -tests- work-dir variant), and websocket combos
displace the dev env's auto-started 8787 daemon for the run's duration
(it serves the dev database; npm run env start brings it back).

de-rtc joins the default engine matrix (9 combos). Its lanes initially
reported reproducible divergence on every seed — all of it the DOCUMENTED
no-title-sync gap (docs/engine-comparison.md: title edits stay local), so
the runner now carries an ENGINE_CAPABILITIES map and disables title
actions for engines that cannot sync them; untouched titles stay
converged, so the oracle is unchanged. With the gap excluded, de-rtc
passes 3 seeds × 12 steps on both http-polling and websocket.

Re-verification on the rebased branch: intent-log/websocket 3/3 (the
duplicate-delivery fix holds; the final-save hang is gone — fixed
upstream by the sync-ID flush retry in #7). One open finding remains:
intent-log/http-long-polling seed 1 still diverges reproducibly at
step 9.

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

* Fuzzer: converge on serialized content, not raw attribute objects

The long-polling lane's reproducible 'divergence' (seed 1) was the oracle
being stricter than persistence: after a reload, intent-log's snapshot
materialization reifies empty rich-text fields as explicit "" attributes
(values on lists, value/citation on quote) that createBlock-authored
trees omit. Both representations serialize to byte-identical content —
verified by rebuilding both captured states in a live editor — so no user
or save can distinguish them; only the raw-attribute JSON comparison
could. The asymmetry surfaced on long-polling because its traffic pattern
keeps the two representations stable through the convergence window,
while polling's cadence washes it out.

The spec now compares each page's SERIALIZED content + title (what
actually persists) instead of the fixture's normalized attribute trees,
keeping the invalid-block invariant unchanged. intent-log then passes
3 seeds × 12 steps on all three transports — including the previously
failing long-polling seed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant