Skip to content

Commit 4d819af

Browse files
authored
fix(shared-state): cap syncIds with FIFO eviction to stop memory growth (#73)
1 parent 59a592e commit 4d819af

4 files changed

Lines changed: 33 additions & 173 deletions

File tree

packages/devframe/src/utils/shared-state.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,20 @@ describe('shared-state', () => {
304304
expect(state.syncIds.has('sync-2')).toBe(true)
305305
})
306306

307+
it('caps syncIds and evicts oldest-first (FIFO), keeping recent de-dup', () => {
308+
const state = createSharedState({ initialValue: { count: 0 } })
309+
for (let i = 0; i < 1500; i++) {
310+
state.mutate((draft) => {
311+
draft.count = i
312+
}, `sync-${i}`)
313+
}
314+
expect(state.syncIds.size).toBeLessThanOrEqual(1000)
315+
// The most recent id is retained (still de-duped)...
316+
expect(state.syncIds.has('sync-1499')).toBe(true)
317+
// ...the oldest was evicted.
318+
expect(state.syncIds.has('sync-0')).toBe(false)
319+
})
320+
307321
it('should able to sync between two shared states', () => {
308322
const state1 = createSharedState({
309323
initialValue: { count: 0 },

packages/devframe/src/utils/shared-state.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ export interface SharedStateOptions<T> {
7171
enablePatches?: boolean
7272
}
7373

74+
/**
75+
* Upper bound on retained syncIds. Loop echoes arrive near-immediately, so a
76+
* generous window preserves de-dup while capping memory on long-lived,
77+
* frequently-mutated states (e.g. a 1s terminal poll).
78+
*/
79+
const MAX_SYNC_IDS = 1000
80+
81+
function rememberSyncId(syncIds: Set<string>, syncId: string): void {
82+
syncIds.add(syncId)
83+
if (syncIds.size > MAX_SYNC_IDS) {
84+
const oldest = syncIds.values().next().value
85+
if (oldest !== undefined)
86+
syncIds.delete(oldest)
87+
}
88+
}
89+
7490
export function createSharedState<T extends object>(
7591
options: SharedStateOptions<T>,
7692
): SharedState<T> {
@@ -91,15 +107,15 @@ export function createSharedState<T extends object>(
91107
return
92108
enableImmerPatches()
93109
state = applyPatches(state as unknown as Objectish, patches as unknown as Patch[]) as T
94-
syncIds.add(syncId)
110+
rememberSyncId(syncIds, syncId)
95111
events.emit('updated', state, undefined, syncId)
96112
},
97113
mutate: (fn, syncId = nanoid()) => {
98114
// Avoid loop syncs
99115
if (syncIds.has(syncId))
100116
return
101117

102-
syncIds.add(syncId)
118+
rememberSyncId(syncIds, syncId)
103119
if (enablePatches) {
104120
const [newState, patches] = produceWithPatches(
105121
state as unknown as Objectish,

plans/006-bound-sharedstate-syncids.md

Lines changed: 0 additions & 170 deletions
This file was deleted.

plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ changes are allowed as long as they're marked).
1818
| 003 | Reject cross-origin WebSocket upgrades ⚠️ | security | P1 | S || DONE |
1919
| 004 | Stop git argument injection via `ref`/`hash` | security/bug | P1 | S || TODO |
2020
| 005 | Don't cache a rejected RPC `setup()` promise | bug | P1 | S || TODO |
21-
| 006 | Bound `SharedState.syncIds` (memory leak) | bug | P1 | S || TODO |
21+
| 006 | Bound `SharedState.syncIds` (memory leak) | bug | P1 | S || DONE |
2222
| 007 | Behavioral tests for the auth/OTP trust boundary | tests | P1 | S || TODO |
2323
| 008 | Cap hub terminal buffer + reject restart-after-terminate | bug | P2 | S || TODO |
2424
| 009 | Fix two server-side streaming lifecycle leaks | bug | P2 | S-M || TODO |

0 commit comments

Comments
 (0)