Skip to content

fix: stop websocket reconnects from re-enabling noise cancellation against template policy - #3736

Open
cyril-k-031225 wants to merge 1 commit into
mainfrom
fix-noise-cancellation-ra
Open

fix: stop websocket reconnects from re-enabling noise cancellation against template policy#3736
cyril-k-031225 wants to merge 1 commit into
mainfrom
fix-noise-cancellation-ra

Conversation

@cyril-k-031225

@cyril-k-031225 cyril-k-031225 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

room.isNoiseCancellationEnabled had two writers, and one of them was destructive.

Writer 1transport/index.ts:1114, in internalConnect, writes the raw noiseCancellation /init feature flag.

Writer 2sdk/store/Store.ts, in handleNoiseCancellationPlugin, ANDed the template policy decision against its own prior value rather than against the init flag:

this.room.isNoiseCancellationEnabled = !!plugin?.enabled && !!this.room.isNoiseCancellationEnabled;

Once writer 1 overwrote the field, writer 2 had lost the information it needed and could not recover the correct value.

retrySignalDisconnectTask calls internalConnect on every websocket reconnect, so each reconnect reset the field to the raw init flag. Policy re-arrived and corrected it, but not instantly — a measured 158–483 ms window (4 runs) in which the Krisp gate was open.

Impact

A customer disabled noise cancellation in their template on June 8. It took effect for 99.6% of joins. The remaining 0.4% kept Krisp running and kept billing 10–50 min/day for 10 weeks.

Only 0.4% because attaching Krisp needs a local audio track to already exist when the gate reopens. On first connect the track is created after policy, so the window is unreachable; once preview completes the track persists and every reconnect becomes a live opportunity. That combination is common on flaky mobile — all 44 leaking peers were mobile.

Two amplifiers made a 300 ms race expensive: the gate is checked only at add time, so Krisp runs for the life of the track once attached (krisp.stop ≈ 0 daily); and billing charges the whole session, turning one peer × 19.5 min of exposure into 4 peers × 53.6 min billed (~2.8×).

Reproduced on a second account, session 6a8620b8d7913cfe895a014f — Krisp running with isNoiseCancellationEnabled: false.

Second leak, no race required

addPluginsToRoles dispatched on the keys present in params.plugins. A template whose policy omitted noiseCancellation never ran the policy writer at all, so the room kept the raw init flag indefinitely — and a policy that later dropped the key could not revoke an earlier enable.

Fix

The single mutable field becomes two independent source fields plus a get-only accessor:

isNoiseCancellationEnabledFromInit = false;    // written only by HMSTransport
isNoiseCancellationEnabledFromPolicy = false;  // written only by Store

get isNoiseCancellationEnabled(): boolean {
  return this.isNoiseCancellationEnabledFromInit && this.isNoiseCancellationEnabledFromPolicy;
}

Three properties follow:

  1. Both writes are idempotent. No interleaving of init, policy, and reconnect can yield true unless both sources genuinely say true.
  2. The window is no longer exploitable. Both sources default to false, so the pre-policy state denies instead of allows. The 158–483 ms interval still exists in time; it no longer grants anything.
  3. A third destructive writer can't be added. Assigning to the accessor is a compile error.

Separately, handleNoiseCancellationPlugin is now called unconditionally from setKnownRoles instead of from the key-driven switch, so an absent noiseCancellation key resolves to disabled. Whiteboard and transcription stay in the switch, where absence correctly means no-op — they grant permissions additively.

Behavior change worth flagging

Templates that have the init flag enabled and no noiseCancellation key in policy currently get working noise cancellation. After this change they do not. That is the intended semantics — the template is the authority and an absent key means "not configured" — but it is a real behavior change. Worth counting how many live templates are in that state before this ships.

Public API

Not affected. The two source fields live on the internal Room class, which is not re-exported from the package barrel; consumers get HMSRoom from ./schema, which is unchanged. interfaces/room.ts gains a readonly marker, and that file is not part of the public export surface either. Verified against the built dist/index.d.ts.

Tests

Two tests, one per defect. Both were verified to be load-bearing by mutating the source to reintroduce each bug:

  • stays disabled when a reconnect re-writes the init flag after policy said no — the regression test for the race. Fails if the derived AND is weakened.
  • revokes the policy source when a later policy drops the noiseCancellation key — fails if the writer goes back to key-driven dispatch.

Restoring the key-driven switch fails only the second test, so the two discriminate cleanly rather than overlapping.

packages/hms-video-store: 32 suites, 235 tests, all passing. Lint and tsc clean. Full monorepo build succeeds across all 10 projects.

Deliberately out of scope

  • Revocation. Nothing removes an already-attached Krisp when the derived value goes false. This PR prevents new attaches; a peer that attached during the pre-fix window keeps it until the track or session ends.
  • Roomkit auto-arm. AudioVideoToggle.tsx:335 re-adds Krisp on audio-track churn whenever the room gate and layout appdata both allow. Unchanged — it is the mechanism that converted an open gate into an attach, but the gate is now closed.
  • Layout and dashboard. noise_cancellation.enabled_by_default stays true, and the dashboard switch still renders disabled-and-unchecked once the template flag is off — invisible and unclearable. Separate repos.
  • Immediate mitigation. Killing the noiseCancellation Unleash flag for the affected account is an ops action independent of this branch.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
dashboard-app Ready Ready Preview Aug 21, 2026 8:36am

Request Review

@cyril-k-031225 cyril-k-031225 added the claude-review Trigger automated Claude code review label Aug 21, 2026
room.isNoiseCancellationEnabled had two writers. HMSTransport wrote the
raw /init feature flag on every connect, and Store ANDed the template
policy decision against the field's own prior value rather than against
the init flag:

    room.isNoiseCancellationEnabled =
      !!plugin?.enabled && !!room.isNoiseCancellationEnabled;

That made the policy writer destructive — once the init writer had
overwritten the field, the correct value was unrecoverable. Since
retrySignalDisconnectTask calls internalConnect on every websocket
reconnect, each reconnect reset the flag to the raw init value and
reopened the Krisp gate for 158-483ms until policy re-arrived. A peer
whose audio track already existed could attach Krisp inside that window,
which is how a template with noise cancellation disabled kept billing on
0.4% of joins.

Each writer now owns its own field and isNoiseCancellationEnabled is a
get-only accessor over their AND. Both writes are idempotent, both
sources default to false so the pre-policy state denies rather than
allows, and the accessor makes a third destructive writer a compile
error.

Also resolve the policy flag unconditionally from setKnownRoles instead
of from the key-driven switch in addPluginsToRoles. Dispatching on key
presence meant a template whose policy omitted noiseCancellation never
ran the writer at all, so such rooms kept the raw init flag and a policy
that later dropped the key could not revoke an earlier enable.
Whiteboard and transcription stay in the switch, where absence correctly
means no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review Trigger automated Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants