Skip to content

Commit 156a9d1

Browse files
committed
test(load): add bounded Docker load-testing harness and issue ledger
1 parent 9d24346 commit 156a9d1

22 files changed

Lines changed: 4073 additions & 0 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ scripts/ralph/.codex-last-msg-*
8181
.claude/scheduled_tasks.lock
8282

8383
# Local caches and stray outputs
84+
.artifacts/
8485
crates/execution/assets/v8-bridge.js
8586
crates/execution/assets/v8-bridge-zlib.js
8687
crates/execution/.agentos-pyodide-cache/

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ AgentOS owns the runtime, kernel, VFS, language execution, registry packages,
44
ACP/session layer, AgentOS client APIs, docs, and publish machinery. The
55
`secure-exec` repository is now a generated compatibility mirror only.
66

7+
For future RivetKit work, start with the current documentation index at
8+
https://rivet.dev/llms.txt and follow the linked page for the surface being
9+
changed.
10+
711
## Boundaries
812

913
- Keep AgentOS product versions pinned at `0.0.1` in committed files. Release

docs-internal/load-testing-issues.md

Lines changed: 71 additions & 0 deletions
Large diffs are not rendered by default.

docs-internal/load-testing.md

Lines changed: 981 additions & 0 deletions
Large diffs are not rendered by default.

justfile

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,130 @@ test-bounded cmd='pnpm test':
181181
182182
test-risky-probe *tests:
183183
./.agent/scripts/run-risky-test-probe.sh "$@"
184+
185+
# Build the exact-workspace image used by every load-test lane. Dockerfile-local
186+
# ignore rules keep generated artifacts and unrelated website sources out of the
187+
# build context.
188+
load-test-image:
189+
docker build --file packages/load-tests/Dockerfile --tag agentos-load-tests:local .
190+
191+
# Container-boundary self-test: prove the cgroup envelope, fd ulimit, tmpfs, and
192+
# network isolation match the flags before trusting any survival verdict. Uses
193+
# the exact bounded profile of the limit lane.
194+
load-test-boundary:
195+
#!/usr/bin/env bash
196+
set -euo pipefail
197+
mkdir -p .artifacts/load-tests
198+
container="agentos-load-boundary-$$"
199+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
200+
timeout --signal=TERM --kill-after=30s 2m docker run --rm \
201+
--name "$container" \
202+
--memory=3g --memory-swap=3g --cpus=2 --pids-limit=256 \
203+
--ulimit nofile=1024:1024 --network=none \
204+
--user "$(id -u):$(id -g)" \
205+
--security-opt no-new-privileges --cap-drop=ALL \
206+
--tmpfs /tmp:rw,nosuid,nodev,size=512m,mode=1777 \
207+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
208+
agentos-load-tests:local boundary
209+
210+
# Guest process-limit attack beside a sentinel VM. The workload never runs on
211+
# the host; the container has hard memory/CPU/PID/fd/swap/time ceilings.
212+
load-test-limits:
213+
#!/usr/bin/env bash
214+
set -euo pipefail
215+
mkdir -p .artifacts/load-tests
216+
container="agentos-load-limits-$$"
217+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
218+
timeout --signal=TERM --kill-after=30s 8m docker run --rm \
219+
--name "$container" \
220+
--memory=3g --memory-swap=3g --cpus=2 --pids-limit=256 \
221+
--ulimit nofile=1024:1024 --network=none \
222+
--user "$(id -u):$(id -g)" \
223+
--security-opt no-new-privileges --cap-drop=ALL \
224+
--tmpfs /tmp:rw,nosuid,nodev,size=512m,mode=1777 \
225+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
226+
--env LOAD_TEST_PROCESS_LIMIT --env LOAD_TEST_PROCESS_ATTEMPTS \
227+
agentos-load-tests:local limits
228+
229+
# High-scale adversarial battery: bounded-but-LARGER cgroup (8 CPU / 8 GiB) so
230+
# the V8 executor pool (= CPU count) is big enough to actually run hundreds of
231+
# concurrent VMs. Still a hard-capped container. Runs the `scale` command.
232+
# Args after the recipe name are passed as the command (default `scale`).
233+
load-test-scale cmd='scale':
234+
#!/usr/bin/env bash
235+
set -euo pipefail
236+
mkdir -p .artifacts/load-tests
237+
container="agentos-load-scale-$$"
238+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
239+
timeout --signal=TERM --kill-after=30s 25m docker run --rm \
240+
--name "$container" \
241+
--user "$(id -u):$(id -g)" \
242+
--memory=8g --memory-swap=8g --cpus=8 --pids-limit=2048 \
243+
--ulimit nofile=8192:8192 --network=none \
244+
--security-opt no-new-privileges --cap-drop=ALL \
245+
--tmpfs /tmp:rw,nosuid,nodev,size=2g,mode=1777 \
246+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
247+
--env LOAD_TEST_VM_COUNT --env LOAD_TEST_CONCURRENCY --env LOAD_TEST_CYCLES \
248+
--env LOAD_TEST_EXEC_CONCURRENCY --env LOAD_TEST_MATRIX_ONLY \
249+
agentos-load-tests:local "{{ cmd }}"
250+
251+
# Full deterministic adversarial limit matrix (processes, fds, sockets,
252+
# filesystem bytes) beside a sentinel, same bounded cgroup as the limit lane.
253+
load-test-limits-matrix:
254+
#!/usr/bin/env bash
255+
set -euo pipefail
256+
mkdir -p .artifacts/load-tests
257+
container="agentos-load-matrix-$$"
258+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
259+
timeout --signal=TERM --kill-after=30s 10m docker run --rm \
260+
--name "$container" \
261+
--user "$(id -u):$(id -g)" \
262+
--memory=3g --memory-swap=3g --cpus=2 --pids-limit=256 \
263+
--ulimit nofile=1024:1024 --network=none \
264+
--security-opt no-new-privileges --cap-drop=ALL \
265+
--tmpfs /tmp:rw,nosuid,nodev,size=512m,mode=1777 \
266+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
267+
agentos-load-tests:local limits-matrix
268+
269+
# Sequential, burst, and steady-replacement VM churn with leak gates. This is
270+
# intentionally more generous than the limit lane but remains a bounded cgroup.
271+
load-test-churn:
272+
#!/usr/bin/env bash
273+
set -euo pipefail
274+
mkdir -p .artifacts/load-tests
275+
container="agentos-load-churn-$$"
276+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
277+
timeout --signal=TERM --kill-after=30s 20m docker run --rm \
278+
--name "$container" \
279+
--memory=4g --memory-swap=4g --cpus=3 --pids-limit=384 \
280+
--ulimit nofile=2048:2048 --network=none \
281+
--user "$(id -u):$(id -g)" \
282+
--security-opt no-new-privileges --cap-drop=ALL \
283+
--tmpfs /tmp:rw,nosuid,nodev,size=1g,mode=1777 \
284+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
285+
--env LOAD_TEST_CYCLES --env LOAD_TEST_BATCH --env LOAD_TEST_SETTLE_MS \
286+
--env LOAD_TEST_RSS_SLOPE_BYTES --env LOAD_TEST_RSS_TOTAL_BYTES \
287+
--env LOAD_TEST_PSS_TOTAL_BYTES \
288+
agentos-load-tests:local churn
289+
290+
# The external Compute load generator is also containerized. Unlike the local
291+
# lanes it needs egress to the Rivet APIs, but retains hard resource ceilings.
292+
load-test-compute:
293+
#!/usr/bin/env bash
294+
set -euo pipefail
295+
mkdir -p .artifacts/load-tests
296+
container="agentos-load-compute-$$"
297+
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT INT TERM
298+
timeout --signal=TERM --kill-after=30s 20m docker run --rm \
299+
--name "$container" \
300+
--memory=1g --memory-swap=1g --cpus=1 --pids-limit=128 \
301+
--ulimit nofile=1024:1024 \
302+
--user "$(id -u):$(id -g)" \
303+
--security-opt no-new-privileges --cap-drop=ALL \
304+
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
305+
--volume "$PWD/.artifacts/load-tests:/artifacts" \
306+
--env RIVET_ENDPOINT --env RIVET_PUBLIC_ENDPOINT --env RIVET_RUN_URL \
307+
--env COMPUTE_STEPS --env COMPUTE_HOLD_MS --env COMPUTE_SCALE_DOWN_MS \
308+
--env COMPUTE_ACTOR_NAME --env COMPUTE_CREATE_CONCURRENCY \
309+
--env COMPUTE_READY_TIMEOUT_MS --env COMPUTE_CLEANUP_DEADLINE_MS \
310+
agentos-load-tests:local compute-load

packages/load-tests/Dockerfile

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# Rust >= 1.91.1 is required by the aws-sdk / smithy dependency tree; pin to the
4+
# workspace's toolchain (matches the canonical host: cargo 1.97).
5+
FROM rust:1.97-bookworm AS build
6+
7+
ARG NODE_VERSION=24.4.1
8+
ARG TARGETARCH
9+
10+
RUN apt-get update \
11+
&& apt-get install -y --no-install-recommends ca-certificates clang cmake curl libssl-dev pkg-config python3 xz-utils \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
RUN case "$TARGETARCH" in \
15+
amd64) node_arch=x64 ;; \
16+
arm64) node_arch=arm64 ;; \
17+
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
18+
esac \
19+
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \
20+
| tar -xJ --strip-components=1 -C /usr/local
21+
22+
RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
23+
24+
WORKDIR /app
25+
COPY . .
26+
27+
# Full install so every package in the build chain has its dev toolchain (tsc,
28+
# tsup). The chain is heavier than the cargo build only in disk, not wall-clock.
29+
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
30+
pnpm install --frozen-lockfile
31+
# The exact-workspace release sidecar the VMs run against. Cargo's registry and
32+
# target dir are cache-mounted so editing TypeScript (which busts `COPY . .`)
33+
# does not force a full recompile. The target dir is a cache mount and is not
34+
# part of the image layer, so the binary is copied to a persisted path.
35+
RUN --mount=type=cache,target=/usr/local/cargo/registry \
36+
--mount=type=cache,target=/app/target \
37+
cargo build --release -p agentos-sidecar \
38+
&& mkdir -p /app/release-bin \
39+
&& cp /app/target/release/agentos-sidecar /app/release-bin/agentos-sidecar
40+
# Build only the TS packages the load runner needs, in dependency order, without
41+
# the WASM toolchain (see packages/load-tests/build-deps.sh for why that is
42+
# sound for node-only, defaultSoftware:false workloads).
43+
RUN bash packages/load-tests/build-deps.sh
44+
45+
FROM node:24-bookworm-slim
46+
47+
RUN apt-get update \
48+
&& apt-get install -y --no-install-recommends ca-certificates tini \
49+
&& rm -rf /var/lib/apt/lists/*
50+
51+
WORKDIR /app
52+
COPY --from=build /app /app
53+
54+
ENV AGENTOS_SIDECAR_BIN=/app/release-bin/agentos-sidecar
55+
ENV LOAD_TEST_ARTIFACT_DIR=/artifacts
56+
ENV RIVET_PORT=3000
57+
58+
EXPOSE 3000
59+
ENTRYPOINT ["/usr/bin/tini", "--", "node", "--expose-gc", "packages/load-tests/dist/cli.js"]
60+
CMD ["compute-server"]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.git
2+
.jj
3+
.artifacts
4+
**/node_modules
5+
**/dist
6+
**/.turbo
7+
target
8+
website
9+
toolchain/vendor
10+
toolchain/c/build
11+
toolchain/c/vendor
12+
toolchain/c/libs
13+
toolchain/c/sysroot
14+
toolchain/c/.cache
15+
software/*/bin

packages/load-tests/build-deps.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
# Build exactly the TypeScript packages the load-test image needs, in
3+
# dependency order, WITHOUT the WASM toolchain.
4+
#
5+
# Why this exists (see docs-internal/load-testing.md handoff notes): the load
6+
# workloads only ever run `node` inside `defaultSoftware:false` VMs, so they
7+
# never read a `.aospkg` binary. That means we can build the whole chain with
8+
# `tsc`/`tsup` and skip the heavy toolchain steps:
9+
# - `@agentos-software/*` leaves only export a `packagePath` string; importing
10+
# them never touches the (unbuilt) WASM binary, so `tsc` alone suffices.
11+
# - runtime-core's `copy-commands` (WASM binaries) and `build:protocol`
12+
# (regenerates already-present generated sources) are skipped.
13+
# - core's `build:protocols` is skipped for the same reason.
14+
#
15+
# Run from the repo root. Requires deps installed (`pnpm install`) and, for
16+
# runtime-core's vm-config generation, a working cargo toolchain.
17+
set -euo pipefail
18+
19+
echo "==> building @agentos-software chain (tsc, no WASM binaries)"
20+
# `@agentos-software/common` (the sole default-software import in core) plus its
21+
# eight leaf packages and the manifest types package.
22+
for pkg in manifest coreutils sed grep gawk findutils diffutils tar gzip common; do
23+
pnpm --filter "@agentos-software/${pkg}" exec tsc
24+
done
25+
26+
echo "==> building @rivet-dev/agentos-runtime-core (tsc, no copy-commands)"
27+
# build:vm-config (cargo test) and build:protocol are skipped: their generated
28+
# sources (src/generated/*.ts) are already present in the tree, so this stage
29+
# needs no cargo — keeping the TS build layer independent of the Rust toolchain.
30+
pnpm --filter @rivet-dev/agentos-runtime-core exec tsc
31+
32+
echo "==> building @rivet-dev/agentos-core (tsc)"
33+
pnpm --filter @rivet-dev/agentos-core exec tsc
34+
35+
echo "==> building @rivet-dev/agentos actor bundle (tsup)"
36+
pnpm --filter @rivet-dev/agentos build:actor
37+
38+
echo "==> building @rivet-dev/agentos-load-tests"
39+
pnpm --dir packages/load-tests build
40+
41+
echo "==> load-test dependency build complete"

packages/load-tests/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@rivet-dev/agentos-load-tests",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "tsc",
8+
"check-types": "tsc --noEmit",
9+
"load:limits": "node --expose-gc dist/cli.js limits",
10+
"load:churn": "node --expose-gc dist/cli.js churn",
11+
"compute:serve": "node dist/cli.js compute-server",
12+
"compute:load": "node dist/cli.js compute-load"
13+
},
14+
"dependencies": {
15+
"@rivet-dev/agentos": "workspace:*",
16+
"@rivet-dev/agentos-core": "workspace:*",
17+
"rivetkit": "catalog:rivetkit"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^22.10.2",
21+
"typescript": "^5.9.2"
22+
}
23+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# Orchestrate the bounded local load-test lanes and collect their verdicts.
3+
# Every lane runs inside its `just` Docker wrapper (never on the host). Prints
4+
# one JSON verdict line per run; the caller records these in the run ledger.
5+
#
6+
# Usage: bash packages/load-tests/run-local-lanes.sh [reps]
7+
set -uo pipefail
8+
reps="${1:-3}"
9+
here="$(cd "$(dirname "$0")/../.." && pwd)"
10+
cd "$here"
11+
12+
run() {
13+
local label="$1"; shift
14+
echo "### ${label}"
15+
if "$@"; then
16+
echo "### ${label} recipe exit=0"
17+
else
18+
echo "### ${label} recipe exit=$? (see verdict line / artifact)"
19+
fi
20+
}
21+
22+
run "boundary" just load-test-boundary
23+
24+
for i in $(seq 1 "$reps"); do
25+
run "limits(process) rep ${i}" just load-test-limits
26+
done
27+
28+
for i in $(seq 1 "$reps"); do
29+
run "limits-matrix rep ${i}" just load-test-limits-matrix
30+
done
31+
32+
# Churn runs a few extra reps to calibrate the provisional RSS/PSS ceilings.
33+
churn_reps="${2:-5}"
34+
for i in $(seq 1 "$churn_reps"); do
35+
run "churn rep ${i}" just load-test-churn
36+
done
37+
38+
echo "### all local lanes complete"

0 commit comments

Comments
 (0)