Skip to content

Throttle persona:state:snapshot — fix 8GB browser memory leak - #288

Merged
joelteply merged 1 commit into
mainfrom
fix/snapshot-event-throttle
Mar 9, 2026
Merged

Throttle persona:state:snapshot — fix 8GB browser memory leak#288
joelteply merged 1 commit into
mainfrom
fix/snapshot-event-throttle

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

Summary

  • emitSnapshot() in PersonaState was firing on every recordActivity(), rest(), and updateInboxLoad() with zero throttling
  • 15 personas × multiple calls per cycle = 200+ events/second flooding the WebSocket to browser
  • EventRateLimiter was blocking at 200/s but the damage was done — Opera hit 8GB
  • Fix: throttle to max 1 emit per 2s per persona with trailing edge so latest state always arrives

Test plan

  • npm run build:ts — zero errors
  • npm start — system comes up
  • 25s runtime: zero EVENT TRENDING / EVENT CASCADE BLOCKED warnings (was immediate before)

…ser leak

emitSnapshot() fired on every recordActivity(), rest(), and updateInboxLoad()
with no throttling. 15 personas × multiple calls per cycle = 200+ events/s
flooding the WebSocket to browser. EventRateLimiter was blocking at 200 but
damage was done. Now throttled to max 1 emit per 2s per persona with trailing
edge to ensure latest state always gets sent.
Copilot AI review requested due to automatic review settings March 9, 2026 01:13
@joelteply
joelteply merged commit b345067 into main Mar 9, 2026
2 of 4 checks passed
@joelteply
joelteply deleted the fix/snapshot-event-throttle branch March 9, 2026 01:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an 8GB browser memory leak caused by unthrottled persona:state:snapshot event emissions flooding the WebSocket. The fix adds a leading+trailing throttle (2s per persona) so the latest state always arrives without overwhelming the browser.

Changes:

  • Added a 2-second throttle with trailing edge to emitSnapshot() in PersonaStateManager, splitting the emission logic into emitSnapshot() (throttle gate) and emitSnapshotNow() (actual emit).
  • Reused the stored _lastSnapshotTime as the payload timestamp instead of calling Date.now() a second time.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 273 to +280
* Uses DataDaemon.jtagContext for cross-context (server→browser) delivery.
* Without the context, bare Events.emit() stays server-local.
*/
/**
* Throttled snapshot emission — max once per SNAPSHOT_THROTTLE_MS.
* With 15 personas each calling this on every cycle (3-5s) plus rest(),
* unthrottled emission hit 200+/s and flooded the WebSocket to browser.
*/

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two consecutive JSDoc blocks are stacked on emitSnapshot() — the original description (lines 271-275) and the new throttle rationale (lines 276-280). Only the last JSDoc block before a declaration is used by tooling (IDE tooltips, generated docs). The old block is now dead documentation. These should be merged into a single JSDoc comment that covers both the purpose (browser-side snapshot emission via jtagContext) and the throttling behavior.

Suggested change
* Uses DataDaemon.jtagContext for cross-context (server→browser) delivery.
* Without the context, bare Events.emit() stays server-local.
*/
/**
* Throttled snapshot emission max once per SNAPSHOT_THROTTLE_MS.
* With 15 personas each calling this on every cycle (3-5s) plus rest(),
* unthrottled emission hit 200+/s and flooded the WebSocket to browser.
*/
* Uses DataDaemon.jtagContext for cross-context (server→browser) delivery;
* without the context, bare Events.emit() stays server-local.
*
* Emission is throttled max once per SNAPSHOT_THROTTLE_MS. With many personas
* each calling this on every cycle (3–5s) plus rest(), unthrottled emission
* can reach 200+/s and flood the WebSocket to the browser.
*/

Copilot uses AI. Check for mistakes.
Comment on lines 49 to +54
/**
* PersonaStateManager: Manages internal state and traffic decisions
*/
/** Minimum interval between snapshot emissions per persona (ms) */
const SNAPSHOT_THROTTLE_MS = 2000;

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SNAPSHOT_THROTTLE_MS constant was inserted between the class-level JSDoc (lines 49-51: PersonaStateManager: Manages internal state and traffic decisions) and the class PersonaStateManager declaration at line 55. In TypeScript/JSDoc, a /** */ comment documents the next declaration — so the class-level JSDoc now documents SNAPSHOT_THROTTLE_MS instead of the class, and the class itself loses its documentation. Move the constant above the class JSDoc or inside the class as a private static readonly to preserve the original documentation association.

Suggested change
/**
* PersonaStateManager: Manages internal state and traffic decisions
*/
/** Minimum interval between snapshot emissions per persona (ms) */
const SNAPSHOT_THROTTLE_MS = 2000;
/** Minimum interval between snapshot emissions per persona (ms) */
const SNAPSHOT_THROTTLE_MS = 2000;
/**
* PersonaStateManager: Manages internal state and traffic decisions
*/

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +297
const now = Date.now();
if (now - this._lastSnapshotTime < SNAPSHOT_THROTTLE_MS) {
// Schedule a trailing emit so the latest state always gets sent
if (!this._snapshotPending) {
this._snapshotPending = true;
setTimeout(() => {
this._snapshotPending = false;
this.emitSnapshotNow();
}, SNAPSHOT_THROTTLE_MS - (now - this._lastSnapshotTime));
}
return;
}

this.emitSnapshotNow();

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a subtle edge case: when a leading-edge emit passes the throttle check while _snapshotPending is still true from a previously scheduled trailing timer, both the leading emit and the trailing timer will fire, causing a double-emit within milliseconds. This happens when setTimeout fires slightly late and a new call arrives just after the throttle window expires but before the pending timer runs.

To fix, either (a) store the timer ID and clear it when a leading emit fires, or (b) reset _snapshotPending = false at line 297 before calling this.emitSnapshotNow() in the leading path. Option (a) is more robust:

Store the timeout handle (e.g., private _snapshotTimer: ReturnType<typeof setTimeout> | null = null) and call clearTimeout(this._snapshotTimer) before the leading-edge emitSnapshotNow() call.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants