Repository navigation
Description
The Breakout Task keeps running after it reports completion. runTask never stops the requestAnimationFrame loop and never removes its keydown, keyup and mousemove listeners from document, so the game keeps redrawing and responding to input after it calls onComplete. Production mostly hides this, because ScalarInstrumentRenderer replaces the iframe with the summary once onSubmit resolves, and that destroys the iframe's document. Until then, or for good if the submit fails, the game is still live. After a loss, the next frame takes another life, so the canvas shows Lives: -1 and serves the ball again. After a win, the ball keeps bouncing among the empty brick rows. If it then gets past the paddle three times, onComplete fires a second time with livesRemaining: 0, and InteractiveContent submits each done event it receives.
Where
packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:
:111-116and:188-192:onComplete(...)is called, anddrawcarries on.:211:requestAnimationFrame(draw)is scheduled unconditionally at the end of every frame.:214-216: the listeners are added and never removed.
Reproduce
- Start the Breakout Task and let the ball past the paddle three times.
- Watch the canvas after
donehas fired, before the summary replaces it. If you run the instrument outside the renderer, keep watching indefinitely.
Actual: the ball is served again and the lives counter goes to Lives: -1. The arrow keys and mouse still move the paddle.
Expected: the game freezes on the final frame once it reports completion, and done is called exactly once.
Tests
packages/instrument-library/src/__tests__/DNP_BREAKOUT_TASK.test.ts. Its afterEach removes every listener recorded through addDocumentListener, which works around the leak. When fixing:
- add a test asserting that no further frame is requested after
doneis called (nextFramestaysundefined); - add a test asserting that a key press after completion does not move the paddle;
- once the source removes its own listeners, drop the cleanup loop from
afterEach.
Suggested fix
Have a finished flag, or a single complete(data) helper, that removes the three listeners, stops scheduling requestAnimationFrame (or calls cancelAnimationFrame), and then calls onComplete once.
Found while raising unit test coverage in #1632.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.tshas not changed since 4caaa70 (2025-06-30), so the cited lines still hold.A loss also reports twice
The second
donedoes not need a win. The ball that is re-served atLives: -1(:187-199) keeps breaking bricks, and when it breaks the last one,collisionDetectioncallsonCompleteagain withlivesRemaining: -1(:111-116). In a Node port ofdrawwith no input, the game is lost 1064 frames after Start with 14 bricks broken, and the seconddonefollows 182 frames (about 3 s at 60 Hz) later. Every scalegetScalecan pick lands within a few frames of that.z.number().int()(:10) accepts -1, so a first save that fails or takes longer than those 3 s is followed by a savedlivesRemaining: -1. On the win path the seconddonecomes afterlivesRemainingmore misses, not always three: a win with one life left reports again on the next miss.The playground example
apps/playground/src/instruments/examples/interactive/Interactive-With-Vanilla/index.tshas the same loop and listeners (:51-53,:87,:160,:182), so it breaks the "calldoneexactly once" rule inpackages/instrument-guidelines/AGENTS.md:541too. Fix it in the same PR or a follow-up. It cannot get a unit test either way: the playground project runs in node and has no environment for/runtime/v1(apps/playground/AGENTS.md, Tests).On the suggested fix
cancelAnimationFramewould cancel nothing. BothonCompletecalls run insidedraw, before:211requests the next frame. Guard the request instead:if (!finished) requestAnimationFrame(draw).drawrenders (:170-175) before it updates (:176-210), so only stopping the loop freezes the canvas one step early: the last brick still drawn withScore: 14, orLives: 1. Havecompletedraw the frame once more without stepping.AbortControllerreplaces the threeremoveEventListenercalls: pass{ signal }to eachaddEventListenerand callabort()incomplete. It also covers thepointermovelistener that Breakout Task paddle does not follow a finger dragged across a touch screen #1672 proposes. happy-dom 20.11.15 supportssignal.Tests
In
DNP_BREAKOUT_TASK.test.ts:lastPaddleX()is the same whether or not they were removed. Assert instead that thesignalpassed in every call inaddDocumentListener.mock.callsis aborted.afterEachloop. Most tests stop mid-game, where the listeners are still attached.nextFramein both completion tests. With the final redraw, the loss test can also expect the lastfillTextcall to be('Lives: 0', 415, 20).e2e, in
testing/src/specs/instrument-completion.spec.ts: the demo seeds the Breakout Task (apps/api/src/demo/demo.service.ts:72), and a game with no input is lost. Press Start with the keyboard so nomousemovemoves the paddle. Answer the firstPOST /v1/instrument-recordswith a 500 throughpage.route, which keeps the iframe mounted. Wait for that POST, then 10 s more, and assert that no second POST was sent; onmainone is. Use a 500, not a slow response: #1668's fix ignores a seconddonewhile the first save is pending, so with a slow response the test would pass without this fix. The loss alone takes about 18 s, so with the session setup and the wait the test exceeds Playwright's 30 s default (testing/playwright.config.tssets no test timeout). Give it its own withtest.describe.configure({ timeout }), asgateway-assignment.spec.tsdoes. #1825 also appends to this spec, so expect a conflict.#1640, #1669 and #1672 change the same source and test file. One PR for all four saves three rebases.
Triage note written with Claude Code. Code references point at
main@d86740e.