Skip to content

Commit ed6ea45

Browse files
committed
fix(observability): normalize the shared Redis state so stale globals cannot emit NaN
The state object lives on globalThis and outlives a module evaluation, but was only ever initialized when absent. A global created before these fields existed kept them undefined, and undefined slips past every `=== null` guard: ages became `now - undefined` and counters became `undefined++`, both NaN, both serialized as null — in the one payload whose entire purpose is being trustworthy. Reachable wherever a process re-evaluates the module against an existing global, dev hot-reload being the obvious case. Normalizes from whatever is present instead of trusting the shape, carrying pingInterval across so the health check is never started twice. The normalization is a pure exported function because a module-evaluation side effect is not reachable from a test that has already imported it — the first attempt at covering this asserted against the module's own already-normalized state and passed with the fix reverted. Also adds guards for two properties the diagnostics depend on but nothing asserted: that describeRedisConnection never throws, since it runs inside catch blocks where a throw would replace the real failure, and that the wrapper rethrows the original error object rather than substituting its own. Each was verified to fail when the behavior is removed.
1 parent 9112d23 commit ed6ea45

3 files changed

Lines changed: 96 additions & 16 deletions

File tree

apps/sim/lib/billing/calculations/usage-reservation.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,13 @@ describe('usage-reservation', () => {
339339
})
340340

341341
describe('refreshExecutionSlotExpiry', () => {
342+
it('rethrows the original error object rather than the diagnostic wrapper', async () => {
343+
const original = Object.assign(new Error('Command timed out'), { code: 'ETIMEDOUT' })
344+
getMock.mockRejectedValueOnce(original)
345+
346+
await expect(refreshExecutionSlotExpiry('exec-1', Date.now() + 60_000)).rejects.toBe(original)
347+
})
348+
342349
it('refreshes only the locally owned slot and matching pointer', async () => {
343350
evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1)
344351
await reserveExecutionSlot(memberParams)

apps/sim/lib/core/config/redis.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ vi.mock('ioredis', () => ({
2727
import {
2828
acquireLock,
2929
closeRedisConnection,
30+
createRedisState,
3031
describeRedisConnection,
3132
extendLock,
3233
getRedisClient,
@@ -225,6 +226,62 @@ describe('redis config', () => {
225226
expect(JSON.stringify(d)).not.toContain('10.0.0.5')
226227
})
227228

229+
it('never throws, so it cannot mask the error it is describing', () => {
230+
// Called from catch blocks: a throw here would replace the real failure.
231+
mockEnv.REDIS_URL = undefined
232+
expect(() => describeRedisConnection()).not.toThrow()
233+
234+
mockEnv.REDIS_URL = 'not a url'
235+
expect(() => describeRedisConnection()).not.toThrow()
236+
expect(describeRedisConnection().hostKind).toBe('unknown')
237+
238+
// rediss:// to a bare IP with no REDIS_TLS_SERVERNAME makes the real
239+
// getConfiguredRedisUrl path throw; the snapshot must still come back.
240+
mockEnv.REDIS_URL = 'rediss://10.0.0.5:6379'
241+
mockEnv.REDIS_TLS_SERVERNAME = undefined
242+
expect(() => describeRedisConnection()).not.toThrow()
243+
})
244+
245+
it('upgrades a state object that predates these fields instead of trusting its shape', () => {
246+
// Exactly the shape the global held before this change.
247+
const stale = {
248+
client: null,
249+
pingFailures: 0,
250+
pingInterval: null,
251+
pingInFlight: false,
252+
reconnectListeners: [],
253+
}
254+
255+
const normalized = createRedisState(stale)
256+
257+
// Undefined slips past the `=== null` guards and yields NaN downstream.
258+
expect(normalized.clientCreatedAt).toBeNull()
259+
expect(normalized.lastReadyAt).toBeNull()
260+
expect(normalized.lastPingOkAt).toBeNull()
261+
expect(normalized.connects).toBe(0)
262+
expect(normalized.reconnects).toBe(0)
263+
expect(normalized.errors).toBe(0)
264+
expect(normalized.lastErrorMessage).toBeNull()
265+
for (const [key, value] of Object.entries(normalized)) {
266+
expect(value, `${key} is undefined`).not.toBeUndefined()
267+
}
268+
})
269+
270+
it('carries a running health check across normalization so it is not started twice', () => {
271+
const interval = setInterval(() => {}, 1_000)
272+
try {
273+
expect(createRedisState({ pingInterval: interval }).pingInterval).toBe(interval)
274+
} finally {
275+
clearInterval(interval)
276+
}
277+
})
278+
279+
it('preserves accumulated counters when the shape is already current', () => {
280+
const normalized = createRedisState({ connects: 4, reconnects: 2, errors: 7 })
281+
282+
expect(normalized).toMatchObject({ connects: 4, reconnects: 2, errors: 7 })
283+
})
284+
228285
it('does not date a connection that has been discarded', async () => {
229286
mockRedisInstance.status = 'ready'
230287
getRedisClient()

apps/sim/lib/core/config/redis.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,40 @@ interface RedisState {
6969
lastErrorMessage: string | null
7070
}
7171

72-
const g = globalThis as typeof globalThis & { _redisState?: RedisState }
73-
if (!g._redisState) {
74-
g._redisState = {
75-
client: null,
76-
pingFailures: 0,
77-
pingInterval: null,
78-
pingInFlight: false,
79-
reconnectListeners: [],
80-
clientCreatedAt: null,
81-
lastReadyAt: null,
82-
lastPingOkAt: null,
83-
connects: 0,
84-
reconnects: 0,
85-
errors: 0,
86-
lastErrorMessage: null,
72+
const g = globalThis as typeof globalThis & { _redisState?: Partial<RedisState> }
73+
74+
/**
75+
* Normalized rather than initialized-once, because the global outlives a module
76+
* evaluation. A state object created before any of these fields existed keeps
77+
* them `undefined`, which slips past the `=== null` guards below and turns every
78+
* age into `now - undefined` and every counter into `undefined++` — NaN in both
79+
* cases, serialized as `null`, in the one payload whose entire purpose is being
80+
* trustworthy. Rebuilding from whatever is there upgrades an existing global
81+
* instead of trusting its shape, and carries `pingInterval` across so the health
82+
* check is never started twice.
83+
*
84+
* Exported so the normalization itself is testable: a module-evaluation-time
85+
* side effect is not reachable from a test that has already imported it.
86+
*/
87+
export function createRedisState(existing: Partial<RedisState> = {}): RedisState {
88+
return {
89+
client: existing.client ?? null,
90+
pingFailures: existing.pingFailures ?? 0,
91+
pingInterval: existing.pingInterval ?? null,
92+
pingInFlight: existing.pingInFlight ?? false,
93+
reconnectListeners: existing.reconnectListeners ?? [],
94+
clientCreatedAt: existing.clientCreatedAt ?? null,
95+
lastReadyAt: existing.lastReadyAt ?? null,
96+
lastPingOkAt: existing.lastPingOkAt ?? null,
97+
connects: existing.connects ?? 0,
98+
reconnects: existing.reconnects ?? 0,
99+
errors: existing.errors ?? 0,
100+
lastErrorMessage: existing.lastErrorMessage ?? null,
87101
}
88102
}
89-
const state = g._redisState
103+
104+
const state = createRedisState(g._redisState)
105+
g._redisState = state
90106

91107
/**
92108
* A command that never gets a reply fails identically whichever of three states

0 commit comments

Comments
 (0)