Skip to content

Commit 76a048b

Browse files
authored
Merge pull request #74 from devframes/spicy-experts-wear
2 parents 1141768 + 2b98f3d commit 76a048b

4 files changed

Lines changed: 58 additions & 207 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { defineRpcFunction } from './define'
3+
import { getRpcResolvedSetupResult } from './handler'
4+
5+
describe('getRpcResolvedSetupResult', () => {
6+
it('retries setup after a rejection instead of caching it (object context)', async () => {
7+
let calls = 0
8+
const def = defineRpcFunction({
9+
name: 't:retry',
10+
type: 'query',
11+
setup: () => {
12+
calls++
13+
if (calls === 1)
14+
throw new Error('transient')
15+
return { handler: () => 'ok' }
16+
},
17+
})
18+
const ctx = {}
19+
await expect(getRpcResolvedSetupResult(def as any, ctx)).rejects.toThrow('transient')
20+
const result = await getRpcResolvedSetupResult(def as any, ctx)
21+
expect(result.handler?.()).toBe('ok')
22+
expect(calls).toBe(2)
23+
})
24+
25+
it('caches a successful setup result per context (no re-run)', async () => {
26+
let calls = 0
27+
const def = defineRpcFunction({
28+
name: 't:cache',
29+
type: 'query',
30+
setup: () => {
31+
calls++
32+
return { handler: () => calls }
33+
},
34+
})
35+
const ctx = {}
36+
await getRpcResolvedSetupResult(def as any, ctx)
37+
await getRpcResolvedSetupResult(def as any, ctx)
38+
expect(calls).toBe(1)
39+
})
40+
})

packages/devframe/src/rpc/handler.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,30 @@ export async function getRpcResolvedSetupResult<
2121
// closed over a prior context's state.
2222
if (typeof context === 'object' && context !== null) {
2323
definition.__cache ??= new WeakMap()
24-
let promise = definition.__cache.get(context as object)
24+
const cache = definition.__cache
25+
let promise = cache.get(context as object)
2526
if (!promise) {
2627
promise = Promise.resolve(definition.setup(context))
27-
definition.__cache.set(context as object, promise)
28+
// If setup rejects, evict so a later call can retry instead of
29+
// re-awaiting the cached rejection permanently.
30+
promise.catch(() => {
31+
if (cache.get(context as object) === promise)
32+
cache.delete(context as object)
33+
})
34+
cache.set(context as object, promise)
2835
}
2936
return await promise
3037
}
3138

3239
// Primitive / undefined context — fall back to a single-slot cache.
33-
definition.__promise ??= Promise.resolve(definition.setup(context))
40+
if (!definition.__promise) {
41+
const promise = Promise.resolve(definition.setup(context))
42+
promise.catch(() => {
43+
if (definition.__promise === promise)
44+
definition.__promise = undefined
45+
})
46+
definition.__promise = promise
47+
}
3448
return await definition.__promise
3549
}
3650

plans/005-rpc-setup-cache-rejection.md

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

plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ changes are allowed as long as they're marked).
1717
| 002 | Clear the critical `shell-quote` advisory in the bundle | security/deps | P1 | S || DONE |
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 |
20-
| 005 | Don't cache a rejected RPC `setup()` promise | bug | P1 | S || TODO |
20+
| 005 | Don't cache a rejected RPC `setup()` promise | bug | P1 | S || DONE |
2121
| 006 | Bound `SharedState.syncIds` (memory leak) | bug | P1 | S || DONE |
2222
| 007 | Behavioral tests for the auth/OTP trust boundary | tests | P1 | S || DONE |
2323
| 008 | Cap hub terminal buffer + reject restart-after-terminate | bug | P2 | S || TODO |

0 commit comments

Comments
 (0)