Skip to content

Track jsPsych#3694's updated multiplayer contract - #90

Merged
jodeleeuw merged 3 commits into
mainfrom
fix/core-contract-3694
Sep 24, 2026
Merged

jodeleeuw merged 3 commits into
mainfrom
fix/core-contract-3694

Conversation

@jodeleeuw

Copy link
Copy Markdown
Member

Follows the contract changes made on jsPsych#3694 after a review of the core MultiplayerAPI. The core side is commits 02e113c3, 96515f67, 5e3f18ca and cfe4c340 on that PR.

What changed in core

  • A pending wait() rejects with a MultiplayerCancelledError when the experiment ends or is aborted, instead of hanging. jsPsych also cancels multiplayer subscriptions in abortExperiment() and after on_finish.
  • push(), update() and wait() reject rather than throwing when the API is not connected.
  • get(), getAll(), subscriber arguments and the wait() result are JSON copies, so session data must be JSON-serializable.
  • update() merges onto this client's last write and coalesces writes issued faster than the backend confirms them.
  • wait() treats null, negative and non-finite timeouts as no timeout; participantId is null until connect() resolves.

Fixes

  • scoreboard: a cancelled wait took the backend-error path, so after abortExperiment() the plugin logged an error and drew a final board over the cleared display, leaving a Continue button that called finishTrial after the run had ended. After disconnect(), getAll() on that path threw inside a void-started async function, stranding the participant on the waiting message.
  • countdown and draw: both drove repeating timers with a raw setInterval, which survives abortExperiment(). Countdown could call finishTrial after the run ended; draw kept writing to the session indefinitely. Both now tick through jsPsych's timer registry.
  • chat and reference-game: each send rebuilt this client's whole message list from the adapter's cache, so two quick sends against a lagging cache could drop a message. Each now keeps its own messages in a local array.
  • adapter-multiplayer-firebase: connect() marked itself connected before arming disconnect cleanup, so a failure there left a connected-looking adapter with a leaked listener, an owned app that disconnect() never released, and a retry that silently did nothing.
  • jatos adapter example: called jsPsych.pluginAPI.connect, a TypeError.

Cleanup

  • resolveMultiplayerApi() reads only jsPsych.multiplayer; the pluginAPI fallback is gone, since any build exposing the API there predates this contract.
  • Hand-rolled read-merge-write against a client's own slot is now update() (choice, countdown, match, role, scoreboard, reference-game, and the live-scoreboard example).
  • Each plugin's local API mirror carries the new types and docs, plus an isMultiplayerCancelledError helper where the plugin waits.
  • Docs site, READMEs and examples updated; every example and the tutorial re-pinned to a preview build carrying the current contract.

Testing

npm test — 576 tests in 26 suites pass. Mocks modelled the old contract (live snapshots, no cancellation, null timeouts firing at once) and could not catch any of the bugs above, so they now follow the real one. Each plugin that waits has a cancelled-wait test, and the timer, message-race and Firebase fixes have regression tests that were each checked to fail against the old code.

Notes for review

  • Chat's failed-send behaviour changed. A failed send is now resent by the next one, so a message that failed still reaches the group. Previously it was rendered locally and then lost, leaving that participant's view permanently out of step.
  • Not done, say if you want it: both chat and reference-game build the saved transcript in end() from getAll(), so with a lagging cache a message the participant saw can be missing from the trial data.
  • ready still replaces its whole slot with push() rather than merging, since that is documented behaviour.
  • Requires a core build with the current contract; it will not work against older #3694 previews.

🤖 Generated with Claude Code

jodeleeuw and others added 3 commits September 17, 2026 18:57
A cancelled wait() (experiment end, abort, disconnect) now rejects with a
MultiplayerCancelledError instead of hanging, so the plugins that wait on the
group stop quietly rather than treating it as a timeout or a backend failure.
Scoreboard previously logged an error and drew a final board over the cleared
display, leaving a Continue button that called finishTrial after the run ended;
it also called getAll() on failure paths, which now throws once disconnected.

Also: resolve the API only from jsPsych.multiplayer (pre-#3694 pluginAPI builds
predate this contract), replace hand-rolled read-merge-write with update(), and
stop normalizing timeouts core now handles.

Two bugs found alongside that work: countdown and draw drove repeating timers
with a raw setInterval that survived abortExperiment(), so countdown could
finishTrial after the run ended and draw kept writing forever — both now tick
through jsPsych's timer registry. Chat and reference-game rebuilt this client's
whole message list from the adapter cache on every send, so two quick sends
could drop a message; each now keeps its own messages locally.

Mocks modelled the old contract (live snapshots, no cancellation, null timeouts
firing at once) and could not catch any of this; they now follow the real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
connect() set `connected` before arming onDisconnect cleanup and wiring the
reconnect handler, so a failure there rejected while leaving an adapter that
looked connected, with a live session listener, an owned app that disconnect()
would never delete, and a retry that returned immediately without reconnecting.
It now flips `connected` last and tears down on any failure.

Also keep the last pushed payload JSON-encoded rather than by reference, so a
caller mutating the object it pushed can't change what a reconnect re-sends,
and refresh adapter comments for the new core contract (snapshots are copied by
the API; core cancels subscriptions before calling adapter.disconnect()).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference and introduction pages still said nothing cancels subscriptions
automatically, told readers to await each update(), and documented participantId
as a plain string; they now cover cancellation, copied snapshots, the timeout
rules, and rejections when not connected. Setup snippets use an async wrapper,
since top-level await fails in a classic <script> tag.

Every example and the tutorial move from the eddf2f62 preview build to one
carrying the current contract, and examples/README.md now says all of them are
pinned together. Also: the JATOS adapter example called jsPsych.pluginAPI.connect
(a TypeError), the root README linked the closed #3692, and the live-scoreboard
and draw-room examples hand-rolled read-merge-write or dropped rejections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jodeleeuw
jodeleeuw merged commit ade7b95 into main Sep 24, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 24, 2026
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