Skip to content

Commit c2d0733

Browse files
committed
fix(mothership): renew a running code call's sandbox CLI scope past its expiry
1 parent cfbc3e8 commit c2d0733

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

‎apps/sim/lib/mothership/tools/sandbox-resources.test.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,33 @@ vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
1111

1212
const { values, lists, seen, persist, redis } = vi.hoisted(() => {
1313
const values = new Map<string, string>()
14+
const expiresAt = new Map<string, number>()
1415
const lists = new Map<string, string[]>()
1516
const seen = new Map<string, Set<string>>()
17+
const live = (key: string) => {
18+
if ((expiresAt.get(key) ?? Number.POSITIVE_INFINITY) <= Date.now()) {
19+
values.delete(key)
20+
expiresAt.delete(key)
21+
}
22+
return values.has(key)
23+
}
1624
return {
1725
values,
1826
lists,
1927
seen,
2028
persist: vi.fn(async () => {}),
2129
redis: {
22-
set: vi.fn(async (key: string, value: string) => {
30+
set: vi.fn(async (key: string, value: string, _mode?: 'EX', ttlSeconds?: number) => {
2331
values.set(key, value)
32+
if (ttlSeconds) expiresAt.set(key, Date.now() + ttlSeconds * 1000)
33+
}),
34+
expire: vi.fn(async (key: string, ttlSeconds: number) => {
35+
if (!live(key)) return 0
36+
expiresAt.set(key, Date.now() + ttlSeconds * 1000)
37+
return 1
2438
}),
25-
get: vi.fn(async (key: string) => values.get(key) ?? null),
26-
exists: vi.fn(async (key: string) => (values.has(key) ? 1 : 0)),
39+
get: vi.fn(async (key: string) => (live(key) ? (values.get(key) ?? null) : null)),
40+
exists: vi.fn(async (key: string) => (live(key) ? 1 : 0)),
2741
del: vi.fn(async (key: string) => {
2842
values.delete(key)
2943
lists.delete(key)
@@ -183,6 +197,33 @@ describe('sandbox resource ownership and publication', () => {
183197
})
184198
})
185199

200+
it('keeps the callback scope live for a call that outlasts its expiry, and revokes it after', async () => {
201+
vi.useFakeTimers()
202+
try {
203+
const finish = Promise.withResolvers<void>()
204+
let token = ''
205+
const call = withSandboxResourceScope(
206+
identity,
207+
new AbortController().signal,
208+
undefined,
209+
async () => {
210+
token =
211+
(await sandboxResourceEndpoint('https://sim.test', args, 'secret')).split('/').at(-1) ??
212+
''
213+
await finish.promise
214+
}
215+
)
216+
await vi.advanceTimersByTimeAsync(15 * 60_000)
217+
expect(await readSandboxResourceScope(token, 'secret')).not.toBeNull()
218+
finish.resolve()
219+
await vi.advanceTimersByTimeAsync(1_000)
220+
await call
221+
expect(await readSandboxResourceScope(token, 'secret')).toBeNull()
222+
} finally {
223+
vi.useRealTimers()
224+
}
225+
})
226+
186227
it('revokes on cancellation and never emits an abandoned callback', async () => {
187228
const controller = new AbortController()
188229
const emit = vi.fn()

‎apps/sim/lib/mothership/tools/sandbox-resources.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1616

1717
const logger = createLogger('MothershipSandboxResources')
1818
const CONTEXT_TTL_SECONDS = 600
19+
/** A running call renews its scope well inside the TTL; the TTL only bounds a crashed owner. */
20+
const CONTEXT_RENEW_MS = (CONTEXT_TTL_SECONDS * 1000) / 3
1921
const POLL_MS = 200
2022
const scopeSchema = z
2123
.object({
@@ -233,10 +235,23 @@ export async function withSandboxResourceScope<T>(
233235
() => {},
234236
() => {}
235237
)
238+
const renew = async () => {
239+
if (!active.token || signal.aborted) return
240+
try {
241+
await redisClient().expire(contextKey(active.token), CONTEXT_TTL_SECONDS)
242+
} catch {
243+
logger.warn('Sandbox resource scope renewal deferred', { toolCallId: identity.toolCallId })
244+
}
245+
}
246+
let renewedAt = Date.now()
236247
try {
237248
while (running) {
238249
await Promise.race([settled, sleep(POLL_MS)])
239250
await drain()
251+
if (Date.now() - renewedAt >= CONTEXT_RENEW_MS) {
252+
renewedAt = Date.now()
253+
await renew()
254+
}
240255
}
241256
return await execution
242257
} finally {

0 commit comments

Comments
 (0)