Skip to content

Commit d03edce

Browse files
committed
Speed up selfhost e2e: tunable sandbox deadline + 3-way sharding
1 parent df62bb3 commit d03edce

7 files changed

Lines changed: 114 additions & 23 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,13 @@ jobs:
199199
- { target: cloud, shard: 2/4, shard-name: 2of4 }
200200
- { target: cloud, shard: 3/4, shard-name: 3of4 }
201201
- { target: cloud, shard: 4/4, shard-name: 4of4 }
202-
- target: selfhost
202+
# Selfhost shards the same way: each shard is its own runner booting
203+
# its own fresh instance (own port block + data dir), so the
204+
# project's shared-bootstrap-admin assumption stays intact per shard
205+
# and `fileParallelism: false` still serializes within a shard.
206+
- { target: selfhost, shard: 1/3, shard-name: 1of3 }
207+
- { target: selfhost, shard: 2/3, shard-name: 2of3 }
208+
- { target: selfhost, shard: 3/3, shard-name: 3of3 }
203209
runs-on: blacksmith-4vcpu-ubuntu-2404
204210
timeout-minutes: 30
205211
steps:
@@ -254,7 +260,7 @@ jobs:
254260

255261
- name: Run selfhost scenarios
256262
if: matrix.target == 'selfhost'
257-
run: bunx vitest run --project selfhost --retry=2
263+
run: bunx vitest run --project selfhost --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
258264
working-directory: e2e
259265

260266
# Failed runs keep their trace.zip / session.mp4 / step screenshots in

‎apps/host-selfhost/src/config.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ export interface SelfHostConfig {
4343
readonly organizationName: string;
4444
/** URL slug for org-prefixed console paths (`/<slug>/policies`). */
4545
readonly orgSlug: string;
46+
/**
47+
* Sandbox execution budget passed to the QuickJS runtime, or undefined for
48+
* the runtime's own default (5 minutes). An operator knob in principle, but
49+
* its real consumer is the e2e harness, which shrinks it to seconds so the
50+
* sandbox-deadline scenario proves its race without waiting out real
51+
* minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud).
52+
*/
53+
readonly sandboxTimeoutMs: number | undefined;
4654
}
4755

4856
export const resolveDataDir = (): string =>
@@ -148,9 +156,26 @@ export const loadConfig = (): SelfHostConfig => {
148156
bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin",
149157
organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default",
150158
orgSlug: resolveOrgSlug(),
159+
sandboxTimeoutMs: resolveSandboxTimeoutMs(),
151160
};
152161
};
153162

163+
// A malformed value is refused rather than silently ignored: an operator who
164+
// sets the knob and typos it should find out at boot, not by watching a
165+
// runaway execution use the 5-minute default.
166+
const resolveSandboxTimeoutMs = (): number | undefined => {
167+
const raw = process.env.EXECUTOR_SANDBOX_TIMEOUT_MS;
168+
if (!raw) return undefined;
169+
const parsed = Number(raw);
170+
if (!Number.isFinite(parsed) || parsed <= 0) {
171+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
172+
throw new Error(
173+
`EXECUTOR_SANDBOX_TIMEOUT_MS ${JSON.stringify(raw)} is not a positive number of milliseconds`,
174+
);
175+
}
176+
return Math.floor(parsed);
177+
};
178+
154179
// The org slug doubles as a URL segment (`/<slug>/policies`), so an
155180
// operator-set value must fit the shared grammar and avoid reserved root
156181
// segments (api, mcp, login, …) — a colliding slug would shadow real routes.

‎apps/host-selfhost/src/execution.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ export const SelfHostHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig
6565

6666
export const SelfHostCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
6767
CodeExecutorProvider,
68-
() => makeQuickJsExecutor(),
68+
() => {
69+
const { sandboxTimeoutMs } = loadConfig();
70+
return makeQuickJsExecutor(
71+
sandboxTimeoutMs === undefined ? {} : { timeoutMs: sandboxTimeoutMs },
72+
);
73+
},
6974
);
7075

7176
/**

‎e2e/scenarios/resume-after-sandbox-deadline.test.ts‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
// unknown execution.
99
//
1010
// The journey drives exactly that shape: ONE execution with TWO approval
11-
// gates. The first approval is granted late in its window (~3.5 min), so the
12-
// second pause's window reaches well past the sandbox's 5-minute mark. The
13-
// second approval arrives ~5.75 min after execution start — inside its OWN
14-
// advertised window, but past the old absolute deadline. Deliberately slow
15-
// (~6 min): the elapsed time IS the subject under test. A single-pause
16-
// variant cannot express this cross-target — hosts that advertise a
17-
// 4-minute window would expire it legitimately before the sandbox clock
18-
// even matters.
11+
// gates. The first approval is granted late (70% of the sandbox budget in),
12+
// so the second pause's window reaches well past the budget. The second
13+
// approval arrives at ~115% of the budget after execution start — inside its
14+
// OWN advertised window, but past the old absolute deadline. The subject is
15+
// that RATIO, not any absolute duration, so the delays scale off the budget
16+
// the target was booted with: selfhost boots with a seconds-long
17+
// EXECUTOR_SANDBOX_TIMEOUT_MS (setup/sandbox-timeout.ts) and proves the race
18+
// in ~25s; a target on the production 5-minute budget runs the original
19+
// ~6-minute journey (the elapsed time IS the subject — nothing is mocked). A
20+
// single-pause variant cannot express this cross-target — hosts that
21+
// advertise a 4-minute window would expire it legitimately before the
22+
// sandbox clock even matters.
1923
//
2024
// The gate is `policies.create`'s own `requiresApproval` annotation
2125
// (hermetic, same device as policy-tool-approval.test.ts); both approvals
@@ -29,15 +33,23 @@ import { composePluginApi } from "@executor-js/api/server";
2933
import { scenario } from "../src/scenario";
3034
import { Api, Mcp, Target } from "../src/services";
3135
import { configuredMcpPausedSessionIdleTimeoutMs } from "../setup/mcp-session-timeouts";
36+
import { configuredSandboxTimeoutMs } from "../setup/sandbox-timeout";
3237

3338
const coreApi = composePluginApi([] as const);
3439

35-
// Grant the first approval at 3.5 min — late but inside its 4-minute window.
36-
// The second pause then opens a fresh window reaching ~7.5 min.
37-
const FIRST_APPROVAL_DELAY_MS = 3.5 * 60_000;
38-
// Grant the second approval 2.25 min later: ~5.75 min after execution start,
39-
// past the sandbox's 5-minute budget but inside the second window.
40-
const SECOND_APPROVAL_DELAY_MS = 2.25 * 60_000;
40+
const SANDBOX_BUDGET_MS = configuredSandboxTimeoutMs();
41+
42+
// Grant the first approval at 70% of the budget — late but inside its window
43+
// (was 3.5 of 5 min). The second pause then opens a fresh window reaching
44+
// past the budget.
45+
const FIRST_APPROVAL_DELAY_MS = 0.7 * SANDBOX_BUDGET_MS;
46+
// Grant the second approval 45% of the budget later: ~115% of the budget
47+
// after execution start, past the sandbox clock but inside the second window
48+
// (was 2.25 of 5 min → ~5.75 min total).
49+
const SECOND_APPROVAL_DELAY_MS = 0.45 * SANDBOX_BUDGET_MS;
50+
// The whole journey plus scheduling slack, for the idle-window guard and the
51+
// vitest timeout.
52+
const JOURNEY_MS = FIRST_APPROVAL_DELAY_MS + SECOND_APPROVAL_DELAY_MS;
4153

4254
/** Sandbox code that creates two policies through the approval-gated core
4355
* tool. Patterns are unique-per-run and match no real tool, so the rules are
@@ -56,19 +68,22 @@ const second = await tools.executor.coreTools.policies.create({
5668
return JSON.stringify({ first: first.ok, second: second.ok });
5769
`;
5870

59-
// The journey spans ~6 real minutes of paused waiting, so the host must keep
60-
// the paused session alive that long. The suite's default e2e override shrinks
71+
// The journey spans the whole paused waiting time, so the host must keep the
72+
// paused session alive that long. The suite's default e2e override shrinks
6173
// the paused-session idle teardown to seconds (to keep teardown tests fast),
6274
// which would evict the session mid-scenario for reasons unrelated to the
63-
// clock under test — require the production-like window instead.
75+
// clock under test — require a window that outlasts the journey instead.
76+
// With a shrunken sandbox budget the journey shrinks too, so even the short
77+
// e2e idle window can suffice; the guard compares the two rather than
78+
// hardcoding either.
6479
const PAUSED_IDLE_WINDOW_TOO_SHORT =
65-
configuredMcpPausedSessionIdleTimeoutMs() < 8 * 60_000
66-
? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ~6-minute journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= 480000 to run it`
80+
configuredMcpPausedSessionIdleTimeoutMs() < JOURNEY_MS + 60_000
81+
? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ${Math.round(JOURNEY_MS / 1000)}s journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= ${JOURNEY_MS + 60_000} or a smaller E2E_SANDBOX_TIMEOUT_MS to run it`
6782
: undefined;
6883

6984
scenario(
7085
"MCP · chained approvals granted within their windows survive the sandbox clock",
71-
{ timeout: 480_000, skip: PAUSED_IDLE_WINDOW_TOO_SHORT },
86+
{ timeout: Math.max(120_000, JOURNEY_MS + 120_000), skip: PAUSED_IDLE_WINDOW_TOO_SHORT },
7287
Effect.gen(function* () {
7388
const target = yield* Target;
7489
const apiSurface = yield* Api;

‎e2e/setup/sandbox-timeout.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// The sandbox execution budget shared between a target's boot env and the
2+
// sandbox-deadline scenario, so they cannot drift apart (same pattern as
3+
// execution-limits.ts). The scenario proves a RATIO — approvals granted
4+
// inside their own windows survive an execution that outlives the sandbox's
5+
// absolute budget — so the budget's magnitude is free to shrink: on selfhost
6+
// the boot recipe passes E2E_SANDBOX_TIMEOUT_MS through to the server as
7+
// EXECUTOR_SANDBOX_TIMEOUT_MS and the scenario scales its approval delays to
8+
// match, turning a ~6-minute real-time wait into seconds. Targets that cannot
9+
// shrink the budget (cloud's dynamic-worker deadline is not env-tunable) run
10+
// against the production default and skip via their paused-session window
11+
// guard instead.
12+
export const E2E_SANDBOX_TIMEOUT_MS = 20_000;
13+
14+
export const SANDBOX_TIMEOUT_ENV = "E2E_SANDBOX_TIMEOUT_MS";
15+
16+
const PRODUCTION_SANDBOX_TIMEOUT_MS = 5 * 60_000;
17+
18+
const positiveMilliseconds = (raw: string | undefined): number | undefined => {
19+
if (!raw) return undefined;
20+
const parsed = Number(raw);
21+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
22+
return Math.floor(parsed);
23+
};
24+
25+
/** The sandbox budget the current target enforces: the harness override when
26+
* the target was booted with one, else the production default. */
27+
export const configuredSandboxTimeoutMs = (): number =>
28+
positiveMilliseconds(process.env[SANDBOX_TIMEOUT_ENV]) ?? PRODUCTION_SANDBOX_TIMEOUT_MS;

‎e2e/setup/selfhost.boot.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export interface SelfhostBootOptions {
2121
/** vite --host (e.g. "0.0.0.0" to be tailnet-reachable). */
2222
readonly host?: string;
2323
readonly logFile?: string;
24+
/** Shrink the sandbox execution budget (EXECUTOR_SANDBOX_TIMEOUT_MS) so
25+
* deadline scenarios prove their race in seconds. Omit for production. */
26+
readonly sandboxTimeoutMs?: number;
2427
}
2528

2629
export const bootSelfhost = async (options: SelfhostBootOptions): Promise<BootedProcesses> => {
@@ -51,6 +54,9 @@ export const bootSelfhost = async (options: SelfhostBootOptions): Promise<Booted
5154
// instance at them; the hosted SSRF guard would otherwise block
5255
// outbound probes/dials to localhost. Hermetic test instance only.
5356
EXECUTOR_ALLOW_LOCAL_NETWORK: "true",
57+
...(options.sandboxTimeoutMs !== undefined
58+
? { EXECUTOR_SANDBOX_TIMEOUT_MS: String(options.sandboxTimeoutMs) }
59+
: {}),
5460
},
5561
logFile: options.logFile,
5662
},

‎e2e/setup/selfhost.globalsetup.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
88
import { claimAndBoot } from "../src/ports";
99
import { SELFHOST_ADMIN } from "../targets/selfhost";
1010
import { waitForHttp } from "./boot";
11+
import { E2E_SANDBOX_TIMEOUT_MS, SANDBOX_TIMEOUT_ENV } from "./sandbox-timeout";
1112
import { bootSelfhost } from "./selfhost.boot";
1213
import { RUNS_DIR } from "../src/scenario";
1314

@@ -37,13 +38,18 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
3738
[{ envVar: "E2E_SELFHOST_PORT", offset: 4, label: "selfhost vite dev" }],
3839
async (ports) => {
3940
const port = ports.E2E_SELFHOST_PORT!;
41+
// Shrink the sandbox execution budget and publish the value to the test
42+
// workers (spawned after this globalsetup, so they inherit the env): the
43+
// sandbox-deadline scenario reads it to scale its approval delays.
44+
process.env[SANDBOX_TIMEOUT_ENV] = String(E2E_SANDBOX_TIMEOUT_MS);
4045
// Fresh data dir per suite run — hermetic; in-suite isolation comes from
4146
// fresh identities, not resets (bootSelfhost wipes it).
4247
const procs = await bootSelfhost({
4348
port,
4449
webBaseUrl: `http://localhost:${port}`,
4550
admin: SELFHOST_ADMIN,
4651
logFile: bootLogFile,
52+
sandboxTimeoutMs: E2E_SANDBOX_TIMEOUT_MS,
4753
});
4854
return { teardown: procs.teardown, value: procs };
4955
},

0 commit comments

Comments
 (0)