Skip to content

Commit 2b77ade

Browse files
authored
improvement(sandbox): log per-phase timing for each sandbox execution (#9587)
* improvement(sandbox): log per-phase timing for each sandbox execution * test(sandbox): time the unchanged-CLI warm path on a controlled clock
1 parent eb6bcdb commit 2b77ade

6 files changed

Lines changed: 154 additions & 7 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import {
4343
SANDBOX_OUTPUT_DIR_MAX_DEPTH,
4444
SANDBOX_OUTPUT_DIR_SENTINEL,
4545
} from '@/lib/execution/remote-sandbox/paths'
46+
import {
47+
createSandboxPhaseTimer,
48+
type SandboxPhaseTimer,
49+
} from '@/lib/execution/remote-sandbox/phase-timing'
4650
import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime'
4751
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
4852
import {
@@ -116,13 +120,16 @@ async function leaseSandbox(
116120
selected: ResolvedSandbox | null,
117121
signal: AbortSignal,
118122
meterUsage: boolean | undefined,
119-
session: SandboxSessionRequest | undefined
123+
session: SandboxSessionRequest | undefined,
124+
phases: SandboxPhaseTimer
120125
): Promise<SandboxLease> {
121126
const provider = resolveProvider()
122127
const sessionCapable = Boolean(session && !meterUsage && provider.findSessionSandbox)
123128

124129
if (session && sessionCapable) {
130+
const endAccess = phases.start('access')
125131
await prepareSandboxSessionAccess(session.key, signal)
132+
endAccess()
126133
return withSandboxSessionLock(session.key, signal, async (leaseSignal) => {
127134
const { created, status } = await ensureSessionSandbox({
128135
provider,
@@ -132,6 +139,7 @@ async function leaseSandbox(
132139
session,
133140
signal: leaseSignal,
134141
bootstrapTimeoutMs: remainingSandboxBudgetMs(signal),
142+
phases,
135143
})
136144
return {
137145
created,
@@ -214,6 +222,16 @@ function raceSandboxAbort<T>(operation: Promise<T>, signal: AbortSignal): Promis
214222
})
215223
}
216224

225+
/** Attaches the phase breakdown to whichever result the execution returns, after its release. */
226+
async function withPhaseTimings(
227+
execute: (phases: SandboxPhaseTimer) => Promise<SandboxExecutionResult>
228+
): Promise<SandboxExecutionResult> {
229+
const phases = createSandboxPhaseTimer()
230+
const result = await execute(phases)
231+
result.phaseMs = phases.timings
232+
return result
233+
}
234+
217235
async function withSandboxExecutionBudget<T>(
218236
timeoutMs: number,
219237
parentSignal: AbortSignal | undefined,
@@ -864,22 +882,26 @@ async function acceptSessionOutputHistory(
864882
async function executeInSandboxWithinBudget(
865883
// The budget wrapper always injects the signal; the required-signal type states that
866884
// invariant instead of a cast hiding it.
867-
req: SandboxExecutionRequest & { signal: AbortSignal }
885+
req: SandboxExecutionRequest & { signal: AbortSignal },
886+
phases: SandboxPhaseTimer
868887
): Promise<SandboxExecutionResult> {
869888
const { code, language, signal } = req
870889
const kind = req.sandboxKind ?? 'code'
871890
throwIfAborted(signal)
872891

873892
// Resolved before the sandbox is created so a selection that cannot be honored
874893
// fails without spending a provider create.
894+
const endResolve = phases.start('resolve')
875895
const selected = await resolveWorkspaceSandbox({
876896
kind,
877897
language,
878898
workspaceId: req.workspaceId,
879899
sandboxId: req.sandboxId,
880900
})
901+
endResolve()
881902
throwIfAborted(signal)
882903

904+
const endLease = phases.start('lease')
883905
const lease = await leaseSandbox(
884906
kind,
885907
{
@@ -890,8 +912,10 @@ async function executeInSandboxWithinBudget(
890912
selected,
891913
signal,
892914
req.meterUsage,
893-
req.session
915+
req.session,
916+
phases
894917
)
918+
endLease()
895919
const created = lease.created
896920
const sandbox = created.sandbox
897921
const sandboxId = sandbox.sandboxId
@@ -906,6 +930,7 @@ async function executeInSandboxWithinBudget(
906930
// the finally below. Dependencies land before the inputs so user code and its
907931
// mounts always see a complete environment.
908932
//
933+
const endPrepare = phases.start('prepare')
909934
if (lease.session && req.session)
910935
await recordSessionFileInput(
911936
req.session.key,
@@ -926,6 +951,7 @@ async function executeInSandboxWithinBudget(
926951
signal,
927952
Boolean(lease.session)
928953
)
954+
endPrepare()
929955
const executionEnvironment = {
930956
...selected?.envs,
931957
...req.session?.envs,
@@ -944,13 +970,15 @@ async function executeInSandboxWithinBudget(
944970

945971
let execution: SandboxCodeResult
946972
try {
973+
const endRun = phases.start('run')
947974
execution = await sandbox.runCode(code, {
948975
timeoutMs: codeBudgetMs(req, signal),
949976
javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []),
950977
maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
951978
signal,
952979
...(hasExecutionEnvironment ? { envs: executionEnvironment } : {}),
953980
})
981+
endRun()
954982
} catch (error) {
955983
throwIfAborted(signal)
956984
throw error
@@ -1009,11 +1037,13 @@ async function executeInSandboxWithinBudget(
10091037
...sessionField,
10101038
}
10111039
try {
1040+
const endCollect = phases.start('collect')
10121041
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
10131042
sandbox,
10141043
req,
10151044
{ signal }
10161045
)
1046+
endCollect()
10171047
throwIfAborted(signal)
10181048
billableResult.exportedFileContent = exportedFileContent
10191049
billableResult.exportedFiles = exportedFiles
@@ -1044,6 +1074,7 @@ async function executeInSandboxWithinBudget(
10441074
if (cost && billableOutputError) {
10451075
attachTrustedSandboxOutputCost(billableOutputError, cost)
10461076
}
1077+
const endRelease = phases.start('release')
10471078
try {
10481079
await privateInputFiles?.cleanup()
10491080
await lease.release()
@@ -1052,6 +1083,7 @@ async function executeInSandboxWithinBudget(
10521083
providerId: created.providerId,
10531084
sandboxId,
10541085
})
1086+
endRelease()
10551087
}
10561088
}
10571089
}
@@ -1061,34 +1093,41 @@ export function executeInSandbox(req: SandboxExecutionRequest): Promise<SandboxE
10611093
req.timeoutMs,
10621094
req.signal,
10631095
Boolean(req.session && !req.meterUsage),
1064-
(signal) => executeInSandboxWithinBudget({ ...req, signal })
1096+
(signal) =>
1097+
withPhaseTimings((phases) => executeInSandboxWithinBudget({ ...req, signal }, phases))
10651098
)
10661099
}
10671100

10681101
async function executeShellInSandboxWithinBudget(
1069-
req: SandboxShellExecutionRequest & { signal: AbortSignal }
1102+
req: SandboxShellExecutionRequest & { signal: AbortSignal },
1103+
phases: SandboxPhaseTimer
10701104
): Promise<SandboxExecutionResult> {
10711105
const { code, envs, signal } = req
10721106
const kind = req.sandboxKind ?? 'shell'
10731107
throwIfAborted(signal)
10741108

10751109
// No language is passed: a shell execution runs commands rather than a language
10761110
// runtime, so whichever language the sandbox carries is the one it installs.
1111+
const endResolve = phases.start('resolve')
10771112
const selected = await resolveWorkspaceSandbox({
10781113
kind,
10791114
workspaceId: req.workspaceId,
10801115
sandboxId: req.sandboxId,
10811116
})
1117+
endResolve()
10821118
throwIfAborted(signal)
10831119

1120+
const endLease = phases.start('lease')
10841121
const lease = await leaseSandbox(
10851122
kind,
10861123
{ imageRef: selected?.imageRef, lifetimeMs: remainingSandboxBudgetMs(signal) },
10871124
selected,
10881125
signal,
10891126
req.meterUsage,
1090-
req.session
1127+
req.session,
1128+
phases
10911129
)
1130+
endLease()
10921131
const created = lease.created
10931132
const sandbox = created.sandbox
10941133
const sandboxId = sandbox.sandboxId
@@ -1102,6 +1141,7 @@ async function executeShellInSandboxWithinBudget(
11021141
// Inside the try so a failed install or mount still releases the sandbox via
11031142
// the finally below. The install shares the caller's budget rather than adding
11041143
// to it — see the note in `executeInSandbox`.
1144+
const endPrepare = phases.start('prepare')
11051145
if (lease.session && req.session)
11061146
await recordSessionFileInput(
11071147
req.session.key,
@@ -1123,9 +1163,11 @@ async function executeShellInSandboxWithinBudget(
11231163
signal,
11241164
Boolean(lease.session)
11251165
)
1166+
endPrepare()
11261167

11271168
let result: SandboxCommandResult
11281169
try {
1170+
const endRun = phases.start('run')
11291171
result = await sandbox.runCommand(code, {
11301172
envs: {
11311173
...selected?.envs,
@@ -1141,6 +1183,7 @@ async function executeShellInSandboxWithinBudget(
11411183
rootUser: !lease.session,
11421184
atMostOnce: true,
11431185
})
1186+
endRun()
11441187
} catch (error) {
11451188
throwIfAborted(signal)
11461189
throw error
@@ -1185,11 +1228,13 @@ async function executeShellInSandboxWithinBudget(
11851228
...sessionField,
11861229
}
11871230
try {
1231+
const endCollect = phases.start('collect')
11881232
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
11891233
sandbox,
11901234
req,
11911235
{ signal }
11921236
)
1237+
endCollect()
11931238
throwIfAborted(signal)
11941239
billableResult.exportedFileContent = exportedFileContent
11951240
billableResult.exportedFiles = exportedFiles
@@ -1220,6 +1265,7 @@ async function executeShellInSandboxWithinBudget(
12201265
if (cost && billableOutputError) {
12211266
attachTrustedSandboxOutputCost(billableOutputError, cost)
12221267
}
1268+
const endRelease = phases.start('release')
12231269
try {
12241270
await privateInputFiles?.cleanup()
12251271
await lease.release()
@@ -1228,6 +1274,7 @@ async function executeShellInSandboxWithinBudget(
12281274
providerId: created.providerId,
12291275
sandboxId,
12301276
})
1277+
endRelease()
12311278
}
12321279
}
12331280
}
@@ -1239,7 +1286,8 @@ export function executeShellInSandbox(
12391286
req.timeoutMs,
12401287
req.signal,
12411288
Boolean(req.session && !req.meterUsage),
1242-
(signal) => executeShellInSandboxWithinBudget({ ...req, signal })
1289+
(signal) =>
1290+
withPhaseTimings((phases) => executeShellInSandboxWithinBudget({ ...req, signal }, phases))
12431291
)
12441292
}
12451293

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Phases of one sandbox execution, in the order they run. `lease` spans the whole acquisition
3+
* (session lock included); for a session, `lookup` and `cli` are the parts of it spent finding or
4+
* creating the machine and verifying its deployment tooling.
5+
*/
6+
type SandboxPhase =
7+
| 'resolve'
8+
| 'access'
9+
| 'lease'
10+
| 'lookup'
11+
| 'cli'
12+
| 'prepare'
13+
| 'run'
14+
| 'collect'
15+
| 'release'
16+
17+
/** Wall-clock milliseconds per phase; a phase that did not finish is absent. */
18+
export type SandboxPhaseTimings = Partial<Record<SandboxPhase, number>>
19+
20+
/** Accumulates per-phase wall time for one execution's log line. */
21+
export interface SandboxPhaseTimer {
22+
readonly timings: SandboxPhaseTimings
23+
/** Starts timing a phase; the returned function records its duration. */
24+
start(phase: SandboxPhase): () => void
25+
}
26+
27+
/** Creates a timer whose callbacks record rounded milliseconds; unfinished phases stay absent. */
28+
export function createSandboxPhaseTimer(): SandboxPhaseTimer {
29+
const timings: SandboxPhaseTimings = {}
30+
return {
31+
timings,
32+
start(phase) {
33+
const startedAt = performance.now()
34+
return () => {
35+
timings[phase] = Math.round(performance.now() - startedAt)
36+
}
37+
},
38+
}
39+
}

‎apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,46 @@ describe('session sandbox lease', () => {
821821
expect(writes).toHaveBeenCalledTimes(firstWrites)
822822
})
823823

824+
it('attributes a warm shell call’s wall time to the phase that spent it', async () => {
825+
const { handle } = fakeSandbox('phase-timing')
826+
mockFindSessionSandbox.mockResolvedValue(handle)
827+
const request = {
828+
code: 'true',
829+
envs: {},
830+
timeoutMs: 10_000,
831+
session: { key: 'phase-timing', cli: CLI },
832+
}
833+
await executeShellInSandbox(request)
834+
835+
let nowMs = 0
836+
const clock = vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
837+
const writes = vi.fn(handle.writeFile.bind(handle))
838+
handle.writeFile = writes
839+
const runCommand = handle.runCommand.bind(handle)
840+
handle.runCommand = async (command, options) => {
841+
nowMs += options.envs?.SIM_CLI_PATH ? 30 : 60
842+
return runCommand(command, options)
843+
}
844+
mockFindSessionSandbox.mockImplementation(async () => {
845+
nowMs += 40
846+
return handle
847+
})
848+
849+
try {
850+
const result = await executeShellInSandbox(request)
851+
852+
const phases = result.phaseMs ?? {}
853+
expect(result.sandboxSession).toBe('reused')
854+
expect(writes).not.toHaveBeenCalled()
855+
expect(phases.lookup).toBe(40)
856+
expect(phases.cli).toBe(30)
857+
expect(phases.run).toBe(60)
858+
expect(phases.lease).toBeGreaterThanOrEqual(70)
859+
} finally {
860+
clock.mockRestore()
861+
}
862+
})
863+
824864
it('pins shell CLI resolution ahead of caller environment paths', async () => {
825865
const { handle, calls } = fakeSandbox('shell-cli-path')
826866
mockFindSessionSandbox.mockResolvedValue(handle)

‎apps/sim/lib/execution/remote-sandbox/session.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { createSandboxPricing } from '@/lib/billing/sandbox-pricing'
44
import { mapSettledWithConcurrency } from '@/lib/core/utils/concurrency'
55
import { type CreatedSandbox, createSelectedSandbox } from '@/lib/execution/remote-sandbox/create'
6+
import type { SandboxPhaseTimer } from '@/lib/execution/remote-sandbox/phase-timing'
67
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
78
import type { ResolvedSandbox } from '@/lib/execution/remote-sandbox/resolve'
89
import { ensureSessionCli } from '@/lib/execution/remote-sandbox/session-cli'
@@ -56,12 +57,14 @@ export async function ensureSessionSandbox(args: {
5657
session: SandboxSessionRequest
5758
signal: AbortSignal
5859
bootstrapTimeoutMs: number
60+
phases?: SandboxPhaseTimer
5961
}): Promise<SessionAcquisition> {
6062
const { provider, kind, options, selected, session, signal } = args
6163
signal.throwIfAborted()
6264
if (!provider.findSessionSandbox) throw new Error('This deployment has no persistent workbench')
6365
const lifetimeMs = SESSION_SANDBOX_IDLE_MS + (options.lifetimeMs ?? 0)
6466
const requestedAtMs = Date.now()
67+
const endLookup = args.phases?.start('lookup')
6568
const existing = await provider.findSessionSandbox(session.key, {
6669
...(options.language ? { language: options.language } : {}),
6770
lifetimeMs,
@@ -83,6 +86,7 @@ export async function ensureSessionSandbox(args: {
8386
true,
8487
provider
8588
)
89+
endLookup?.()
8690
// Before the abort check: a stopped caller still leaves this machine for the next call to
8791
// reuse, and a reused machine without history can never be certified.
8892
if (!existing)
@@ -96,7 +100,9 @@ export async function ensureSessionSandbox(args: {
96100
await created.sandbox.extendLifetime?.(lifetimeMs)
97101
signal.throwIfAborted()
98102
if (session.cli) {
103+
const endCli = args.phases?.start('cli')
99104
await ensureSessionCli(created.sandbox, session.cli, signal, args.bootstrapTimeoutMs)
105+
endCli?.()
100106
}
101107
signal.throwIfAborted()
102108
return { created, status: existing ? 'reused' : 'created' }

0 commit comments

Comments
 (0)