Skip to content

Commit 455c801

Browse files
authored
fix: shared-state trust-flip re-init, atomic storage writes, falsy RPC cache (#80)
1 parent 50e6306 commit 455c801

20 files changed

Lines changed: 413 additions & 763 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createEventEmitter } from 'devframe/utils/events'
2+
import { describe, expect, it } from 'vitest'
3+
import { createRpcSharedStateClientHost } from './rpc-shared-state'
4+
5+
function makeFakeRpc() {
6+
const events = createEventEmitter<any>()
7+
const setCalls: any[][] = []
8+
const rpc = {
9+
connectionMeta: { backend: 'websocket' },
10+
isTrusted: false,
11+
events,
12+
client: { register: () => {} },
13+
callEvent: (name: string, ...args: any[]) => {
14+
if (name === 'devframe:rpc:server-state:set')
15+
setCalls.push(args)
16+
},
17+
call: async () => undefined,
18+
} as any
19+
return { rpc, events, setCalls }
20+
}
21+
22+
describe('client shared state', () => {
23+
it('registers the server-sync bridge once across repeated trust flips', async () => {
24+
const { rpc, events, setCalls } = makeFakeRpc()
25+
const host = createRpcSharedStateClientHost(rpc)
26+
const state = await host.get('k', { initialValue: { a: 1 } })
27+
28+
events.emit('rpc:is-trusted:updated', true)
29+
events.emit('rpc:is-trusted:updated', true) // second flip must not re-register
30+
31+
state.mutate((d: any) => {
32+
d.a = 2
33+
})
34+
expect(setCalls).toHaveLength(1) // exactly one server-state:set, not two
35+
})
36+
})

packages/devframe/src/client/rpc-shared-state.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
114114
return new Promise<SharedState<T>>((resolve) => {
115115
if (!rpc.isTrusted) {
116116
resolve(state)
117+
let initialized = false
117118
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
118-
if (isTrusted) {
119+
if (isTrusted && !initialized) {
120+
initialized = true
119121
initSharedState()
120122
}
121123
})

packages/devframe/src/client/rpc.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,14 +316,11 @@ export async function getDevframeRpcClient(
316316
async onRequest(req, next, resolve) {
317317
await rpcOptions.onRequest?.call(this, req, next, resolve)
318318
if (cacheOptions && cacheManager?.validate(req.m)) {
319-
const cached = cacheManager.cached(req.m, req.a)
320-
if (cached) {
321-
return resolve(cached)
322-
}
323-
else {
324-
const res = await next(req)
325-
cacheManager?.apply(req, res)
319+
if (cacheManager.has(req.m, req.a)) {
320+
return resolve(cacheManager.cached(req.m, req.a))
326321
}
322+
const res = await next(req)
323+
cacheManager.apply(req, res)
327324
}
328325
else {
329326
await next(req)

packages/devframe/src/node/__tests__/storage.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,67 @@ describe('createStorage', () => {
4040
fs.rmSync(dir, { recursive: true, force: true })
4141
}
4242
})
43+
44+
it('writes atomically via temp file + rename', async () => {
45+
const dir = fs.mkdtempSync(join(os.tmpdir(), 'devframe-storage-'))
46+
const filepath = join(dir, 'state.json')
47+
48+
try {
49+
const state = createStorage({
50+
filepath,
51+
initialValue: { count: 1 },
52+
debounce: 0,
53+
})
54+
55+
state.mutate((draft) => {
56+
draft.count = 2
57+
})
58+
59+
await wait(20)
60+
61+
// no leftover temp file
62+
const entries = fs.readdirSync(dir)
63+
expect(entries).toEqual(['state.json'])
64+
65+
const saved = JSON.parse(fs.readFileSync(filepath, 'utf-8'))
66+
expect(saved).toEqual({ count: 2 })
67+
}
68+
finally {
69+
fs.rmSync(dir, { recursive: true, force: true })
70+
}
71+
})
72+
73+
it('reports a write failure instead of throwing', async () => {
74+
const dir = fs.mkdtempSync(join(os.tmpdir(), 'devframe-storage-'))
75+
const filepath = join(dir, 'state.json')
76+
77+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
78+
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(() => {
79+
throw new Error('EACCES: permission denied')
80+
})
81+
82+
try {
83+
const state = createStorage({
84+
filepath,
85+
initialValue: { count: 1 },
86+
debounce: 0,
87+
})
88+
89+
expect(() => {
90+
state.mutate((draft) => {
91+
draft.count = 2
92+
})
93+
}).not.toThrow()
94+
95+
await wait(20)
96+
97+
expect(errorSpy).toHaveBeenCalled()
98+
expect(fs.existsSync(filepath)).toBe(false)
99+
}
100+
finally {
101+
renameSpy.mockRestore()
102+
errorSpy.mockRestore()
103+
fs.rmSync(dir, { recursive: true, force: true })
104+
}
105+
})
43106
})

packages/devframe/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,9 @@ export const diagnostics = defineDiagnostics({
6464
`Scoped RPC registration for namespace "${p.namespace}" received an already-namespaced function name "${p.name}".`,
6565
fix: 'A scoped context auto-namespaces ids. Pass a bare name without a ":" separator (e.g. `register({ name: "get-cwd" })`), or use the unscoped `ctx.base.rpc.register` for a fully-qualified name.',
6666
},
67+
DF0035: {
68+
why: (p: { filepath: string }) => `Failed to persist storage file: ${p.filepath}`,
69+
fix: 'Check that the storage directory is writable and has free space.',
70+
},
6771
},
6872
})

packages/devframe/src/node/storage.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import process from 'node:process'
23
import { destr } from 'destr'
34
import { createSharedState } from 'devframe/utils/shared-state'
45
import { dirname } from 'pathe'
@@ -39,8 +40,16 @@ export function createStorage<T extends object>(options: CreateStorageOptions<T>
3940
state.on(
4041
'updated',
4142
debounce((newState) => {
42-
fs.mkdirSync(dirname(options.filepath), { recursive: true })
43-
fs.writeFileSync(options.filepath, `${JSON.stringify(newState, null, 2)}\n`)
43+
try {
44+
const dir = dirname(options.filepath)
45+
fs.mkdirSync(dir, { recursive: true })
46+
const tmp = `${options.filepath}.${process.pid}.tmp`
47+
fs.writeFileSync(tmp, `${JSON.stringify(newState, null, 2)}\n`)
48+
fs.renameSync(tmp, options.filepath) // atomic replace on same filesystem
49+
}
50+
catch (error) {
51+
diagnostics.DF0035({ filepath: options.filepath, cause: error }, { method: 'error' })
52+
}
4453
}, debounceTime),
4554
)
4655

packages/devframe/src/rpc/cache.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,46 @@ it('cache', async () => {
1919
cache.clear()
2020
expect(cache.cached<number>('fn2', [3, 4])).toBeUndefined()
2121
})
22+
23+
it('serves falsy cached values via `has` (presence, not truthiness)', () => {
24+
const cache = new RpcCacheManager({ functions: ['fn'] })
25+
26+
// absent key: `has` reports false, distinct from a stored falsy value
27+
expect(cache.has('fn', ['zero'])).toBe(false)
28+
29+
cache.apply({ m: 'fn', a: ['zero'] }, 0)
30+
cache.apply({ m: 'fn', a: ['false'] }, false)
31+
cache.apply({ m: 'fn', a: ['empty'] }, '')
32+
cache.apply({ m: 'fn', a: ['null'] }, null)
33+
34+
expect(cache.has('fn', ['zero'])).toBe(true)
35+
expect(cache.has('fn', ['false'])).toBe(true)
36+
expect(cache.has('fn', ['empty'])).toBe(true)
37+
expect(cache.has('fn', ['null'])).toBe(true)
38+
39+
expect(cache.cached('fn', ['zero'])).toBe(0)
40+
expect(cache.cached('fn', ['false'])).toBe(false)
41+
expect(cache.cached('fn', ['empty'])).toBe('')
42+
expect(cache.cached('fn', ['null'])).toBe(null)
43+
})
44+
45+
it('the client cache path (has-then-cached) serves a falsy value without a second fetch', async () => {
46+
const cache = new RpcCacheManager({ functions: ['fn'] })
47+
let nextCalls = 0
48+
const next = async (req: { m: string, a: unknown[] }) => {
49+
nextCalls++
50+
cache.apply(req, 0)
51+
return 0
52+
}
53+
54+
// mirrors the onRequest cache path in client/rpc.ts
55+
async function callThroughCache(req: { m: string, a: unknown[] }) {
56+
if (cache.has(req.m, req.a))
57+
return cache.cached(req.m, req.a)
58+
return next(req)
59+
}
60+
61+
expect(await callThroughCache({ m: 'fn', a: [] })).toBe(0)
62+
expect(await callThroughCache({ m: 'fn', a: [] })).toBe(0)
63+
expect(nextCalls).toBe(1) // second call served from cache, not re-fetched
64+
})

packages/devframe/src/rpc/cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export class RpcCacheManager {
3333
return undefined
3434
}
3535

36+
has(m: string, a: unknown[]): boolean {
37+
return this.cacheMap.get(m)?.has(this.keySerializer(a)) ?? false
38+
}
39+
3640
apply(req: { m: string, a: unknown[] }, res: unknown): void {
3741
const methodCache = this.cacheMap.get(req.m) || new Map<string, unknown>()
3842
methodCache.set(this.keySerializer(req.a), res)

0 commit comments

Comments
 (0)