Skip to content

Commit a40f1be

Browse files
waleedlatif1claude
andcommitted
fix(redis): never credit the shared user counter from a buffer delete
An owner id is not proof of who wrote the bytes, so crediting the user counter on clear let anyone able to name a stream decrement a ceiling they never charged. That is the one direction that must not be possible: a counter driven down grants writes rather than denying them. The clear now drops the owner counter only. The user counter's fixed window settles it instead — it already tolerates accruing bytes Redis has dropped, and this is the same over-count bounded by the same window. The scope threading that existed only to credit it is removed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4af6e71 commit a40f1be

4 files changed

Lines changed: 37 additions & 49 deletions

File tree

‎apps/sim/lib/copilot/request/lifecycle/start.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
206206
}
207207
| undefined
208208

209-
await Promise.all([
210-
resetBuffer(streamId, { streamId, ...(userId ? { userId } : {}) }),
211-
clearFilePreviewSessions(streamId),
212-
])
209+
await Promise.all([resetBuffer(streamId), clearFilePreviewSessions(streamId)])
213210

214211
if (chatId) {
215212
createRunSegment({

‎apps/sim/lib/copilot/request/session/buffer.test.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -412,24 +412,24 @@ describe('mothership-stream-outbox', () => {
412412
expect(mockRedis.eval).not.toHaveBeenCalled()
413413
})
414414

415-
it('releases the owner counter and credits the user when the buffer is cleared', async () => {
415+
it('drops the owner counter together with the buffer it accounts for', async () => {
416416
// The buffer keys are deleted rather than expired, so a counter left behind would refuse a
417-
// retry that reuses the same streamId against bytes that no longer exist anywhere.
418-
await clearBuffer('stream-1', 'clear_outbox', { streamId: 'stream-1', userId: 'user-1' })
417+
// retry that reuses the same streamId against bytes that no longer exist anywhere. One
418+
// script, so a concurrent append cannot land between the delete and the release and keep
419+
// its events stored with its reservation already erased.
420+
await clearBuffer('stream-1')
419421

420-
// One script, so a concurrent append cannot land between the delete and the release and
421-
// keep its events stored with its reservation already erased.
422422
const evalCall = mockRedis.eval.mock.calls.at(-1)
423-
expect(evalCall?.[1]).toBe(5)
423+
expect(evalCall?.[1]).toBe(4)
424424
expect(evalCall?.[5]).toBe('execution:redis-budget:copilot_stream:stream-1')
425-
expect(evalCall?.[6]).toBe('execution:redis-budget:user:user-1')
426425
})
427426

428-
it('releases only the owner counter when no user is in scope', async () => {
427+
it('never touches the shared user counter when clearing a buffer', async () => {
428+
// An owner id is not proof of who wrote the bytes, so crediting the user counter here would
429+
// let anyone who can name a stream decrement a ceiling they never charged.
429430
await clearBuffer('stream-1')
430431

431-
const evalCall = mockRedis.eval.mock.calls.at(-1)
432-
expect(evalCall?.[1]).toBe(4)
433-
expect(evalCall?.[5]).toBe('execution:redis-budget:copilot_stream:stream-1')
432+
const keys = mockRedis.eval.mock.calls.at(-1)?.slice(2, 6) as string[]
433+
expect(keys.some((key) => key.includes('redis-budget:user:'))).toBe(false)
434434
})
435435
})

‎apps/sim/lib/copilot/request/session/buffer.ts‎

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,35 +110,29 @@ ${renderRedisBudgetReleaseLua(3)}
110110
return 1
111111
`
112112

113-
export async function resetBuffer(streamId: string, scope?: StreamBudgetScope): Promise<void> {
114-
await clearBuffer(streamId, 'reset_outbox', scope)
113+
export async function resetBuffer(streamId: string): Promise<void> {
114+
await clearBuffer(streamId, 'reset_outbox')
115115
}
116116

117-
export async function clearBuffer(
118-
streamId: string,
119-
operation = 'clear_outbox',
120-
scope?: StreamBudgetScope
121-
): Promise<void> {
117+
export async function clearBuffer(streamId: string, operation = 'clear_outbox'): Promise<void> {
122118
/*
123-
Delete and release in ONE script. The counter outlives the data it accounts for
124-
unless it is released here — these keys are deleted rather than expired, so a retry
125-
reusing the same streamId would be refused against bytes that no longer exist. Doing
126-
it in a second round trip would be its own hole: a concurrent append landing between
127-
the two would keep its events stored with its reservation already erased.
119+
Delete and release in ONE script. The counter outlives the data it accounts for unless
120+
it is dropped here — these keys are deleted rather than expired, so a retry reusing the
121+
same streamId would be refused against bytes that no longer exist. Doing it in a second
122+
round trip would be its own hole: a concurrent append landing between the two would keep
123+
its events stored with its reservation already erased.
124+
125+
Only the owner counter, never the shared user counter — see the release fragment.
128126
*/
129-
const budgetKeys = getRedisBudgetKeys({
130-
kind: 'copilot_stream',
131-
id: streamId,
132-
...(scope?.userId ? { userId: scope.userId } : {}),
133-
})
127+
const [ownerBudgetKey] = getRedisBudgetKeys({ kind: 'copilot_stream', id: streamId })
134128
await withRedisRetry({ operation, streamId }, async (redis) => {
135129
await redis.eval(
136130
CLEAR_BUFFER_SCRIPT,
137-
3 + budgetKeys.length,
131+
4,
138132
getEventsKey(streamId),
139133
getSeqKey(streamId),
140134
getAbortKey(streamId),
141-
...budgetKeys
135+
ownerBudgetKey
142136
)
143137
})
144138
}

‎apps/sim/lib/core/redis/byte-budget.server.ts‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -202,30 +202,27 @@ end
202202
}
203203

204204
/**
205-
* Lua that releases an owner's whole reservation, for data that is deleted rather than
206-
* left to expire.
205+
* Lua that drops an owner's counter, for data that is deleted rather than left to expire.
207206
*
208207
* Rendered into the caller's own script, the same way {@link renderRedisBudgetLua} is, so
209208
* the release commits together with the delete it accounts for. Releasing in a second
210209
* round trip would let a concurrent write land in between and keep its bytes stored with
211210
* its reservation already erased.
212211
*
213-
* Contract: budget keys are the **last** one or two entries of `KEYS`, in the order
214-
* {@link getRedisBudgetKeys} returns them, and `baseKeyCount` is how many precede them.
212+
* The shared user counter is deliberately NOT credited here. An owner id is not proof of
213+
* who wrote the bytes — anyone who can name an owner could otherwise decrement a counter
214+
* they never charged, which is the one direction that must never be possible, since a
215+
* counter driven down grants writes rather than denying them. The user counter's fixed
216+
* window is what settles it instead: it already tolerates accruing bytes Redis has dropped
217+
* (see {@link REDIS_BUDGET_TTL_SECONDS}), and this is the same over-count, bounded by the
218+
* same window.
219+
*
220+
* Contract: the owner key is the **last** entry of `KEYS`, and `baseKeyCount` is how many
221+
* precede it.
215222
*/
216223
export function renderRedisBudgetReleaseLua(baseKeyCount: number): string {
217-
const ownerKey = `KEYS[${baseKeyCount + 1}]`
218-
const userKey = `KEYS[${baseKeyCount + 2}]`
219-
220224
return `
221-
local owner_bytes = tonumber(redis.call('GET', ${ownerKey}) or '0')
222-
redis.call('DEL', ${ownerKey})
223-
if #KEYS >= ${baseKeyCount + 2} and owner_bytes > 0 then
224-
local user_next = redis.call('DECRBY', ${userKey}, owner_bytes)
225-
if user_next <= 0 then
226-
redis.call('DEL', ${userKey})
227-
end
228-
end
225+
redis.call('DEL', KEYS[${baseKeyCount + 1}])
229226
`
230227
}
231228

0 commit comments

Comments
 (0)