Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/devframe/src/utils/shared-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,20 @@ describe('shared-state', () => {
expect(state.syncIds.has('sync-2')).toBe(true)
})

it('caps syncIds and evicts oldest-first (FIFO), keeping recent de-dup', () => {
const state = createSharedState({ initialValue: { count: 0 } })
for (let i = 0; i < 1500; i++) {
state.mutate((draft) => {
draft.count = i
}, `sync-${i}`)
}
expect(state.syncIds.size).toBeLessThanOrEqual(1000)
// The most recent id is retained (still de-duped)...
expect(state.syncIds.has('sync-1499')).toBe(true)
// ...the oldest was evicted.
expect(state.syncIds.has('sync-0')).toBe(false)
})

it('should able to sync between two shared states', () => {
const state1 = createSharedState({
initialValue: { count: 0 },
Expand Down
20 changes: 18 additions & 2 deletions packages/devframe/src/utils/shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ export interface SharedStateOptions<T> {
enablePatches?: boolean
}

/**
* Upper bound on retained syncIds. Loop echoes arrive near-immediately, so a
* generous window preserves de-dup while capping memory on long-lived,
* frequently-mutated states (e.g. a 1s terminal poll).
*/
const MAX_SYNC_IDS = 1000

function rememberSyncId(syncIds: Set<string>, syncId: string): void {
syncIds.add(syncId)
if (syncIds.size > MAX_SYNC_IDS) {
const oldest = syncIds.values().next().value
if (oldest !== undefined)
syncIds.delete(oldest)
}
}

export function createSharedState<T extends object>(
options: SharedStateOptions<T>,
): SharedState<T> {
Expand All @@ -91,15 +107,15 @@ export function createSharedState<T extends object>(
return
enableImmerPatches()
state = applyPatches(state as unknown as Objectish, patches as unknown as Patch[]) as T
syncIds.add(syncId)
rememberSyncId(syncIds, syncId)
events.emit('updated', state, undefined, syncId)
},
mutate: (fn, syncId = nanoid()) => {
// Avoid loop syncs
if (syncIds.has(syncId))
return

syncIds.add(syncId)
rememberSyncId(syncIds, syncId)
if (enablePatches) {
const [newState, patches] = produceWithPatches(
state as unknown as Objectish,
Expand Down
170 changes: 0 additions & 170 deletions plans/006-bound-sharedstate-syncids.md

This file was deleted.

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