Skip to content

fix(viewer): tear down the room model when an abandoned join returns (#3016) - #3048

Merged
louistrue merged 4 commits into
mainfrom
fix/3016-orphan-room-model-on-leave
Aug 22, 2026
Merged

fix(viewer): tear down the room model when an abandoned join returns (#3016)#3048
louistrue merged 4 commits into
mainfrom
fix/3016-orphan-room-model-on-leave

Conversation

@BIMvoice

Copy link
Copy Markdown
Collaborator

Closes #3016. The abandoned-join guard returned without running or nulling recipientLiveTeardown, so the reconstructed room:<id> model outlived the session the user left — until the next stopCollab happened to clear it.

RED:

not ok 1 - does not leave an orphan room:<id> model behind
  error: 'the room model outlived the session the user left'
  expected: false / actual: true

The guard now runs ownLiveTeardown — this join's own closure, held in a startCollab-scoped local — before session.dispose().

A second leak, found while fixing the first

My initial shape (run own teardown, clear the slot if it is ours) still failed the two-join case. Instrumentation showed why: a stale continuation resumes after a newer join has published its teardown, and the unconditional assignment overwrote it — so the newer room's model was never removed on the next Leave.

That is the mirror image of the reported leak, it is pre-existing, and nothing covered it. Publishing into the module-level slot is now conditional on this join still being the live one.

Both checks read collabRoomId against this join's roomId — the same granularity as every other re-check in startCollab — so neither distinguishes a rejoin of the same room. Left as-is deliberately and stated in the changeset rather than silently narrowed.

Reverse direction checked

A model the session did not create (local-file) survives the leave. And after the two-join race, the live room's model survives and its teardown is still armed — leaving removes it. Both matter: a fix that removed too much would look identical in the reported test.

2 guards, 2 mutants, 2 killed: guard does not run the teardown → tests 1 and 3 fail; unconditional publish → test 3 fails.

New tests 3 pass; collabSlice.leave-during-join-race, entry-race, gates, session-failure 16 pass, 0 fail. tsc --noEmit clean, oxlint clean.

Test infrastructure: src/test/collab-hydrate-gate-hook.mjs, a per-call loader gate modelled on the existing collab-session-race-hook.mjs. It is the only way to park a join between the model registration and the guard — every earlier await is followed by a collabRoomId re-check that returns before the model exists, so no other seam can reach the window.

Deliberately not touched

The issue notes two other homes not cleared on leave: the ?room=&t= URL params, and ShareDialog.tsx:174 minting a new room while open. Both left alone — they are separate surfaces from the store teardown, the issue frames them as "worth recording", and the URL one changes reload behaviour I have no test seam for here.

🤖 Generated with Claude Code

…struct (#3016)

The recipient join registers `room:<id>` as a real model and installs the
teardown that removes it only after `await reconstruct()` returns. The
abandoned-join guard sits below that assignment and returned without running
it, leaving the model in `models` — and the doc listener attached — until the
next `stopCollab`.

The guard now runs the teardown this join installed. It runs the join's own
closure rather than the module-level slot: a newer join may already own that
slot, and running its teardown would drop the room model of the session the
user is actually in. Publishing into the slot is likewise now conditional on
this join still being the live one, which closes the mirror-image leak — a
stale continuation used to overwrite a newer join's teardown, so the newer
room's model was never removed on the next Leave.
@BIMvoice
BIMvoice requested a review from louistrue as a code owner August 21, 2026 15:17
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@BIMvoice, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a22526fa-745f-4483-84e8-8c8f3fe54a78

📥 Commits

Reviewing files that changed from the base of the PR and between 210961e and 73571eb.

📒 Files selected for processing (4)
  • .changeset/leave-mid-join-removes-the-room-model.md
  • apps/viewer/src/store/slices/collabSlice.leave-after-reconstruct.test.ts
  • apps/viewer/src/store/slices/collabSlice.ts
  • apps/viewer/src/test/collab-hydrate-gate-hook.mjs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Viewer benchmark

✅ No threshold regressions detected.

01_Snowdon_Towers_Sample_Structural(1).ifc

Baseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 1950ms 2905ms -32.9% +50%
firstVisibleGeometryMs 2421ms 3652ms -33.7% +50%
streamCompleteMs 2903ms 3598ms -19.3% +50%
spatialReadyMs 1387ms 1032ms +34.4% +50%
metadataCompleteMs 1817ms 3063ms -40.7% +50%
totalWallClockMs 3100ms 3700ms -16.2% +50%

AC20-FZK-Haus.ifc

Baseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 329ms 1075ms -69.4% +50%
firstVisibleGeometryMs 1278ms 1572ms -18.7% +50%
streamCompleteMs 1003ms 1980ms -49.3% +50%
spatialReadyMs 1033ms 915ms +12.9% +50%
metadataCompleteMs 1145ms 1392ms -17.7% +50%
totalWallClockMs 1500ms 3300ms -54.5% +50%

Refresh the baseline from a CI run: dispatch the Benchmark workflow with record_baseline, download the benchmark-baseline artifact, and commit baseline.json (see tests/benchmark/README.md).

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
ifc-lite-dev Ignored Ignored Preview Aug 22, 2026 5:43am
ifc-lite-viewer-embed Ignored Ignored Aug 22, 2026 5:43am

… on CI's node

`collabSlice.leave-after-reconstruct.test.ts` timed out on `Viewer tests
(shard 3)` — 60s on the first case, then the 120s file cap — while passing
locally. The cause is not the diff under test: the loader hook that parks the
recipient reconstruct never applied.

`module.registerHooks` (synchronous, in-thread) landed in Node 22.15.0 and tsx
feature-detects it. On a newer 22 the `@/lib/collab/geometry-sync` alias is
resolved to a `file://` URL by that sync path BEFORE this async `register()`
hook is consulted, so `specifier === TARGET` misses, `hydrateGeometryFromRoom`
is never wrapped, `__collabHydrateGated` never fires, and every case in the
file waits forever at `await atGate`.

`collab-session-race-hook.mjs` already documents this exact trap; this hook
was written against the specifier and reproduced the bug.

Reproduced rather than inferred: on 22.13.1 the file passes, on 22.23.2 it
fails 3 of 3 with the CI signature byte for byte — the same
`annotation sync setup failed` at collabSlice.ts:1229 (the live path, i.e. the
abandoned-join guard was never reached), the same
`Cannot unregister unknown model: room:room-3016` from the next case running
the stale module-level teardown slot, the same 60s and 120s timeouts. After
the fix: 20 of 20 on 22.23.2 and 20 of 20 on 22.13.1.

The gate is still doing its job rather than passing vacuously: with
`collabSlice.ts` reverted to main and the fixed hook, 22.23.2 reports two
assertion failures, not timeouts.
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Two different things were red here. One is your flake; one was genuinely ours, and it was not a flake at all — it fails 0/3 deterministically on the Node CI actually runs. Fixed and pushed 73571eb46.

Shard 1 — yours

useSandbox.runSupersession.test.tsx tests 11 and 12, 0 !== 1 and 2 !== 1 — verbatim the signature in #3061's message. This PR's diff touches four files, none under src/hooks/. The branch did not contain #3061 (git log --oneline upstream/main ^HEAD listed it as main-only, and apps/viewer/package.json still carried --test-concurrency=4). Merged main.

Shard 3 — ours, and worth reading

collabSlice.leave-after-reconstruct.test.ts is added by this PR, so it cannot be inherited. It timed out at 60 s then 120 s.

Root cause is not the teardown logic and not concurrency. The test parks the recipient reconstruct with a node:module register() loader hook that matched specifier === '@/lib/collab/geometry-sync'. module.registerHooks — synchronous, in-thread — landed in Node 22.15.0, and tsx feature-detects it: on a newer 22 the @/… alias is normalised to a file:// URL before the async register() hook is consulted. The exact-match misses, hydrateGeometryFromRoom is never wrapped, __collabHydrateGated never fires, and every case hangs on await atGate.

My machine runs 22.13.1. CI pins node-version: 22, which floats to 22.23.2. So this is invisible locally and deterministic in CI.

Verified rather than inferred: downloaded 22.23.2 and ran the file unchanged. Fails 3/3 with your CI signature byte for byte — the same [collab] annotation sync setup failed … at startCollab (collabSlice.ts:1229), which proves the live path ran and the abandoned-join guard was never reached, and the same [FederationRegistry] Cannot unregister unknown model: room:room-3016 from the next case running the stale module-level teardown slot.

Node 22.13.1 Node 22.23.2 (CI's)
before fix 20/20 0/3
after fix 20/20 20/20

Concurrency was never it: 8/8 green locally at concurrency 4 under ten CPU hogs.

The fix is one file, apps/viewer/src/test/collab-hydrate-gate-hook.mjs — match the resolved URL as well as the bare specifier, plus the parentURL guard the wrapper needs to avoid wrapping itself. No production code, no assertion touched, no skip, no timeout change.

Not vacuous either way: with collabSlice.ts reverted to main and the fixed hook, 22.23.2 reports two assertion failures rather than timeouts — the gate still catches the bug it was written for.

Full shards on 22.23.2 at concurrency 1: shard 1 1328 pass / 0 fail, shard 3 1278 pass / 0 fail / 2 skipped.

A correction on my own reasoning, and a suggestion

I had been treating the shard number as identifying: "this is shard 3, so your cleared shard-1 flake cannot explain it." That is invalid — sharding is NR % 4 over a sorted find, so a file's index shifts whenever a branch's test-file set differs from main's. On #2979 the same useSandbox file sits in shard 2. What identifies a failure is the file and the assertion, not the label. It happened to be true here that shard 3 was ours, but for the wrong reason.

The suggestion: the repo's sibling hook collab-session-race-hook.mjs already documents this exact trap in a 20-line comment, and the new hook walked straight into it anyway. That is two CI-only hangs from the same cause, and a third hook written the same way will hang the same way. There is a case for a check flagging register()-based loader hooks that match on a bare specifier. Happy to build it if you want it — it is the kind of thing that is cheap now and expensive on the third occurrence.

@louistrue
louistrue merged commit 9b29946 into main Aug 22, 2026
24 checks passed
@louistrue
louistrue deleted the fix/3016-orphan-room-model-on-leave branch August 22, 2026 07:41
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.

Leaving a collab session can leave an orphan room:<id> model in the store

2 participants