diff --git a/.gitignore b/.gitignore
index aaadf736..244b42c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,7 @@
# Test binary, built with `go test -c`
*.test
-
+third_party/
# Code coverage profiles and other test artifacts
*.out
coverage.*
@@ -30,3 +30,11 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
+
+# macOS system files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+__MACOSX/
diff --git a/Dockerfile b/Dockerfile
index d8fa6211..724b6b08 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,8 +18,11 @@ RUN git clone --depth 1 --branch ${NSJAIL_VERSION} https://github.com/google/nsj
# ---- Builder / dev image (Go + linters + nsjail) ----
FROM golang:${GO_VERSION}-${DEBIAN_VERSION} AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
- libnl-route-3-200 libprotobuf32 \
- && rm -rf /var/lib/apt/lists/*
+ libnl-route-3-200 libprotobuf32 python3 \
+ default-jdk nodejs iverilog uidmap \
+ && rm -rf /var/lib/apt/lists/* \
+ && echo "root:100000:1000000000" > /etc/subuid \
+ && echo "root:100000:1000000000" > /etc/subgid
COPY --from=nsjail-builder /usr/local/bin/nsjail /usr/local/bin/nsjail
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
WORKDIR /src
@@ -30,10 +33,26 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/goboxd ./cmd/gobox
# ---- Runtime image ----
FROM debian:${DEBIAN_VERSION}-slim AS runtime
+ARG KOTLIN_VERSION=2.1.10
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libnl-route-3-200 libprotobuf32 \
- && rm -rf /var/lib/apt/lists/*
+ python3 gcc libc6-dev php sbcl \
+ g++ default-jdk nodejs iverilog uidmap \
+ curl unzip \
+ # Install the Kotlin command-line compiler (depends on the JDK above)
+ && curl -fsSL -o /tmp/kotlin.zip \
+ "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" \
+ && unzip -q /tmp/kotlin.zip -d /opt \
+ && ln -sf /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc \
+ && ln -sf /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin \
+ && rm -f /tmp/kotlin.zip \
+ && apt-get purge -y --auto-remove unzip \
+ && rm -rf /var/lib/apt/lists/* \
+ && echo "root:100000:1000000000" > /etc/subuid \
+ && echo "root:100000:1000000000" > /etc/subgid
COPY --from=nsjail-builder /usr/local/bin/nsjail /usr/local/bin/nsjail
COPY --from=builder /out/goboxd /usr/local/bin/goboxd
+COPY configs/ /configs/
+ENV LANGUAGE_CONFIG=/configs/languages/languages.yaml
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/goboxd"]
diff --git a/Makefile b/Makefile
index 0b142089..bf9db445 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,14 @@
-.PHONY: build run test integration lint
+.PHONY: build run test integration load lint
COMPOSE ?= docker compose
TOOLS := $(COMPOSE) --profile tools run --rm tools
+# Load test knobs (override on the CLI, e.g. `make load LANG=mixed C=6 N=60`)
+LANG ?= py3
+C ?= 10
+N ?= 100
+URL ?= http://localhost:8080/run
+
build:
$(COMPOSE) build goboxd
@@ -13,7 +19,10 @@ test:
$(TOOLS) go test ./...
integration:
- $(TOOLS) go test -tags=integration ./tests/...
+ $(TOOLS) env LANGUAGE_CONFIG=/src/configs/languages/languages.yaml go test -tags=integration ./tests/...
+
+load:
+ go run scripts/loadtest.go -c $(C) -n $(N) -url $(URL) -lang $(LANG)
lint:
$(TOOLS) golangci-lint run ./...
diff --git a/README.md b/README.md
index cb00af79..f8f92d69 100644
--- a/README.md
+++ b/README.md
@@ -1,70 +1,77 @@
-
-
# goboxd
-**A Go HTTP service for executing untrusted code in isolated sandboxes.**
+goboxd (Go Sandbox Daemon) is a Go HTTP service that compiles and executes untrusted code inside an isolated `nsjail` sandbox and returns per-test results.
+
+---
-[](LICENSE)
-[](https://go.dev)
-[](https://www.docker.com)
-[](https://github.com/thesouldev/goboxd/pulls)
+## HTTP Framework Choice
-
+We use the Go standard library's `net/http` (with the enhanced `ServeMux` introduced in Go 1.22) for all endpoint routing. This design avoids introducing third-party web framework dependencies and guarantees excellent performance with zero external overhead.
---
-## Overview
+## Features
-goboxd is an HTTP service written in Go that compiles and runs untrusted code inside isolated sandboxes and returns the result. Optional test cases can be supplied to assert behaviour against expected output. It is built for safe execution of code across many languages, with strict isolation, bounded concurrency, and a plug and play language registry.
+* **Nested Sandboxing:** Dual-layer containment using Docker and `nsjail` (utilizing user/UTS/PID/IPC namespaces, tmpfs mounts, process caps, and disabled networking).
+* **Plug-and-Play Languages:** Declarative runtime toolchain specs loaded dynamically via YAML, featuring custom smoke check configuration commands.
+* **SJF Concurrency Queue:** Min-heap request scheduling sorted by execution cost, integrated with starvation prevention (wait-time aging) and graceful server shutdown.
+* **Load-Adaptive Resource Clamping:** Monitors request rate over a sliding window, dynamically scaling down CPU and memory limits to protect the host under heavy load.
+* **Process-Unique UID Isolation:** Maps concurrent execution tasks to process-unique unprivileged UIDs, preventing sibling process and workspace directory collisions.
+* **Structured telemetry:** Exposes real-time queue states, active jobs, disk capacity, and registry metadata under `/info`.
-## Features
+---
-- Plug and play language registry driven by YAML
-- Process isolation using Linux namespaces and cgroups
-- Bounded concurrency with request queuing
-- Fully containerised for local development and deployment
-- Per request resource limits for time, memory, and processes
-- Liveness and readiness probes for orchestration
+## Documentation
-## Getting started
+Extended documentation is located in the `docs/` directory:
-### Prerequisites
+* **[API Reference](docs/api.md)** — Payload details for `/run`, `/healthz`, `/readyz`, and `/info`.
+* **[System Architecture](docs/architecture.md)** — Concurrency scheduling, priority queueing, and lifecycle flows.
+* **[Language Registry](docs/languages.md)** — Configuration schema for registering compilers and runtimes.
+* **[Security Model](docs/security.md)** — Explaining namespace isolation, UID mapping, and resource limits.
+* **[Load Testing](docs/loadtest/runs/README.md)** — MemoryHog breaking-point benchmark: methodology, how to run it, and the per-run results/plots (best config: `CONCURRENCY_LIMIT=8`, breaking point 5 rps @ 2 vCPU / 2 GB).
-- Docker with Compose v2
+---
+
+## Configuration
-No Go toolchain or system dependencies are required on the host. Everything runs in containers.
+The service can be configured using the following environment variables:
-### Installation
+| Variable | Description | Default |
+| :--- | :--- | :--- |
+| `CONCURRENCY_LIMIT` | Maximum concurrent sandboxes executing at once. | `runtime.NumCPU()` |
+| `MAX_QUEUE_SIZE` | Maximum pending requests allowed in the priority queue. | `500` |
+| `LANGUAGE_CONFIG` | Path to the registered languages YAML file. | `configs/languages/languages.yaml` |
-```sh
-git clone https://github.com/thesouldev/goboxd.git
-cd goboxd
-make build
-```
+---
-### Usage
+## Getting Started
-```sh
-make run # start the service on :8080
-make test # run unit tests
-make integration # run end to end tests
-make lint # run static analysis
-```
+### Prerequisites
-## Project structure
+* Docker (with compose v2)
-```
-.
-├── cmd/goboxd/ binary entry point
-├── internal/ private application packages
-├── docs/ api, languages, security, benchmarks, architecture
-└── tests/ integration tests
-```
+### Booting the Server
-## Contributing
+1. **Build the container image:**
+ ```bash
+ make build
+ ```
+
+2. **Run the HTTP service (listens on port 8080):**
+ ```bash
+ make run
+ ```
+
+---
-Contributions are welcome. Open an issue to discuss substantial changes before sending a pull request.
+## Command Reference
-## License
+Every common operation has a dedicated Makefile target:
-This project is distributed under the GNU General Public License v3.0. See [LICENSE](LICENSE) for the full text.
+* `make build` — Builds the goboxd runtime image.
+* `make run` — Spins up the goboxd daemon on port 8080.
+* `make test` — Runs unit and configuration validation tests.
+* `make integration` — Runs end-to-end sandbox execution tests inside Docker.
+* `make load` — Launches a local performance load test against a running server.
+* `make lint` — Runs static analysis and code checks (`golangci-lint`).
diff --git a/cmd/goboxd/.gitkeep b/cmd/goboxd/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/cmd/goboxd/main.go b/cmd/goboxd/main.go
new file mode 100644
index 00000000..df6e916e
--- /dev/null
+++ b/cmd/goboxd/main.go
@@ -0,0 +1,130 @@
+// cmd/goboxd/main.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "runtime"
+ "strconv"
+ "syscall"
+ "time"
+
+ "github.com/thesouldev/goboxd/internal/executor"
+ "github.com/thesouldev/goboxd/internal/handler"
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/worker"
+)
+
+func main() {
+ // Configure global structured JSON logger
+ slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
+
+ // Clean up stale orphan directories from previous runs/crashes
+ if err := executor.SweepOrphans(5 * time.Minute); err != nil {
+ slog.Warn("failed to sweep orphan directories at startup", "err", err)
+ } else {
+ slog.Info("startup orphan directory sweep completed")
+ }
+
+ // Load language registry
+ configPath := envOrDefault("LANGUAGE_CONFIG", "configs/languages/languages.yaml")
+ registry, err := languages.Load(configPath)
+ if err != nil {
+ slog.Error("failed to load language config", "err", err)
+ os.Exit(1)
+ }
+ slog.Info("language registry loaded")
+
+ // Determine concurrency limit (default to CPU cores)
+ concurrencyLimit := runtime.NumCPU()
+ if envVal := os.Getenv("CONCURRENCY_LIMIT"); envVal != "" {
+ if val, err := strconv.Atoi(envVal); err == nil && val > 0 {
+ concurrencyLimit = val
+ }
+ } else if envVal := os.Getenv("MAX_CONCURRENT_JOBS"); envVal != "" {
+ if val, err := strconv.Atoi(envVal); err == nil && val > 0 {
+ concurrencyLimit = val
+ }
+ }
+
+ // Determine max queue size (default to 500)
+ maxQueueSize := 500
+ if envVal := os.Getenv("MAX_QUEUE_SIZE"); envVal != "" {
+ if val, err := strconv.Atoi(envVal); err == nil && val >= 0 {
+ maxQueueSize = val
+ }
+ }
+
+ slog.Info("initializing concurrency pool",
+ "concurrency_limit", concurrencyLimit,
+ "max_queue_size", maxQueueSize,
+ )
+
+ // Instantiate stats and pool
+ stats := &handler.ServerStats{}
+ pool := worker.NewConcurrencyPool(concurrencyLimit, maxQueueSize)
+
+ mux := http.NewServeMux()
+
+ // GET /healthz
+ mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, `{"status":"ok"}`)
+ })
+
+ // GET /readyz
+ readyHandler := handler.NewReadyHandler(registry)
+ mux.Handle("GET /readyz", readyHandler)
+
+ // GET /info
+ mux.Handle("GET /info", handler.NewInfoHandler(registry, stats, readyHandler, pool))
+
+ // POST /run
+ mux.Handle("POST /run", &handler.RunHandler{
+ Registry: registry,
+ Stats: stats,
+ Pool: pool,
+ })
+
+ srv := &http.Server{
+ Addr: ":8080",
+ Handler: mux,
+ }
+
+ // Graceful shutdown channel
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+
+ go func() {
+ slog.Info("server starting", "addr", srv.Addr)
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ slog.Error("server failed", "err", err)
+ os.Exit(1)
+ }
+ }()
+
+ <-stop
+ slog.Info("shutting down server gracefully...")
+
+ // 15 seconds window to drain in-flight requests
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ if err := srv.Shutdown(ctx); err != nil {
+ slog.Error("graceful shutdown failed", "err", err)
+ } else {
+ slog.Info("server stopped cleanly")
+ }
+}
+
+func envOrDefault(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/configs/languages/languages.yaml b/configs/languages/languages.yaml
new file mode 100644
index 00000000..e7fe2ac5
--- /dev/null
+++ b/configs/languages/languages.yaml
@@ -0,0 +1,219 @@
+# configs/languages/languages.yaml
+# Christiano Fernandes
+# 31 May 26
+# Language registry
+#
+#
+#
+
+languages:
+ - id: py3
+ name: Python 3
+ source_filename: solution.py
+ run:
+ cmd: /usr/bin/python3
+ args: ["{{source}}"]
+ limits:
+ wall_time_s: 9
+ memory_kb: 102400
+ max_processes: 100
+
+ - id: c
+ name: C
+ source_filename: solution.c
+ artifact: solution
+ build:
+ cmd: /usr/bin/gcc
+ args: ["{{source}}", "-o", "{{artifact}}", "-std=c11"]
+ limits:
+ wall_time_s: 15
+ memory_kb: 1048576
+ max_processes: 100
+ flag_allowlist:
+ - "-O0"
+ - "-O1"
+ - "-O2"
+ - "-Wall"
+ - "-Wextra"
+ - "-std=c11"
+ - "-std=c99"
+ run:
+ cmd: "./{{artifact}}"
+ limits:
+ wall_time_s: 10
+ memory_kb: 128000
+ max_processes: 32
+
+ - id: cpp
+ name: C++
+ source_filename: solution.cpp
+ artifact: solution
+ build:
+ cmd: /usr/bin/g++
+ args: ["{{source}}", "-o", "{{artifact}}", "-std=c++17"]
+ limits:
+ wall_time_s: 15
+ memory_kb: 1048576
+ max_processes: 100
+ flag_allowlist:
+ - "-O0"
+ - "-O1"
+ - "-O2"
+ - "-O3"
+ - "-Wall"
+ - "-Wextra"
+ - "-std=c++17"
+ - "-std=c++20"
+ run:
+ cmd: "./{{artifact}}"
+ limits:
+ wall_time_s: 10
+ memory_kb: 128000
+ max_processes: 32
+
+ - id: java
+ name: Java
+ source_filename_strategy: from_request
+ artifact_filename_strategy: from_request
+ build:
+ cmd: /usr/bin/javac
+ args:
+ - "-J-XX:+UseSerialGC"
+ - "-J-XX:TieredStopAtLevel=1"
+ - "-J-XX:CompressedClassSpaceSize=32m"
+ - "-J-Xms128m"
+ - "-J-Xmx256m"
+ - "{{source}}"
+ limits:
+ wall_time_s: 15
+ memory_kb: 1048576
+ max_processes: 100
+ run:
+ cmd: /usr/bin/java
+ args:
+ - "-XX:+UseSerialGC"
+ - "-XX:TieredStopAtLevel=1"
+ - "-XX:CompressedClassSpaceSize=32m"
+ - "-Xms64m"
+ # 384m heap: headroom for java workloads that hold ~150 MB on the heap
+ # (the 128m default OOMs them) while staying well under the container's
+ # 2 GB cap even at concurrency 2.
+ - "-Xmx384m"
+ - "{{artifact}}"
+ limits:
+ wall_time_s: 10
+ # 1.5 GB rlimit_as so the JVM's virtual reservation (heap + metaspace +
+ # thread stacks) fits at -Xmx384m. Address space, not RSS.
+ memory_kb: 1572864
+ max_processes: 100
+
+ - id: bash
+ name: Bash
+ source_filename: solution.sh
+ run:
+ cmd: /bin/bash
+ args: ["{{source}}"]
+ limits:
+ wall_time_s: 5
+ memory_kb: 51200
+ max_processes: 32
+
+ - id: js
+ name: JavaScript (Node)
+ source_filename: solution.js
+ run:
+ cmd: /usr/bin/node
+ args: ["--max-old-space-size=128", "{{source}}"]
+ limits:
+ wall_time_s: 9
+ memory_kb: 1048576
+ max_processes: 64
+
+ - id: php
+ name: PHP
+ source_filename: solution.php
+ run:
+ cmd: /usr/bin/php
+ args: ["{{source}}"]
+ limits:
+ wall_time_s: 9
+ memory_kb: 102400
+ max_processes: 64
+
+ - id: kotlin
+ name: Kotlin
+ source_filename: solution.kt
+ artifact: solution.jar
+ build:
+ cmd: /usr/local/bin/kotlinc
+ # -J flags bound the compiler JVM's virtual reservation so it fits under
+ # nsjail's rlimit_as (serial GC + small compressed class space), mirroring javac.
+ # NOTE: no -include-runtime — that bundles kotlin-stdlib (~5 MB) into the jar
+ # and trips nsjail's 1 MB RLIMIT_FSIZE. We emit a thin jar (~1 KB) and supply
+ # the already-installed stdlib on the classpath at run time instead.
+ args:
+ - "-J-XX:+UseSerialGC"
+ - "-J-XX:TieredStopAtLevel=1"
+ - "-J-XX:CompressedClassSpaceSize=64m"
+ - "-J-Xms128m"
+ - "-J-Xmx512m"
+ - "{{source}}"
+ - "-d"
+ - "{{artifact}}"
+ limits:
+ wall_time_s: 60
+ memory_kb: 2097152
+ max_processes: 256
+ run:
+ cmd: /usr/bin/java
+ # Thin jar on the classpath alongside the host-installed kotlin-stdlib (read-only,
+ # so RLIMIT_FSIZE never applies to it). Main class is Kt:
+ # solution.kt compiles to SolutionKt.
+ args:
+ - "-XX:+UseSerialGC"
+ - "-XX:TieredStopAtLevel=1"
+ - "-XX:CompressedClassSpaceSize=32m"
+ - "-Xms64m"
+ - "-Xmx256m"
+ - "-cp"
+ - "{{artifact}}:/opt/kotlinc/lib/kotlin-stdlib.jar"
+ - "SolutionKt"
+ limits:
+ wall_time_s: 10
+ memory_kb: 1048576
+ max_processes: 100
+ smoke_check_cmd: /usr/local/bin/kotlinc
+ smoke_check_args: ["-version"]
+
+ - id: lisp
+ name: Common Lisp (SBCL)
+ source_filename: solution.lisp
+ run:
+ cmd: /usr/bin/sbcl
+ # --dynamic-space-size bounds SBCL's GC heap reservation so it fits under rlimit_as.
+ args: ["--dynamic-space-size", "256", "--script", "{{source}}"]
+ limits:
+ wall_time_s: 10
+ memory_kb: 1048576
+ max_processes: 32
+ smoke_check_cmd: /usr/bin/sbcl
+ smoke_check_args: ["--version"]
+
+ - id: verilog
+ name: Verilog
+ source_filename: solution.v
+ artifact: solution.vvp
+ build:
+ cmd: /usr/bin/iverilog
+ args: ["-o", "{{artifact}}", "{{source}}"]
+ limits:
+ wall_time_s: 10
+ memory_kb: 262144
+ max_processes: 32
+ run:
+ cmd: /usr/bin/vvp
+ args: ["{{artifact}}"]
+ limits:
+ wall_time_s: 5
+ memory_kb: 128000
+ max_processes: 32
diff --git a/docker-compose.yml b/docker-compose.yml
index 155d3ce4..bb69deaf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,13 +7,29 @@ services:
container_name: goboxd
ports:
- "8080:8080"
- privileged: true
+ # Hard caps: 2 vCPUs of CPU time and 2 GB RAM (honored by `docker compose up`).
+ cpus: 2.0
+ mem_limit: 2g
+ # Pin job concurrency to the CPU cap. The app otherwise defaults to
+ # runtime.NumCPU(), which reads the HOST core count, not the cgroup limit.
+ environment:
+ - CONCURRENCY_LIMIT=16
+ cap_add:
+ - SYS_ADMIN
+ security_opt:
+ - apparmor=unconfined
+ - seccomp=unconfined
tools:
build:
context: .
target: builder
image: goboxd-tools:dev
+ cap_add:
+ - SYS_ADMIN
+ security_opt:
+ - apparmor=unconfined
+ - seccomp=unconfined
volumes:
- .:/src
working_dir: /src
diff --git a/docs/ai/adrs.md b/docs/ai/adrs.md
new file mode 100644
index 00000000..a3d0b8d0
--- /dev/null
+++ b/docs/ai/adrs.md
@@ -0,0 +1,51 @@
+# docs/ai/adrs.md
+# Architectural Decision Records (ADRs)
+
+## ADR 1: Read-Only Host-Root Chroot (`--chroot /`)
+
+**Context:**
+When executing compiled binary code (like C) or running interpreters (like Python) under `nsjail`, the application requires access to system libraries (e.g. `libc.so`, `libpython3.11.so.1.0`) and runtime binaries. If we isolate the sandbox using a clean chroot folder containing only the user program, execution fails immediately because the dynamic linker cannot find its dependencies.
+
+**Options considered:**
+1. **Custom Minimal Chroot Directory:** Dynamically detect all shared library dependencies using `ldd` and copy them into the sandbox workspace for every execution.
+2. **Host-Root Chroot with Read-Only Binds (`--chroot /`):** Use the host filesystem's `/` as the chroot path in read-only mode, and bind paths like `/lib`, `/usr`, and `/bin` read-only.
+
+**Decision:**
+Use the host root `/` as a read-only chroot combined with read-only binds, while mounting a separate writable `tmpfs` at `/tmp` for temporary workspaces.
+
+**Rationale:**
+Copying dependencies dynamically is slow, fragile, and prone to breaking when language runtimes update. Using `--chroot /` read-only keeps shared library lookups 100% correct, supports compilation pipelines out of the box, and ensures files on the host are protected since the sandbox has zero write access to anything except `/tmp`.
+
+---
+
+## ADR 2: Map-Based Registry Pattern via YAML
+
+**Context:**
+We need to support multiple languages with unique compilation/run arguments and limits. Hardcoding language rules within Go structs or handlers makes extending the platform difficult. Adding new runtimes should be possible without editing Go code.
+
+**Options considered:**
+1. **Hardcoded Switch/Case:** Map language behavior using Go code logic in the handlers.
+2. **YAML-Driven Generic Registry:** Store all metadata, arguments, commands, and allow-lists inside a structured YAML file, loading it into a map-based lookup at server startup.
+
+**Decision:**
+Load and validate languages from `configs/languages/languages.yaml` into a `map[string]Language` registry at startup.
+
+**Rationale:**
+This separates platform logic from runtime configuration, complying with the "plug-and-play" architectural constraint. We can support additional runtimes in Stage 2 simply by editing the YAML file.
+
+---
+
+## ADR 3: Atomic Temporary Directories (`os.MkdirTemp`)
+
+**Context:**
+The executor writes user code to files on disk, compiles them, feeds inputs, and compares outputs. If multiple requests execute concurrently using static directory paths, they will overwrite each other's source files, causing collisions and data leaks.
+
+**Options considered:**
+1. **UID-based directories:** Generate folders named after the run ID or numeric request ID.
+2. **Atomic Temp Directories (`os.MkdirTemp`):** Use the operating system's temporary folder API to create randomly-named directories atomically.
+
+**Decision:**
+Create a unique directory using `os.MkdirTemp` for every run, and use Go's `defer` to clean up the directory when the function finishes.
+
+**Rationale:**
+`os.MkdirTemp` guarantees that directory names do not collide, providing atomic isolation on the disk level. Utilizing `defer os.RemoveAll` ensures cleanup is executed on all execution paths, preventing stale files from polluting disk space.
diff --git a/docs/ai/issues.md b/docs/ai/issues.md
new file mode 100644
index 00000000..c6712964
--- /dev/null
+++ b/docs/ai/issues.md
@@ -0,0 +1,34 @@
+# docs/ai/issues.md
+# Issues Log (Stage 1)
+
+## 2026-05-31 · nsjail Python 3 dynamic library loading failure
+
+**What we were trying to do:**
+Execute sandboxed Python scripts inside `nsjail` by running the interpreter `/usr/bin/python3` inside the container runtime.
+
+**What went wrong:**
+The sandbox execution failed immediately with the error:
+`python3: error while loading shared libraries: libpython3.11.so.1.0: cannot open shared object file: No such file or directory`
+
+**How we resolved it:**
+We discovered that `nsjail`'s custom isolation did not map the host system's library folder paths correctly into the running workspace. We resolved it by mounting the host root read-only using `--chroot /` and explicitly setting up read-only bindings for `/lib`, `/usr/lib`, and `/lib64`.
+
+**What we learned:**
+Standard runtimes/interpreters have dynamic linkages that require full system access paths, meaning custom minimal root jail directory mappings will fail unless system directories are bound read-only.
+
+---
+
+## 2026-05-31 · GCC compilation failing with missing stdio.h header
+
+**What we were trying to do:**
+Compile user-submitted C programs under the sandbox using `/usr/bin/gcc`.
+
+**What went wrong:**
+The compilation failed during execution with:
+`fatal error: stdio.h: No such file or directory`
+
+**How we resolved it:**
+We checked the Dockerfile runtime dependencies and found that we only installed `gcc`. In Debian, the standard library header files are not bundled with the compiler but reside in `libc6-dev`. We added `libc6-dev` to the runtime stage package manager list in the `Dockerfile`.
+
+**What we learned:**
+Always install developer header packages alongside standard compilation toolchains in base container layers to ensure compiler environments are fully self-sufficient.
diff --git a/docs/ai/plan-evalution.md b/docs/ai/plan-evalution.md
new file mode 100644
index 00000000..b7083de3
--- /dev/null
+++ b/docs/ai/plan-evalution.md
@@ -0,0 +1,39 @@
+# docs/ai/plan-evolution.md
+# Plan Evolution Log (Stage 1)
+
+## 2026-05-31 · Pivot from custom chroot directories to read-only host-root chroot (`--chroot /`)
+
+**What we thought we'd do:**
+Isolate executions inside a minimal custom chroot directory containing only the compiled user executable/source files.
+
+**What we actually did:**
+Switched to `--chroot /` (mounting the host root read-only in nsjail), combined with read-only binds of system runtime paths (`/lib`, `/usr`) and a writable `tmpfs` mounted at `/tmp`.
+
+**Why it changed:**
+Running under a custom minimal chroot broke interpreters (Python) and compilers (GCC) which failed with "shared library not found" and "header not found" errors (e.g., missing `libpython3.11.so.1.0` or `stdio.h`). Mounting the host root read-only securely resolved these dependencies while keeping the user code containerized and isolated.
+
+---
+
+## 2026-05-31 · Trimming Stage 1 scope to meet the June 1 EOD deadline
+
+**What we thought we'd do:**
+Implement all features (concurrency pool, security boundary verification, load testing, all 7 languages, and `/readyz`/`/info` endpoints) in a single massive development push.
+
+**What we actually did:**
+Separated Stage 1 must-ship features from subsequent stages. Focused strictly on `POST /run` for Python and C, basic `/healthz`, isolation via nsjail, Docker compilation stages, and initial unit/integration tests.
+
+**Why it changed:**
+The competition timeline explicitly splits Stage 1 (due June 1 EOD) from Stage 2/3. To ensure a solid, well-tested prototype was delivered on time, we trimmed unnecessary scope and focused on core stability.
+
+---
+
+## 2026-05-31 · Health endpoint response payload
+
+**What we thought we'd do:**
+Implement `GET /healthz` returning a simple plain text string `"ok"` with status code 200.
+
+**What we actually did:**
+Updated `GET /healthz` to return a structured JSON object `{"status":"ok"}`.
+
+**Why it changed:**
+To ensure exact compliance with the JSON API contract specified in the official competition brief.
diff --git a/docs/ai/prompts.md b/docs/ai/prompts.md
new file mode 100644
index 00000000..2f69ae53
--- /dev/null
+++ b/docs/ai/prompts.md
@@ -0,0 +1,233 @@
+# docs/ai/prompts.md
+# AI Prompts Log (Stage 1)
+
+## 2026-05-31 · Stage 1 Build sequence & Workspace Setup
+
+**Prompt:**
+Where do I start and what do I need to complete?
+
+**Response summary:**
+Identified empty folders marked with `.gitkeep` files and defined an 11-step build order to safely bring up Stage 1: `main.go` entrypoint → models → languages config loader → YAML language definitions → executor → handler → Dockerfile updates → unit tests → documentation → linting → PR opening.
+
+**What we used / didn't use:**
+Used the proposed 11-step sequence as our checklist to maintain a steady, testable flow of commits.
+
+---
+
+## 2026-05-31 · Go project packages and directory structure
+
+**Prompt:**
+How should I efficiently structure files and folders?
+
+**Response summary:**
+Confirmed that standard Go layouts (`cmd/` for entrypoints, `internal/` for private application modules, `configs/` for language configuration) were ideal, keeping package names aligned with folder names.
+
+**What we used / didn't use:**
+Adopted the clean structure layout, keeping packages decoupled (e.g. `validate`, `executor`, `languages`).
+
+---
+
+## 2026-05-31 · API spec alignment with full competition brief
+
+**Prompt:**
+Pasted the full competition brief. Do we need external libraries/packages, and are there any differences between this brief and the initial plans?
+
+**Response summary:**
+Stated that no third-party libraries were required beyond `gopkg.in/yaml.v3` for configuration. Flagged key specification updates from the brief: field names (`source` instead of `code`), new `/readyz` and `/info` endpoints, required security mitigations, and performance tests. Updated the build checklist to 16 steps.
+
+**What we used / didn't use:**
+Used standard library packages only, renamed fields to match the final spec, and expanded our task checklist.
+
+---
+
+## 2026-05-31 · Stage timeline vs. specs resolution
+
+**Prompt:**
+Confused about what to build when — stage spec vs. stage timeline?
+
+**Response summary:**
+Clarified that Stage 1 corresponds to the June 1 EOD deadline and requires a basic working prototype with C/Python runtimes. Suggested deferring `/readyz`, `/info`, concurrency pools, and load testing to Stage 2/3. Trimmed the build sequence to 13 steps.
+
+**What we used / didn't use:**
+Deferred endpoints and limits, focusing strictly on core POST `/run` support for Python and C.
+
+---
+
+## 2026-05-31 · cmd/goboxd/main.go HTTP server and health check
+
+**Prompt:**
+Start Step 1: cmd/goboxd/main.go.
+
+**Response summary:**
+Provided Go code to start a basic HTTP server on port `:8080` returning "ok" on `GET /healthz`. Suggested using `http.NewServeMux` with Go 1.22 routing pattern and `log/slog`.
+
+**What we used / didn't use:**
+Implemented the HTTP server and routing. We initially returned a plain text string "ok", but corrected it in a later step to return JSON `{"status":"ok"}`.
+
+---
+
+## 2026-05-31 · Designing model structs, language registry, and YAML loader
+
+**Prompt:**
+Guide through model structs and language registry.
+
+**Response summary:**
+Proposed structures for `RunRequest` and `RunResponse` JSON schemas, along with the `Language` and `PhaseConfig` YAML mapping structs. Provided a map-based parser for `languages.yaml`.
+
+**What we used / didn't use:**
+Used the exact JSON and YAML struct definitions. Mounted configuration lookup as a map of `Language` objects keyed by identifier.
+
+---
+
+## 2026-05-31 · Executor pipeline & validation mechanisms
+
+**Prompt:**
+Continue with executor and handler.
+
+**Response summary:**
+Proposed an execution architecture: creating unique work folders, executing compilation and run stages sequentially, reading standard output streams safely, and validating requests (filenames, code size, test suite bounds).
+
+**What we used / didn't use:**
+Used the suggested multi-stage execution pipeline and implemented validation rules to block path traversal.
+
+---
+
+## 2026-05-31 · Wiring POST /run handler into Go HTTP Server
+
+**Prompt:**
+Wire executor into the HTTP handler.
+
+**Response summary:**
+Provided `internal/handler/handler.go` implementation to parse requests, enforce standard constraints (size bounds, limits), execute the run module, and handle exceptions.
+
+**What we used / didn't use:**
+Wired the handler into `main.go`. Disallowed requests missing the `language` field or containing invalid filename schemas.
+
+---
+
+## 2026-05-31 · Dockerfile runtime dependency adjustment (Python)
+
+**Prompt:**
+Dockerfile currently missing python3 and gcc in runtime stage.
+
+**Response summary:**
+Suggested adding `python3` and `gcc` packages to the `apt-get install` block in stage 3, and copy configuration files to `/configs` while setting the `LANGUAGE_CONFIG` environment variable.
+
+**What we used / didn't use:**
+Modified the Dockerfile runtime layer and verified that the container successfully loaded `languages.yaml` upon launch.
+
+---
+
+## 2026-05-31 · Unit testing strategy (validate, registry, capped writer)
+
+**Prompt:**
+Write unit tests.
+
+**Response summary:**
+Supplied boilerplate for `validate_test.go` (asserting filename boundary checks and size constraints) and `registry_test.go` (verifying YAML loading behavior).
+
+**What we used / didn't use:**
+Used the unit test structures to cover core packages, running them successfully with `make test`.
+
+---
+
+## 2026-05-31 · Debugging nsjail dynamic linker library loading errors
+
+**Prompt:**
+POST /run failing with "error while loading shared libraries: libpython3.11.so.1.0".
+
+**Response summary:**
+Diagnosed that nsjail's chroot is isolating the process from the host's `/lib` and `/usr` libraries. Suggested mounting the root directory read-only (`--chroot /`) and binding libraries (`--ro-bind /lib`, `--ro-bind /usr`).
+
+**What we used / didn't use:**
+Used the `--chroot /` read-only mount approach. Modified the sandboxed flags to bind system runtime directories directly.
+
+---
+
+## 2026-05-31 · C Compilation Error (missing stdio.h and libc6-dev)
+
+**Prompt:**
+C run returning "fatal error: stdio.h: No such file or directory".
+
+**Response summary:**
+Identified that `gcc` on Debian does not include base C headers. Recommended adding `libc6-dev` to the runtime container installation packages.
+
+**What we used / didn't use:**
+Installed `libc6-dev` in the runtime stage of the Dockerfile. Rebuilt the image, successfully compiling and executing C programs.
+
+---
+
+## 2026-05-31 · Stage 1 Completion check & documentation prep
+
+**Prompt:**
+We've basically completed Stage 1 requirements.
+
+**Response summary:**
+Summarized deliverables achieved (health check JSON, Python/C runs, tests passing, linting clean). Outlined the next phase documentation requirements (`README.md`, `api.md`, `architecture.md`, `languages.md`, `security.md`).
+
+**What we used / didn't use:**
+Accepted the summary and prepared to start writing the comprehensive documentation files.
+
+---
+
+## 2026-06-01 · Stage 3 Bounded Concurrency, Dynamic Scheduling, and Load-Adaptive Limits
+
+**Prompt:**
+Work on the concurrency and sustained load part. Discuss Docker container resource allocations, enforce minimum memory limits to prevent boot failures for Java and JavaScript, implement priority queueing (Shortest Job First with Starvation Aging), overload queue size limit at 500, dynamic Retry-After header calculations, and graceful shutdown.
+
+**Response summary:**
+Provided architecture and design details:
+- Bounded Concurrency: Channel-based semaphore to limit active jobs to 15, and Priority Queue (using `container/heap`) to hold up to 500 waiting requests.
+- SJF + Aging: Expected wall time cost score with wait-time aging subtractions to prevent starvation.
+- Under/Over-Allocation Clamping: Java/JS runs clamped to minimum 1 GB, other overrides clamped to safe maximums. Clamping modifications returned via a `warnings` field.
+- Load-Adaptive Capping: Sliding window tracker dynamically lowers maximum resource overrides when request rate exceeds 5 req/sec.
+- Graceful Shutdown: Captured SIGINT/SIGTERM to trigger `http.Server.Shutdown`.
+Implemented the changes across `pool.go`, `validate.go`, `handler.go`, `info.go`, and `main.go`.
+
+**What we used / didn't use:**
+Implemented all proposed changes. Verified with new test cases covering rate tracking, priority sorting, aging, and load-shedding.
+
+---
+
+## 2026-06-12 · Plug-and-play language test: adding PHP
+
+**Prompt:**
+Test the plug-and-play registry by adding PHP, and append the load tester (`scripts/loadtest.go`) for PHP too.
+
+**Response summary:**
+Confirmed the registry is pure-YAML, so a new language needs no Go changes — only a `languages.yaml` entry plus the runtime toolchain in the Docker image. Added an interpreted `php` entry (`/usr/bin/php {{source}}`), a `php` payload to the load tester, and wired `php` into the `mixed` rotation and the usage strings. Noted that `/readyz` smoke-checks new languages automatically via its `--version` fallback (no handler change needed). PHP was already present in the runtime Dockerfile stage.
+
+**What we used / didn't use:**
+Used the YAML-only path. PHP ran first try end-to-end (`accepted`, `hello`). Confirmed the principle that interpreted languages are effectively zero-friction to add.
+
+---
+
+## 2026-06-12 · Adding Kotlin and Lisp — three sandbox issues, all solved without touching Go
+
+**Prompt:**
+Add two more languages, Kotlin and Lisp, and keep it plug-and-play (no edits to the core Go files).
+
+**Response summary:**
+Added `kotlin` (compiled: `kotlinc` build → `java` run) and `lisp` (SBCL, interpreted) YAML entries, installed `sbcl` and the Kotlin compiler in the runtime Docker stage, and extended the load tester. The first end-to-end run surfaced three distinct sandbox failures, each diagnosed by reproducing the exact `nsjail` invocation inside the container:
+
+1. **Lisp — `os_alloc_gc_space ... ENOMEM`.** SBCL reserves ~1 GB of *virtual* address space at boot; nsjail's `--rlimit_as` (derived from `memory_kb`) was far below it. Fix: bound SBCL's heap with `--dynamic-space-size 256` and raise `memory_kb` so the virtual reservation fits.
+2. **Kotlin build — silent failure, then `java.io.IOException: Map failed`.** The compiler JVM reserves multiple GB of virtual space. Mirrored the existing Java entry's `-J-XX` flags (`+UseSerialGC`, `CompressedClassSpaceSize`, bounded `-Xmx`) to collapse the reservation under `rlimit_as`.
+3. **Kotlin build — `Caused by: java.io.IOException: File too large`.** The decisive clue. `-include-runtime` bundles `kotlin-stdlib` into a ~4.7 MB fat jar, but nsjail defaults `RLIMIT_FSIZE` to **1 MB**, so the sandbox could not write the artifact. Since the core Go (`sandbox/nsjail.go`, which would set `--rlimit_fsize`) was off-limits, we *removed* the unwanted bundled binary instead: drop `-include-runtime` to emit a ~1 KB thin jar, and load the host-installed `kotlin-stdlib.jar` (read-only, exempt from `RLIMIT_FSIZE`) on the classpath at run time via `-cp {{artifact}}:/opt/kotlinc/lib/kotlin-stdlib.jar SolutionKt`.
+
+Iterated by `docker cp`-ing the edited `languages.yaml` into the running container and restarting (registry reloads at startup), avoiding a full rebuild per attempt.
+
+**What we used / didn't use:**
+Used the config-only approach throughout — a proposed `--rlimit_fsize` addition to `sandbox/nsjail.go` was explicitly rejected to keep the change plug-and-play, so we worked around the 1 MB cap by shrinking the artifact. All three languages ended `accepted`. Open caveat recorded: the 1 MB `RLIMIT_FSIZE` is sidestepped, not fixed — a future language whose sandbox-written artifact exceeds 1 MB will need the Go change.
+
+---
+
+## 2026-06-12 · Load-testing all 10 languages
+
+**Prompt:**
+Use the load test file and test.
+
+**Response summary:**
+Ran `scripts/loadtest.go` against the live server: per-language passes for `php`, `lisp`, `kotlin` (12 requests each, concurrency 4), then a `mixed` pass (60 requests, concurrency 6) exercising all ten languages plus the random under/over-allocation override injection. PHP ~18 ms p50, Lisp ~12 ms p50, Kotlin ~2.2 s p50 (JVM compile dominates). All individual runs were 12/12 `accepted`. Mixed: 60/60 HTTP 200, 54 `accepted`, 6 `runtime_error`, 63 clamp warnings — the errors and warnings being the deliberately-injected 256 KB under-allocations and over-allocation clamps, i.e. the load tester verifying the limit machinery rather than language failures.
+
+**What we used / didn't use:**
+Used the existing load tester as-is with the new payloads. Confirmed all ten languages pass under concurrency; interpreted the mixed-mode `runtime_error`/warning counts as expected behaviour, not regressions.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 00000000..756cdfe9
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,130 @@
+# API Reference (Stage 1)
+
+This document describes the HTTP endpoints, payload definitions, and validation constraints implemented for **Stage 1** of the goboxd service.
+
+---
+
+## Endpoints
+
+| Method | Path | Description | Access |
+|---|---|---|---|
+| `GET` | `/healthz` | Lightweight liveness probe | Public |
+| `POST` | `/run` | Compiles and executes untrusted code in an isolated sandbox | Public |
+
+---
+
+## 1. GET /healthz
+Checks if the HTTP server is up and responsive.
+
+### Response
+* **Status Code:** `200 OK`
+* **Content-Type:** `application/json`
+```json
+{
+ "status": "ok"
+}
+```
+
+---
+
+## 2. POST /run
+Submits code to be written, compiled (optional), and run under `nsjail` isolation against test cases.
+
+### Request Payload (`RunRequest`)
+```json
+{
+ "language": "c",
+ "source": "#include \nint main() { printf(\"Hello!\"); return 0; }",
+ "source_filename": "solution.c",
+ "artifact_filename": "solution",
+ "build": {
+ "limits": { "wall_time_s": 15, "memory_kb": 1048576, "max_processes": 100 },
+ "flags": ["-O2", "-std=c11"]
+ },
+ "run": {
+ "limits": { "wall_time_s": 10, "memory_kb": 128000, "max_processes": 32 },
+ "flags": []
+ },
+ "tests": [
+ {
+ "stdin": "",
+ "expected_stdout": "Hello!"
+ }
+ ]
+}
+```
+
+#### Fields Description:
+* `language` *(string, Required)*: The identifier matching a registered language runtime (`py3` or `c` for Stage 1).
+* `source` *(string, Required)*: UTF-8 source code to run. Must be non-empty and ≤ 256 KiB.
+* `source_filename` *(string, Optional)*: Custom filename. Must be a single path component (no directory separators `/` or `\`) and cannot start with `.`.
+* `artifact_filename` *(string, Optional)*: Custom compiled output filename. Must be a single path component and cannot start with `.`.
+* `build` / `run` *(object, Optional)*: Limits overrides and compiler/interpreter flags.
+ * `limits` *(object, Optional)*: Wall time, memory, or process limit bounds.
+ * `flags` *(array of strings, Optional)*: Custom compiler flags. Checked against a configuration allow-list.
+* `tests` *(array of objects, Required)*: Non-empty list of test cases (capped at 50 cases in Stage 1).
+
+---
+
+### Response Payload (`RunResponse`)
+The server always returns HTTP `200 OK` even if user code fails to compile or run, detailing the failure inside the JSON response.
+
+```json
+{
+ "status": "accepted",
+ "build": {
+ "status": "ok",
+ "stdout": "",
+ "stderr": "",
+ "duration_ms": 74
+ },
+ "tests": [
+ {
+ "status": "accepted",
+ "stdout": "Hello!",
+ "stderr": "",
+ "duration_ms": 2
+ }
+ ]
+}
+```
+
+#### Status Vocabulary
+
+| Scope | Value | Description |
+|---|---|---|
+| `build.status` | `ok` | Compilation completed successfully. |
+| | `failed` | Compilation returned a non-zero exit code. |
+| | `internal_error` | Server error setting up compilation. |
+| `tests[].status` | `accepted` | Test stdout matched expected value exactly (whitespace ignored/normalized). |
+| | `wrong_output` | Executed successfully but stdout did not match. |
+| | `output_whitespace_mismatch` | Output matched expected value except for white space differences. |
+| | `time_exceeded` | Process killed because execution exceeded the wall-time limit. |
+| | `memory_exceeded` | Process killed because execution exceeded the memory limit. |
+| | `runtime_error` | Process exited with a non-zero code or signal. |
+| | `not_executed` | Test skipped (e.g. because compilation failed). |
+| | `internal_error` | Sandbox environment failed to initiate. |
+| **Top-Level `status`** | Same as above | Returns `accepted` only if all tests succeeded. Otherwise, returns the status of the first failing step/test. If the build step fails, returns `build_failed`. |
+
+---
+
+### Error Payloads (HTTP 400 Bad Request)
+Returned when payload validation fails prior to execution.
+
+```json
+{
+ "error": {
+ "code": "disallowed_flag",
+ "message": "flag \"-Ofast\" is not allowed for this language"
+ }
+}
+```
+
+#### Error Code Types:
+* `invalid_json`: Bad request payload JSON format.
+* `missing_language`: Request did not specify a `language` identifier.
+* `unknown_language`: Specified language is not supported.
+* `invalid_source`: Code size exceeds 256 KiB or is empty.
+* `invalid_tests`: Missing tests array or too many tests (capped at 50).
+* `invalid_filename`: Filename contains path separators or dot prefixes.
+* `disallowed_flag`: Build flag not matching the runtime's configuration allow-list.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 00000000..e4e8de8e
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,115 @@
+# System Architecture (Stage 3)
+
+This document describes the internal structure, component design, and execution lifecycle of the **goboxd** sandbox service.
+
+---
+
+## 1. Component Architecture & Route Lifecycles
+
+The service consists of three core HTTP endpoints, wired through a centralized mapping registry, validator, and concurrency scheduler:
+
+```mermaid
+graph TD
+ Client[HTTP Client] --> Mux[net/http ServeMux]
+ Mux -->|POST /run| RunH[RunHandler]
+ Mux -->|GET /readyz| ReadyH[ReadyHandler]
+ Mux -->|GET /info| InfoH[InfoHandler]
+
+ RunH --> Val[validate package]
+ RunH --> Reg[languages Registry]
+ RunH --> Pool[worker.ConcurrencyPool]
+ RunH --> Exec[executor package]
+ Exec --> Sand[sandbox package]
+ Sand --> NsJail[nsjail Binary]
+
+ ReadyH --> Reg
+ ReadyH --> ExecLook[exec.LookPath Check]
+
+ InfoH --> Reg
+ InfoH --> ReadyH
+ InfoH --> Pool
+ InfoH --> Stats[atomic Request Counter]
+```
+
+### Request Lifecycles:
+
+#### POST /run (Code Execution)
+1. **Validation & Clamping:** The HTTP request body is capped at 4 MiB. The `validate` package checks that the source code does not exceed 256 KiB, individual test case stdin and expected output sizes do not exceed 64 KiB, and test counts do not exceed 50. It also enforces single-path component filenames (mitigating path traversal). Per-request resource limits are dynamically validated and **clamped** instead of rejected:
+ * **Java/JS Min Memory:** Enforces a minimum of 1 GB memory mapping to prevent runtime boot crashes.
+ * **C/C++ Build Min Memory:** Enforces a minimum of 256 MB.
+ * **Load-Adaptive Upper Caps:** If the sliding window request rate exceeds 5 req/sec, maximum resource caps (memory, wall time) are dynamically scaled down to protect the host.
+ * **Warnings:** Clamped adjustments are logged and returned in the `warnings` array.
+2. **Registry Lookup:** Resolves the compilation and execution commands, default limits, and strategy flags for the specified language.
+3. **Concurrency Acquisition:** The request attempts to acquire a slot in the `worker.ConcurrencyPool`.
+ * If the pool is saturated (15 active slots + 500 queue slots are full), it rejects the request immediately with `503 Service Unavailable` and a dynamic `Retry-After` estimation header.
+ * If slots are full but the queue is not, the request is placed in a **Min-Heap Priority Queue** ordered by job cost (wall time and memory requested) with wait-time aging to prevent starvation.
+4. **Execution Pipeline:** An ephemeral directory is created using `os.MkdirTemp`.
+ * **Compilation:** For compiled languages, compiles inside the sandbox using `nsjail`.
+ * **Test Run Loop:** Runs the code inside `nsjail` against each test case sequentially.
+5. **Garbage Collection & Release:** The temporary directory is deleted, and the concurrency slot is released, triggering a queue re-heapify and scheduling the next job.
+
+#### GET /readyz (Readiness Check)
+The `ReadyHandler` runs an assertion suite once upon server startup, caching the outcome:
+1. Verifies that `nsjail` is present.
+2. Resolves each language's compiler/runtime path.
+3. Runs dynamic version commands (e.g. `python3 --version`) to verify baseline execution.
+4. Serves cached status: `200 OK` or `503 Service Unavailable`.
+
+#### GET /info (Server Diagnostic metadata)
+Exposes active configuration constraints and live telemetry:
+1. Returns limits: max source size, max tests, max concurrent jobs (15), and max queue size (500).
+2. Lists registered languages and their cached versions (populated from `ReadyHandler`).
+3. Stats tracking: returns `in_flight_jobs`, `queued_jobs`, `jobs_total`, `jobs_failed_internal`, `jobs_shed_total`, `last_internal_error_at`, and free space in the jail temp folder.
+
+---
+
+## 2. Directory & Package Structure
+
+```
+goboxd/
+├── cmd/
+│ └── goboxd/
+│ └── main.go # Service entry point; graceful shutdown signal loop & pool bootstrap
+├── internal/
+│ ├── handler/
+│ │ ├── handler.go # POST /run execution handler
+│ │ ├── ready.go # GET /readyz health check & version prober
+│ │ └── info.go # GET /info configurations, saturation metrics, & stats check
+│ ├── worker/
+│ │ └── pool.go # Priority queue (Min-Heap), RateTracker, and ExecutionTracker
+│ ├── validate/
+│ │ └── validate.go # Filename, size, flags, and load-adaptive resource limits clamping
+│ ├── languages/
+│ │ ├── config.go # Language struct definitions
+│ │ └── registry.go # Registry cache registry
+│ ├── executor/
+│ │ └── executor.go # Sandboxed compiler & execution run loop
+│ ├── sandbox/
+│ │ └── nsjail.go # nsjail parameter generator
+│ └── model/
+│ ├── request.go # JSON request structures
+│ └── response.go # JSON response structures
+└── configs/
+ └── languages/
+ └── languages.yaml # Declarative runtime specifications
+```
+
+---
+
+## 3. Key Design Decisions
+
+### Nested Container Namespace Hardening & UID Isolation
+Under concurrent load, running sandboxes under identical UIDs poses security risks. We resolved this by assigning a process-unique UID to each execution via an atomic counter and PID mapping.
+Furthermore, we hardened the Docker boundary by removing `privileged: true` from the container configuration. Because Docker mounts masked paths over `/proc` inside standard containers for security, nested mount namespaces fail to mount a new `procfs` without host-level privileges. We bypassed this by disabling `procfs` mounting inside the jail (`--disable_proc`), enabling `nsjail` to run with unprivileged user namespaces under the narrow `SYS_ADMIN` capability.
+
+### Headless Sandboxed VMs Memory Isolation
+Java JVM and Node.js require massive virtual address space reservation at startup on 64-bit systems. Enforcing tight address space limits (`--rlimit_as` under nsjail) causes VM initialization crashes.
+We resolve this by:
+1. Enforcing a minimum override floor of 1 GB memory limit for Java and JS runs.
+2. Restricting internal physical heap size via command line args (`-Xmx128m` and `--max-old-space-size=128`). This maps virtual memory to satisfy VM boot while keeping actual physical RAM usage under ~40-50MB.
+
+### Shortest Job First (SJF) Scheduling with Starvation Aging
+Under heavy load, processing long-running or high-memory jobs first creates queue head-of-line blocking. We utilize a Min-Heap sorted by expected cost. To prevent heavy requests from starving indefinitely, we subtract an aging factor of `2.0 * wait_time_seconds` from the priority score, ensuring all requests eventually get scheduled.
+
+### Load-Adaptive Limit Clamping
+To survive massive spikes, the validator monitors request rate over a sliding 10-second window. Above 5 req/sec, the maximum allowed overrides are progressively clamped down, forcing heavy requests to use smaller, safer runtime boundaries and protecting the host from OOM.
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
new file mode 100644
index 00000000..499e5f67
--- /dev/null
+++ b/docs/benchmarks.md
@@ -0,0 +1,38 @@
+# goboxd — Concurrency Benchmarks
+
+This document records the performance benchmarks of the **goboxd** service under concurrent loads.
+
+## Test Environment
+* **Host OS**: macOS (Darwin 25.4.0)
+* **Hardware**: Apple M2 MacBook Air (8 CPU cores, 8 GB RAM)
+* **Virtualization**: Docker Desktop (running Linux container environment, unprivileged mode)
+* **Target Payload**: Trivial Python 3 execution (`py3`, "Hello World" print)
+
+---
+
+## Baseline Benchmark Results (Python 3)
+
+The following table summarizes the requests/sec throughput and latency percentiles ($p_{50}$, $p_{95}$, $p_{99}$) for `POST /run` under different concurrent client configurations.
+
+| Concurrent Clients | Total Requests | Throughput (req/sec) | Average Latency | $p_{50}$ (Median) | $p_{95}$ | $p_{99}$ |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| **1** | 100 | 94.61 | 10.52ms | 8.42ms | 15.76ms | 144.36ms |
+| **10** | 100 | 237.81 | 40.90ms | 33.42ms | 106.24ms | 132.43ms |
+| **50** | 500 | 361.35 | 132.38ms | 122.28ms | 203.54ms | 263.01ms |
+| **100** | 1000 | 407.66 | 234.62ms | 231.64ms | 276.41ms | 345.31ms |
+
+---
+
+## Mixed Payload and Limit Clamping Verification
+
+To verify that the queue scheduler, priority routing, and resource limit clamping function correctly under realistic workloads, we ran a mixed payload benchmark:
+* **Configuration:** 10 concurrent clients sending 100 total requests.
+* **Workload:** Randomized mix of all 7 supported languages (Python, C, C++, Java, Bash, JavaScript, and Verilog) with randomized over-allocated and under-allocated resource limits.
+* **Outcome:** 100% of requests processed successfully (HTTP 200).
+* **Throughput:** **45.33 requests/sec**.
+* **Latency Profile:**
+ * **Average:** 203.95ms
+ * **p50 (Median):** 105.53ms
+ * **p95:** 792.55ms
+ * **p99:** 937.16ms
+* **Clamping Assertion:** **110 warnings** were returned in the response payloads, confirming that both under-allocation floors (e.g. JVM/Node 1 GB minimums) and load-adaptive upper caps were correctly computed and active.
diff --git a/docs/languages.md b/docs/languages.md
new file mode 100644
index 00000000..0bdce2bb
--- /dev/null
+++ b/docs/languages.md
@@ -0,0 +1,103 @@
+# Language Registry System (Stage 2)
+
+This document describes how **goboxd** dynamically parses, registers, and executes languages using a configuration-driven registry system.
+
+---
+
+## 1. Core Concepts
+
+The system is designed around a **zero Go modification** goal for adding new languages. All compile and execution behaviors are declared in a central YAML configuration file: `configs/languages/languages.yaml`.
+
+At server startup, the file is read, validated, and cached in a thread-safe registry module.
+
+---
+
+## 2. Configuration Schema
+
+Each language block inside the registry contains the following parameters:
+
+```yaml
+languages:
+ - id: java # Unique identifier (used in POST /run)
+ name: Java # Human-readable name
+ source_filename_strategy: from_request # Resolves source filename from client request
+ artifact_filename_strategy: from_request # Resolves artifact filename from client request
+ build: # Compilation configuration
+ cmd: /usr/bin/javac # Compiler binary path
+ args: # Arguments to compiler (with placeholders)
+ - "-J-XX:+UseSerialGC"
+ - "-J-XX:TieredStopAtLevel=1"
+ - "-J-XX:CompressedClassSpaceSize=32m"
+ - "-J-Xms128m"
+ - "-J-Xmx256m"
+ - "{{source}}"
+ limits: # Compilation resource limits
+ wall_time_s: 15
+ memory_kb: 1048576
+ max_processes: 100
+ run: # Execution configuration
+ cmd: /usr/bin/java # Runner binary path
+ args: # Arguments to runner
+ - "-XX:+UseSerialGC"
+ - "-XX:TieredStopAtLevel=1"
+ - "-XX:CompressedClassSpaceSize=32m"
+ - "-Xms64m"
+ - "-Xmx128m"
+ - "{{artifact}}"
+ limits: # Running execution limits
+ wall_time_s: 10
+ memory_kb: 1048576
+ max_processes: 100
+```
+
+### Placeholder Substitution Rules:
+The executor maps arguments dynamically by searching for the following tokens inside `args` or execution `cmd` blocks:
+* `{{source}}`: Expands to the absolute path of the written source code file (e.g. `/tmp/goboxd-123/solution.c`).
+* `{{artifact}}`: Resolves relative to the working directory (`Main` or `solution`) for argument commands (e.g. `java Main`), but expands to the absolute path for execution targets (e.g. `/tmp/goboxd-123/solution`).
+* `{{flags}}`: Expands to the slice of compiler flags provided by the client request (e.g., `["-O3", "-std=c++20"]`).
+
+---
+
+## 3. Supported Languages List
+
+### 1. Python 3 (`py3`)
+* **Type:** Interpreted
+* **Engine:** `/usr/bin/python3`
+* **Default Limits:** Wall time 9s · Memory 100 MiB · Processes 100 max
+
+### 2. C (`c`)
+* **Type:** Compiled
+* **Compiler:** `/usr/bin/gcc`
+* **Runner Target:** `./{{artifact}}`
+* **Flag Allow-list:** `["-O0", "-O1", "-O2", "-Wall", "-Wextra", "-std=c11", "-std=c99"]`
+* **Default Limits:** Compile: 15s/1GB · Run: 10s/125MB
+
+### 3. C++ (`cpp`)
+* **Type:** Compiled
+* **Compiler:** `/usr/bin/g++`
+* **Runner Target:** `./{{artifact}}`
+* **Flag Allow-list:** `["-O0", "-O1", "-O2", "-O3", "-Wall", "-Wextra", "-std=c++17", "-std=c++20"]`
+* **Default Limits:** Compile: 15s/1GB · Run: 10s/125MB
+
+### 4. Java (`java`)
+* **Type:** Compiled
+* **Compiler:** `/usr/bin/javac` (with Serial GC and JIT constraints)
+* **Runner Target:** `/usr/bin/java` (with Serial GC and JIT constraints)
+* **Dynamic Strategies:** Evaluates classname matching by reading `source_filename_strategy` and `artifact_filename_strategy` from request values.
+* **Default Limits:** Compile: 15s/1GB · Run: 10s/1GB
+
+### 5. Bash (`bash`)
+* **Type:** Interpreted (Script)
+* **Engine:** `/bin/bash`
+* **Default Limits:** Run: 5s/50MB · Processes 32 max
+
+### 6. JavaScript (`js`)
+* **Type:** Interpreted (V8 Engine)
+* **Engine:** `/usr/bin/node` (with `--max-old-space-size=128` heap constraints)
+* **Default Limits:** Run: 9s/1GB · Processes 64 max
+
+### 7. Verilog (`verilog`)
+* **Type:** Compiled
+* **Compiler:** `/usr/bin/iverilog` (compiles to `.vvp` simulator artifact)
+* **Runner Target:** `/usr/bin/vvp`
+* **Default Limits:** Compile: 10s/256MB · Run: 5s/125MB
diff --git a/docs/loadtest/MemoryHog.java b/docs/loadtest/MemoryHog.java
new file mode 100644
index 00000000..2bd78228
--- /dev/null
+++ b/docs/loadtest/MemoryHog.java
@@ -0,0 +1,43 @@
+public class MemoryHog {
+ public static void main(String[] args) throws InterruptedException {
+ final int megabytes = readSize();
+ final int blockSize = 1 << 20; // 1 MB per block
+ final byte[][] blocks = new byte[megabytes][];
+
+ long checksum = 0;
+
+ // Allocate and touch every page so the pages are actually committed to RSS.
+ for (int i = 0; i < megabytes; i++) {
+ byte[] block = new byte[blockSize];
+ for (int j = 0; j < blockSize; j += 4096) {
+ block[j] = (byte) (i * 31 + j);
+ checksum += block[j];
+ }
+ blocks[i] = block;
+ }
+
+ // Light CPU pass so a run is not pure allocation.
+ for (byte[] block : blocks) {
+ for (int j = 0; j < block.length; j += 512) {
+ checksum += block[j];
+ }
+ }
+
+ // Hold the memory resident for a moment so concurrent runs pile up.
+ Thread.sleep(1000);
+
+ // Deterministic output proves the run actually completed.
+ System.out.println("MemoryHog OK mb=" + megabytes + " checksum=" + checksum);
+ }
+
+ private static int readSize() {
+ String env = System.getenv("MEMHOG_MB");
+ if (env != null && !env.isEmpty()) {
+ try {
+ return Integer.parseInt(env.trim());
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return 150;
+ }
+}
diff --git a/docs/loadtest/breaking-point.png b/docs/loadtest/breaking-point.png
new file mode 100644
index 00000000..3fbaa0db
Binary files /dev/null and b/docs/loadtest/breaking-point.png differ
diff --git a/docs/loadtest/latency.png b/docs/loadtest/latency.png
new file mode 100644
index 00000000..9ddc843e
Binary files /dev/null and b/docs/loadtest/latency.png differ
diff --git a/docs/loadtest/load-test.sh b/docs/loadtest/load-test.sh
new file mode 100755
index 00000000..f945d90c
--- /dev/null
+++ b/docs/loadtest/load-test.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# docs/loadtest/load-test.sh
+#
+# Reproducible MemoryHog load test for goboxd.
+#
+# Prereqs:
+# - goboxd running in its container, capped at 2 vCPU / 2 GB
+# (docker compose up -d goboxd; see docker-compose.yml)
+# - Go toolchain (the generator runs on the host)
+# - a python with matplotlib for the graphs (point PYTHON at it)
+#
+# The generator auto-labels the run from the live CONCURRENCY_LIMIT (/info),
+# writes a timestamped CSV + its plots to docs/loadtest/runs/, and refreshes the
+# canonical docs/loadtest/results.csv + breaking-point.png + latency.png.
+set -euo pipefail
+
+cd "$(dirname "$0")/../.." # repo root
+
+URL="${URL:-http://localhost:8080/run}"
+INFO="${INFO:-http://localhost:8080/info}"
+RATES="${RATES:-1,2,3,5,10,25,50,75,100,150,200,300,400}"
+DURATION="${DURATION:-30s}"
+TIMEOUT="${TIMEOUT:-10s}"
+STOP_AFTER_FAIL="${STOP_AFTER_FAIL:-3}"
+DRAIN_MAX="${DRAIN_MAX:-60s}"
+PYTHON="${PYTHON:-/tmp/plotenv/bin/python}" # a python that has matplotlib
+
+echo "==> Driving load (open-loop, ${DURATION}/step, ${TIMEOUT} timeout) + plotting"
+go run ./scripts/memhog \
+ -url "$URL" -info "$INFO" \
+ -source docs/loadtest/MemoryHog.java \
+ -rates "$RATES" -duration "$DURATION" -timeout "$TIMEOUT" \
+ -stop-after-fail "$STOP_AFTER_FAIL" -drain-max "$DRAIN_MAX" \
+ -python "$PYTHON"
+
+echo "==> Done. See docs/loadtest/runs/ (this run) and docs/loadtest/results.csv (latest)"
diff --git a/docs/loadtest/plot.py b/docs/loadtest/plot.py
new file mode 100644
index 00000000..49ff2939
--- /dev/null
+++ b/docs/loadtest/plot.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""Plot breaking-point.png and latency.png from results.csv.
+
+Usage: python3 docs/loadtest/plot.py [results.csv] [--breaking-point RPS]
+"""
+import csv
+import sys
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+
+def load(path):
+ with open(path) as f:
+ return list(csv.DictReader(f))
+
+
+def find_breaking_point(rows):
+ """First offered RPS with a failure (error_pct > 0)."""
+ for r in rows:
+ if float(r["error_pct"]) > 0:
+ return float(r["target_rps"])
+ return None
+
+
+def main():
+ path = "docs/loadtest/results.csv"
+ bp = None
+ outdir = "docs/loadtest"
+ prefix = "" # "" -> breaking-point.png ; "foo" -> foo_breaking-point.png
+ title_suffix = "2 vCPU / 2 GB"
+ args = sys.argv[1:]
+ i = 0
+ while i < len(args):
+ if args[i] == "--breaking-point":
+ bp = float(args[i + 1]); i += 2
+ elif args[i] == "--outdir":
+ outdir = args[i + 1]; i += 2
+ elif args[i] == "--prefix":
+ prefix = args[i + 1]; i += 2
+ elif args[i] == "--title":
+ title_suffix = args[i + 1]; i += 2
+ else:
+ path = args[i]; i += 1
+
+ pre = (prefix + "_") if prefix else ""
+ bp_png = f"{outdir}/{pre}breaking-point.png"
+ lat_png = f"{outdir}/{pre}latency.png"
+ waste_png = f"{outdir}/{pre}wasted-work.png"
+
+ rows = load(path)
+ rps = [float(r["target_rps"]) for r in rows]
+ if bp is None:
+ bp = find_breaking_point(rows)
+
+ # ---- breaking-point.png: offered RPS vs error rate ----
+ plt.figure(figsize=(8, 5))
+ plt.plot(rps, [float(r["error_pct"]) for r in rows], marker="o", color="#c0392b")
+ if bp is not None:
+ plt.axvline(bp, color="#2c3e50", linestyle="--", linewidth=1.5,
+ label=f"breaking point = {bp:g} rps")
+ plt.legend()
+ plt.xlabel("Offered RPS")
+ plt.ylabel("Error rate (%)")
+ plt.title(f"Breaking point — MemoryHog @ {title_suffix}")
+ plt.grid(True, alpha=0.3)
+ plt.savefig(bp_png, dpi=150, bbox_inches="tight")
+
+ # ---- latency.png: offered RPS vs p50/p95/p99 ----
+ plt.figure(figsize=(8, 5))
+ for key, lbl, color in [("p50_ms", "p50", "#27ae60"),
+ ("p95_ms", "p95", "#f39c12"),
+ ("p99_ms", "p99", "#c0392b")]:
+ plt.plot(rps, [float(r[key]) for r in rows], marker="o", label=lbl, color=color)
+ if bp is not None:
+ plt.axvline(bp, color="#2c3e50", linestyle="--", linewidth=1.5,
+ label=f"breaking point = {bp:g} rps")
+ plt.xlabel("Offered RPS")
+ plt.ylabel("Latency (ms)")
+ plt.title(f"RPS vs latency — MemoryHog @ {title_suffix}")
+ plt.legend()
+ plt.grid(True, alpha=0.3)
+ plt.savefig(lat_png, dpi=150, bbox_inches="tight")
+
+ # ---- wasted-work.png: delivered (goodput) vs wasted server work ----
+ # Only plotted if the CSV carries the extra accounting columns.
+ if rows and "server_completed" in rows[0]:
+ plt.figure(figsize=(8, 5))
+ plt.plot(rps, [float(r["success"]) for r in rows], marker="o",
+ label="delivered (within SLA)", color="#27ae60")
+ plt.plot(rps, [float(r["wasted"]) for r in rows], marker="o",
+ label="wasted (finished after client gave up)", color="#c0392b")
+ plt.plot(rps, [float(r["server_completed"]) for r in rows], marker="o",
+ label="server total completed", color="#2c3e50", linestyle="--")
+ if bp is not None:
+ plt.axvline(bp, color="#7f8c8d", linestyle=":", linewidth=1.2)
+ plt.xlabel("Offered RPS")
+ plt.ylabel("Jobs per step")
+ plt.title(f"Delivered vs wasted server work — MemoryHog @ {title_suffix}")
+ plt.legend()
+ plt.grid(True, alpha=0.3)
+ plt.savefig(waste_png, dpi=150, bbox_inches="tight")
+ print(f"wrote {waste_png}")
+
+ print(f"breaking point: {bp} rps")
+ print(f"wrote {bp_png} and {lat_png}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/loadtest/results.csv b/docs/loadtest/results.csv
new file mode 100644
index 00000000..10266570
--- /dev/null
+++ b/docs/loadtest/results.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+1,0.96,30.3,29,29,0,0.00,1273.6,1319.1,1339.2,1339.2,29,0,0.00,0,0
+2,1.92,30.8,59,59,0,0.00,1271.9,1299.4,1305.6,1305.6,59,0,0.00,0,0
+3,2.88,30.9,89,89,0,0.00,1249.4,1293.5,1319.8,1319.8,89,0,0.00,0,0
+5,0.75,39.8,149,30,119,79.87,10000.4,10001.5,10002.2,10004.3,84,54,64.29,119,0
+10,0.15,39.9,299,6,293,97.99,10000.5,10001.5,10001.8,10002.4,68,62,91.18,293,0
+25,0.13,40.0,749,5,744,99.33,10000.4,10001.4,10002.5,10015.6,65,60,92.31,744,0
+50,0.05,40.0,1499,2,1497,99.87,10000.5,10002.2,10025.2,10062.9,60,58,96.67,1491,6
diff --git a/docs/loadtest/runs/README.md b/docs/loadtest/runs/README.md
new file mode 100644
index 00000000..39ef3894
--- /dev/null
+++ b/docs/loadtest/runs/README.md
@@ -0,0 +1,148 @@
+# Load-test run archive
+
+One CSV + its plots per benchmark run, named by workload + concurrency + a
+timestamp so sequential runs never collide. The canonical "latest" copy lives at
+`../results.csv` with `../breaking-point.png` / `../latency.png`; this folder is
+the history for comparing runs.
+
+## TL;DR — run the load test
+
+With goboxd already up (see below to (re)start it), from the **repo root**:
+
+```bash
+# everything in one shot: test -> timestamped CSV + plots in runs/ + latest copies
+go run ./scripts/memhog \
+ -source docs/loadtest/MemoryHog.java \
+ -python /tmp/plotenv/bin/python
+```
+
+Or the wrapper, which sets the same defaults and is env-overridable
+(`RATES=… DURATION=… PYTHON=…`):
+
+```bash
+docs/loadtest/load-test.sh
+```
+
+`-python` must point at a Python that has matplotlib (one-time setup:
+`python3.12 -m venv /tmp/plotenv && /tmp/plotenv/bin/pip install -q matplotlib`).
+Add `-plot=false` to skip plots. The run is labelled from the server's live
+`CONCURRENCY_LIMIT`, so no flags are needed to tag it.
+
+## How to run a new instance (varying `CONCURRENCY_LIMIT`)
+
+`CONCURRENCY_LIMIT` is an **environment variable** in `docker-compose.yml`, so a
+change only needs a container **recreate** — no image rebuild. (The java
+`-Xmx384m` heap config is baked into the image; only rebuild if you edit
+`configs/languages/languages.yaml`.)
+
+```bash
+# 1. Set the limit in docker-compose.yml (service: goboxd)
+# environment:
+# - CONCURRENCY_LIMIT=100
+
+# 2. Recreate the container so the new env takes effect
+docker compose up -d --force-recreate goboxd
+
+# 3. Wait until ready, and confirm the limit that actually loaded
+until curl -sf http://localhost:8080/readyz >/dev/null; do sleep 1; done
+docker logs goboxd 2>&1 | grep "concurrency pool" | tail -1
+# -> ...,"concurrency_limit":100,...
+
+# 4. (one-time) a Python venv with matplotlib for the plots
+python3.12 -m venv /tmp/plotenv && /tmp/plotenv/bin/pip install -q matplotlib
+
+# 5. Run the benchmark — auto-labels the run c100 from /info, writes a
+# timestamped CSV + per-run plots here, and refreshes ../results.csv
+go run ./scripts/memhog \
+ -source docs/loadtest/MemoryHog.java \
+ -python /tmp/plotenv/bin/python
+```
+
+Produces (example): `memhog_c100_2vcpu-2gb_20060102-150405.csv` plus
+`..._breaking-point.png`, `..._latency.png`, `..._wasted-work.png`.
+
+### Useful overrides
+
+| Flag | Default | Purpose |
+|---|---|---|
+| `-rates` | `1,2,3,5,10,25,50,75,100,150,200,300,400` | the RPS ladder |
+| `-duration` | `30s` | hold time per step |
+| `-timeout` | `10s` | per-request SLA (slower = failed) |
+| `-stop-after-fail` | `3` | steps to run past the first failure |
+| `-tag` | `2vcpu-2gb` | label suffix (change if you resize the box) |
+| `-label` | auto `c` from /info | override the run label entirely |
+| `-plot=false` | (plots on) | skip plotting |
+
+If you only changed concurrency, **do not** `make build`. If you changed
+`languages.yaml`, run `make build` before step 2.
+
+## CSV schema
+
+Columns 1–11 are the challenge schema; 12–16 are extra work-accounting signals:
+
+```
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+```
+
+- `server_completed` — jobs the server actually finished this step (`/info` `jobs_total` delta).
+- `wasted` — finished **after** the client had already timed out (abandoned work; the running phase isn't cancelled on client disconnect).
+- `wasted_pct` — `wasted / server_completed`.
+- `timeouts`, `shed_503` — failure-mode breakdown.
+
+## Runs
+
+| File | Concurrency | Limits | Breaking point | Notes |
+|---|---|---|---|---|
+| `memhog_2vcpu-2gb_xmx384_30s_2026-06-12.csv` | 2 | 2 vCPU / 2 GB | 3 rps | first run (pre-rename; no plots / extra cols) |
+| `memhog_c8_2vcpu-2gb_20260612-172224.csv` | **8** | 2 vCPU / 2 GB | **5 rps** | **best config tested** — see below |
+| `memhog_c16_2vcpu-2gb_20260612-173319.csv` | 16 | 2 vCPU / 2 GB | 5 rps | same breaking point as c8 but worse past it (more thrash) |
+| `memhog_c50_2vcpu-2gb_.csv` | 50 | 2 vCPU / 2 GB | 5 rps | instant saturation; heavy congestion collapse |
+
+## Best result — `CONCURRENCY_LIMIT=8`
+
+`memhog_c8_2vcpu-2gb_20260612-172224.csv` is the best configuration tested
+(plots: `..._breaking-point.png`, `..._latency.png`, `..._wasted-work.png`).
+
+| offered rps | throughput/s | error % | p50 ms | server_completed | wasted | wasted % |
+|---:|---:|---:|---:|---:|---:|---:|
+| 1 | 0.96 | 0.0 | 1291 | 29 | 0 | 0 |
+| 2 | 1.92 | 0.0 | 1275 | 59 | 0 | 0 |
+| 3 | 2.88 | 0.0 | 1255 | 89 | 0 | 0 |
+| **5** | 1.66 | **55.7** | 10000 | 119 | 53 | 44.5 |
+| 10 | 0.73 | 90.3 | 10000 | 110 | 81 | 73.6 |
+| 25 | 0.60 | 96.8 | 10000 | 112 | 88 | 78.6 |
+| 50 | 0.55 | 98.5 | 10000 | 112 | 90 | 80.4 |
+
+**Breaking point = 5 rps** (first offered rate with a failed request).
+
+### Why this is the best config
+
+The bottleneck for the MemoryHog workload is **CPU/wall-time, not memory**: each
+request costs ~1.3 s (JVM compile + run + a fixed 1 s `sleep`), and the box has
+only **2 vCPU**. Capacity ≈ `2 CPUs ÷ CPU-seconds-per-request`. Because the 1 s
+sleep is *idle* (not CPU), a handful of concurrent jobs can overlap their sleeps
+and lift throughput — but only up to a point.
+
+- **c8 hits that sweet spot.** Enough slots to overlap the sleeps (clean through
+ 3 rps at 2.88/s), without over-subscribing 2 cores.
+- **More concurrency is worse, not better.** c16 and c50 break at the *same*
+ 5 rps but complete *less* real work past the knee (c8 finishes **119** jobs at
+ 5 rps vs c16's 84) — 16+ JVMs fighting over 2 cores thrash on CPU and memory.
+ This is **congestion collapse**: adding load/slots reduces goodput.
+- **Concurrency is not the lever.** c8, c16, c50 all break at 5 rps → the limit
+ is the 2 vCPU budget, not the slot count. Raising `CONCURRENCY_LIMIT` past ~8
+ only degrades behaviour.
+
+### How it fails (and why that's acceptable)
+
+Every failure is a **client-side 10 s timeout**, not a crash: `shed_503 ≈ 0`,
+`OOMKilled=false`. The server keeps accepting and queuing requests; past capacity
+the queue wait exceeds the 10 s SLA and clients give up. The `wasted` column
+shows the cost of that — 44–80% of the work the server *completes* past the knee
+is delivered after the client already left (the run phase isn't cancelled on
+disconnect). Below the breaking point, 0% waste and 0% errors.
+
+**Takeaway:** at a fixed 2 vCPU / 2 GB this service sustains ~3–5 rps of a heavy
+memory workload, peaking at `CONCURRENCY_LIMIT=8`. To go higher you'd add CPU,
+cut per-request cost (warm JVM / AppCDS), or stop wasting CPU on abandoned work
+(deadline-aware admission, cancel-on-disconnect) — not raise concurrency.
diff --git a/docs/loadtest/runs/breaking-point.png b/docs/loadtest/runs/breaking-point.png
new file mode 100644
index 00000000..9b68ad27
Binary files /dev/null and b/docs/loadtest/runs/breaking-point.png differ
diff --git a/docs/loadtest/runs/latency.png b/docs/loadtest/runs/latency.png
new file mode 100644
index 00000000..d64c1748
Binary files /dev/null and b/docs/loadtest/runs/latency.png differ
diff --git a/docs/loadtest/runs/memhog_2vcpu-2gb_xmx384_30s_2026-06-12.csv b/docs/loadtest/runs/memhog_2vcpu-2gb_xmx384_30s_2026-06-12.csv
new file mode 100644
index 00000000..bf7c54b9
--- /dev/null
+++ b/docs/loadtest/runs/memhog_2vcpu-2gb_xmx384_30s_2026-06-12.csv
@@ -0,0 +1,7 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms
+1,0.96,30.3,29,29,0,0.00,1265.7,1337.0,1385.8,1385.8
+2,1.53,38.6,59,59,0,0.00,4998.8,8764.5,9051.0,9051.0
+3,0.71,39.7,89,28,61,68.54,10000.3,10001.5,10001.7,10001.7
+5,0.50,39.8,149,20,129,86.58,10000.4,10001.4,10001.5,10001.5
+10,0.40,39.9,299,16,283,94.65,10000.4,10001.5,10003.3,10007.3
+25,0.35,40.0,749,14,735,98.13,10000.3,10001.3,10002.0,10007.2
diff --git a/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035.csv b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035.csv
new file mode 100644
index 00000000..9f4c585b
--- /dev/null
+++ b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+1,0.96,30.3,29,29,0,0.00,1274.8,1354.6,1391.7,1391.7,29,0,0.00,0,0
+2,1.92,30.8,59,59,0,0.00,1274.8,1305.4,1312.7,1312.7,59,0,0.00,0,0
+3,2.88,30.9,89,89,0,0.00,1248.8,1282.1,1334.5,1334.5,89,0,0.00,0,0
+5,0.58,39.8,149,23,126,84.56,10000.4,10001.6,10001.7,10002.3,149,126,84.56,126,0
+10,0.05,39.9,299,2,297,99.33,10000.4,10001.5,10001.6,10002.7,271,269,99.26,297,0
+25,0.00,40.0,749,0,749,100.00,10000.4,10001.4,10001.8,10031.8,300,300,100.00,749,0
+50,0.00,40.0,1499,0,1499,100.00,10000.4,10001.4,10001.6,10009.0,300,300,100.00,1494,5
diff --git a/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_breaking-point.png b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_breaking-point.png
new file mode 100644
index 00000000..9b68ad27
Binary files /dev/null and b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_breaking-point.png differ
diff --git a/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_latency.png b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_latency.png
new file mode 100644
index 00000000..d64c1748
Binary files /dev/null and b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_latency.png differ
diff --git a/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_wasted-work.png b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_wasted-work.png
new file mode 100644
index 00000000..1bfb5973
Binary files /dev/null and b/docs/loadtest/runs/memhog_c100_2vcpu-2gb_20260612-165035_wasted-work.png differ
diff --git a/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319.csv b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319.csv
new file mode 100644
index 00000000..10266570
--- /dev/null
+++ b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+1,0.96,30.3,29,29,0,0.00,1273.6,1319.1,1339.2,1339.2,29,0,0.00,0,0
+2,1.92,30.8,59,59,0,0.00,1271.9,1299.4,1305.6,1305.6,59,0,0.00,0,0
+3,2.88,30.9,89,89,0,0.00,1249.4,1293.5,1319.8,1319.8,89,0,0.00,0,0
+5,0.75,39.8,149,30,119,79.87,10000.4,10001.5,10002.2,10004.3,84,54,64.29,119,0
+10,0.15,39.9,299,6,293,97.99,10000.5,10001.5,10001.8,10002.4,68,62,91.18,293,0
+25,0.13,40.0,749,5,744,99.33,10000.4,10001.4,10002.5,10015.6,65,60,92.31,744,0
+50,0.05,40.0,1499,2,1497,99.87,10000.5,10002.2,10025.2,10062.9,60,58,96.67,1491,6
diff --git a/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_breaking-point.png b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_breaking-point.png
new file mode 100644
index 00000000..3fbaa0db
Binary files /dev/null and b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_breaking-point.png differ
diff --git a/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_latency.png b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_latency.png
new file mode 100644
index 00000000..9ddc843e
Binary files /dev/null and b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_latency.png differ
diff --git a/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_wasted-work.png b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_wasted-work.png
new file mode 100644
index 00000000..5e715760
Binary files /dev/null and b/docs/loadtest/runs/memhog_c16_2vcpu-2gb_20260612-173319_wasted-work.png differ
diff --git a/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807.csv b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807.csv
new file mode 100644
index 00000000..8d53f0be
--- /dev/null
+++ b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+1,0.96,30.3,29,29,0,0.00,1298.9,1398.1,1401.3,1401.3,29,0,0.00,0,0
+2,1.92,30.8,59,59,0,0.00,1282.0,1312.0,1334.5,1334.5,59,0,0.00,0,0
+3,2.87,31.0,89,89,0,0.00,1285.1,1325.3,1360.8,1360.8,89,0,0.00,0,0
+5,0.25,39.8,149,10,139,93.29,10000.5,10001.4,10001.6,10001.8,143,133,93.01,139,0
+10,0.05,39.9,299,2,297,99.33,10000.4,10001.4,10002.1,10002.4,152,150,98.68,297,0
+25,0.00,40.0,749,0,749,100.00,10000.4,10001.4,10001.6,10014.5,150,150,100.00,749,0
+50,0.00,40.0,1499,0,1499,100.00,10000.3,10001.4,10001.5,10008.5,149,149,100.00,1494,5
diff --git a/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_breaking-point.png b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_breaking-point.png
new file mode 100644
index 00000000..05f4ed22
Binary files /dev/null and b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_breaking-point.png differ
diff --git a/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_latency.png b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_latency.png
new file mode 100644
index 00000000..89c39fe7
Binary files /dev/null and b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_latency.png differ
diff --git a/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_wasted-work.png b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_wasted-work.png
new file mode 100644
index 00000000..610639da
Binary files /dev/null and b/docs/loadtest/runs/memhog_c50_2vcpu-2gb_20260612-165807_wasted-work.png differ
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827.csv b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827.csv
new file mode 100644
index 00000000..7d9b92aa
--- /dev/null
+++ b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms
+1,0.96,30.3,29,29,0,0.00,1272.4,1345.5,1417.8,1417.8
+2,1.92,30.8,59,59,0,0.00,1272.0,1296.4,1332.1,1332.1
+3,2.88,30.9,89,89,0,0.00,1252.5,1277.4,1288.6,1288.6
+5,1.46,39.8,149,58,91,61.07,10000.3,10001.5,10001.6,10002.1
+10,0.70,39.9,299,28,271,90.64,10000.4,10001.3,10001.6,10001.9
+25,0.55,40.0,749,22,727,97.06,10000.4,10001.4,10001.8,10006.1
+50,0.50,40.0,1499,20,1479,98.67,10000.4,10001.4,10001.5,10006.9
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_breaking-point.png b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_breaking-point.png
new file mode 100644
index 00000000..919eddf5
Binary files /dev/null and b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_breaking-point.png differ
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_latency.png b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_latency.png
new file mode 100644
index 00000000..300086d9
Binary files /dev/null and b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-161827_latency.png differ
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224.csv b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224.csv
new file mode 100644
index 00000000..521f4b5d
--- /dev/null
+++ b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224.csv
@@ -0,0 +1,8 @@
+target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503
+1,0.96,30.3,29,29,0,0.00,1291.3,1364.4,1408.8,1408.8,29,0,0.00,0,0
+2,1.92,30.8,59,59,0,0.00,1275.4,1313.4,1337.3,1337.3,59,0,0.00,0,0
+3,2.88,30.9,89,89,0,0.00,1254.5,1281.7,1307.5,1307.5,89,0,0.00,0,0
+5,1.66,39.8,149,66,83,55.70,10000.3,10001.5,10001.7,10002.8,119,53,44.54,83,0
+10,0.73,39.9,299,29,270,90.30,10000.4,10001.4,10001.7,10002.7,110,81,73.64,270,0
+25,0.60,40.0,749,24,725,96.80,10000.4,10001.4,10001.7,10005.8,112,88,78.57,725,0
+50,0.55,40.0,1499,22,1477,98.53,10000.3,10001.4,10001.5,10007.3,112,90,80.36,1475,2
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_breaking-point.png b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_breaking-point.png
new file mode 100644
index 00000000..c66f8619
Binary files /dev/null and b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_breaking-point.png differ
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_latency.png b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_latency.png
new file mode 100644
index 00000000..5079d59e
Binary files /dev/null and b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_latency.png differ
diff --git a/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_wasted-work.png b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_wasted-work.png
new file mode 100644
index 00000000..cd0674ba
Binary files /dev/null and b/docs/loadtest/runs/memhog_c8_2vcpu-2gb_20260612-172224_wasted-work.png differ
diff --git a/docs/loadtest/wasted-work.png b/docs/loadtest/wasted-work.png
new file mode 100644
index 00000000..5e715760
Binary files /dev/null and b/docs/loadtest/wasted-work.png differ
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 00000000..e65db559
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,96 @@
+# Security Architecture & Sandbox Boundaries (Stage 2)
+
+This document describes the security model, isolation mechanisms, and defense-in-depth layout of the **goboxd** sandbox service.
+
+---
+
+## 1. Dual-Layer Security Model
+
+The service implements a defense-in-depth model, wrapping executing user code inside two distinct sandbox layers:
+
+```
++--------------------------------------------------------+
+| Host Machine |
+| +--------------------------------------------------+ |
+| | Layer 1: Docker Container (Service Runtime) | |
+| | +--------------------------------------------+ | |
+| | | Layer 2: nsjail Sandbox (Execution Jail) | | |
+| | | +------------------------------------+ | | |
+| | | | Untrusted User Code | | | |
+| | | +------------------------------------+ | | |
+| | +--------------------------------------------+ | |
+| +--------------------------------------------------+ |
++--------------------------------------------------------+
+```
+
+### Layer 1: Docker Container Boundaries
+* The `goboxd` Go server and the compiler/interpreter toolchains execute inside a Debian container.
+* The container isolates the compilation tools and dependencies from the host machine.
+* **Minimal Privileges (Host Security Hardening)**: The container is configured with the specific `SYS_ADMIN` capability (`cap_add: [SYS_ADMIN]`) along with unconfined Seccomp/AppArmor security options, instead of the broad and insecure `privileged: true`.
+* **Procfs Overmount Bypass (`--disable_proc`)**: Inside non-privileged containers, Docker mounts "masked" paths over `/proc` (e.g., `/proc/kcore` mapped to `/dev/null`) to block host kernel leaks. Under Linux namespace rules, creating a nested namespace and mounting a new `procfs` over a masked procfs is blocked with `Operation not permitted`. We bypassed this restriction cleanly by passing the `--disable_proc` flag to `nsjail`, which completely disables procfs mounting inside the jail. Compilers and interpreters execute successfully without `/proc`.
+
+### Layer 2: nsjail Sandboxing
+`nsjail` is a lightweight, secure sandboxing tool utilizing Linux kernel features (namespaces, cgroups, seccomp filters) to run processes under strict resource constraints.
+
+---
+
+## 2. nsjail Isolation Mechanisms
+
+### Namespace Isolation
+Every code execution runs in its own clean set of Linux namespaces:
+* **PID Namespace (`CLONE_NEWPID`)**: The sandboxed program cannot see any other processes running on the system or container. It runs as PID 1 inside its namespace.
+* **Mount Namespace (`CLONE_NEWNS`)**: The jail operates on an independent file system mount hierarchy.
+* **Network Namespace (`CLONE_NEWNET`)**: Disables network access inside the sandbox, preventing user code from making outbound connections or running reverse shells.
+* **IPC Namespace (`CLONE_NEWIPC`)**: Prevents communication via system-level IPC pipelines (shared memory, message queues).
+* **UTS Namespace (`CLONE_NEWUTS`)**: Isolates hostnames.
+* **User Namespace (`CLONE_NEWUSER`) & UID Isolation**: Maps the internal sandbox user (acting as `root` inside the jail) to a process-unique, unprivileged UID on the host system, neutralizing privilege escalation vectors.
+ To prevent UID collisions under concurrent load, `goboxd` allocates a unique UID for each request using a thread-safe atomic counter and process ID:
+ $$\text{UID} = 100000 + (\text{PID} \bmod 100000) \times 10000 + (\text{counter} \bmod 10000)$$
+ This guarantees that concurrent requests run under strictly distinct UIDs, preventing them from interacting with each other's processes or accessing sibling temp directories (which are set to `0777` permissions to allow access to the unprivileged UID).
+
+### Filesystem Isolation
+* **Read-Only Root (`--chroot /`)**: The host root filesystem is mounted as read-only. User code cannot modify compiler libraries, interpreters, or configuration files.
+* **Writable Workspaces (`--tmpfsmount /tmp`)**: A virtual `tmpfs` is mounted at `/tmp`. Code writing, compilation, and execution occur strictly inside memory-backed, ephemeral space that is discarded instantly when the sandbox exits.
+
+### Resource Limits (Cgroups & Rlimits)
+`nsjail` sets strict boundaries on CPU, memory, and process creation to prevent Denial of Service (DoS) attacks:
+* **Time limits (`--time_limit`)**: Enforces execution timeouts (wall-time). If execution hangs or runs into infinite loops, `nsjail` kills the process.
+* **Process limits (`--max_pids` / `--rl_nproc`)**: Limits the number of concurrent processes or threads the sandbox can spawn, preventing fork bomb attacks.
+* **Memory Limits (`--rlimit_as` / `--max_memory_m`)**: Limits address space allocation. Out-of-memory executions are instantly terminated.
+
+---
+
+## 3. Virtual Machine Memory Defense
+
+Modern execution runtimes (like the Java JVM and Node.js V8 engine) attempt to reserve large virtual address spaces (often 1 GB or more) during startup for heap, GC card tables, and compressed class spaces. Under strict sandbox address limits, this results in immediate startup crashes (`OutOfMemoryError` or VM allocation failures).
+
+`goboxd` implements a **dual defense** for VM runtimes:
+1. **Virtual Space Overhead Allowances:** Raised memory allocation caps for JVM and Node.js sandboxes to 1 GB.
+2. **Runtime Memory Optimization Flags:**
+ * **Java compiler/runner:** Configured to use the Serial Garbage Collector (`-XX:+UseSerialGC`) and level 1 tiered JIT compilation (`-XX:TieredStopAtLevel=1`), which minimizes thread stack and compilation table metadata overhead. We also restrict the compressed class space size (`-XX:CompressedClassSpaceSize=32m`) to reduce the default 1 GB address space reservation to 32 MB.
+ * **Node.js runner:** Configured to limit the V8 old space heap memory allocation (`--max-old-space-size=128`), keeping V8 from over-reserving memory blocks.
+
+This ensures that the runtimes boot successfully and execute with minimal physical memory (RSS) footprints, protecting the server against host memory exhaustion.
+
+---
+
+## 4. Host-Level Defenses
+
+### Path Traversal Mitigation
+The HTTP validator package (`internal/validate`) blocks request filenames containing path separators (`/`, `\`) or dot prefixes (`.`, `..`). This ensures user source files are written strictly inside the unique workspace directory created by `os.MkdirTemp`, making directory traversal out of the workspace boundary impossible.
+
+### Strict Request Size Limits
+To prevent denial of service (DoS) and memory exhaustion attacks at the HTTP parsing layer, we enforce strict boundaries:
+* **HTTP Body Capping**: The total HTTP request body read is capped at **4 MiB** (`MaxRequestBodyBytes`) in the handler.
+* **Component-Level Limits**:
+ * **Source Code size**: Capped at **256 KiB** (`MaxSourceBytes`).
+ * **Test Count**: Capped at **50** max cases (`MaxTests`).
+ * **Per-Test Inputs**: Each test case's `Stdin` and `ExpectedStdout` are validated to not exceed **64 KiB** (`MaxStdinBytes`).
+
+### Output Truncation
+To prevent runaway programs inside the sandbox from flooding stdout/stderr and OOM-ing the Go host process during response capture, the executor limits the read buffer size. Streams are read via an `io.LimitReader` capped at **64 KiB** (`maxOutputBytes`). Any output exceeding this cap is discarded, and a truncation marker is appended.
+
+### Stale Jail Directory Cleanup
+To prevent disk exhaustion from orphaned sandbox directories:
+* **Exit-Path Cleanup**: Every exit path in execution runs within a `defer os.RemoveAll(workDir)` scope to clean up workspaces immediately.
+* **Startup Orphan Sweep**: Upon server boot, a background garbage collection sweep (`executor.SweepOrphans`) scans `/tmp` and removes any stale `goboxd-*` directories older than **5 minutes**.
diff --git a/go.mod b/go.mod
index b976ec54..3d3a0bab 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module github.com/thesouldev/goboxd
go 1.23
+
+require gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 00000000..4bc03378
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,3 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/.gitkeep b/internal/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
new file mode 100644
index 00000000..20383891
--- /dev/null
+++ b/internal/executor/executor.go
@@ -0,0 +1,316 @@
+// internal/executor/executor.go
+// Christiano Fernandes
+// 31 May 26
+package executor
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/model"
+ "github.com/thesouldev/goboxd/internal/sandbox"
+)
+
+const (
+ maxOutputBytes = 64 * 1024 // 64 KiB cap on stdout/stderr per run
+ truncMarker = "\n[TRUNCATED]"
+)
+
+var uidCounter uint32
+
+// nextUID returns a process-unique UID in the range [100000, 1001099999]
+// to prevent namespace collisions under concurrent load.
+func nextUID() int {
+ c := atomic.AddUint32(&uidCounter, 1)
+ pid := os.Getpid()
+ return 100000 + (pid%100000)*10000 + int(c%10000)
+}
+
+// Run executes a RunRequest end-to-end and returns a RunResponse.
+func Run(req *model.RunRequest, lang languages.Language) (*model.RunResponse, error) {
+ // Create a unique temp directory for this request — never reuse
+ workDir, err := os.MkdirTemp("", "goboxd-*")
+ if err != nil {
+ return nil, fmt.Errorf("create workdir: %w", err)
+ }
+ // Always clean up, even on panic
+ defer os.RemoveAll(workDir)
+
+ // Ensure the sandbox user (running under a mapped unprivileged UID) has full read/write access
+ if err := os.Chmod(workDir, 0777); err != nil {
+ return nil, fmt.Errorf("chmod workdir: %w", err)
+ }
+
+ reqUID := nextUID()
+
+ // Determine source filename
+ srcFilename := lang.SourceFilename
+ if req.SourceFilename != "" {
+ srcFilename = req.SourceFilename
+ }
+ srcPath := filepath.Join(workDir, srcFilename)
+
+ // Write source to temp dir
+ if err := os.WriteFile(srcPath, []byte(req.Source), 0644); err != nil {
+ return nil, fmt.Errorf("write source: %w", err)
+ }
+
+ resp := &model.RunResponse{}
+
+ // --- Build phase (compiled languages only) ---
+ if lang.Build != nil {
+ artifactName := lang.Artifact
+ if lang.ArtifactFilenameStrategy == "from_request" || artifactName == "" {
+ artifactName = req.ArtifactFilename
+ }
+ artifactPath := ""
+ if artifactName != "" {
+ artifactPath = filepath.Join(workDir, artifactName)
+ }
+
+ buildFlags := []string{}
+ if req.Build != nil && len(req.Build.Flags) > 0 {
+ buildFlags = req.Build.Flags
+ }
+
+ buildCmd := resolvePlaceholders(lang.Build.Args, srcPath, artifactPath, buildFlags)
+
+ limits := lang.Build.Limits
+ if req.Build != nil && req.Build.Limits != nil {
+ limits = mergeLimits(limits, *req.Build.Limits)
+ }
+
+ start := time.Now()
+ stdout, stderr, exitErr := runInSandbox(sandbox.Config{
+ WorkDir: workDir,
+ WallTimeSecs: limits.WallTimeS,
+ MemoryKB: limits.MemoryKB,
+ MaxPIDs: limits.MaxProcesses,
+ Command: append([]string{lang.Build.Cmd}, buildCmd...),
+ UID: reqUID,
+ }, "")
+ durationMs := time.Since(start).Milliseconds()
+
+ buildStatus := "ok"
+ if exitErr != nil {
+ buildStatus = "failed"
+ }
+
+ resp.Build = &model.BuildResult{
+ Status: buildStatus,
+ Stdout: stdout,
+ Stderr: stderr,
+ DurationMs: durationMs,
+ }
+
+ // If build failed, mark all tests as not_executed and return
+ if buildStatus == "failed" {
+ resp.Status = "build_failed"
+ resp.Tests = make([]model.TestResult, len(req.Tests))
+ for i := range resp.Tests {
+ resp.Tests[i] = model.TestResult{Status: "not_executed"}
+ }
+ return resp, nil
+ }
+ }
+
+ // --- Run phase — one execution per test case ---
+ runLimits := lang.Run.Limits
+ if req.Run != nil && req.Run.Limits != nil {
+ runLimits = mergeLimits(runLimits, *req.Run.Limits)
+ }
+
+ artifactName := lang.Artifact
+ if lang.ArtifactFilenameStrategy == "from_request" || artifactName == "" {
+ artifactName = req.ArtifactFilename
+ }
+ artifactPath := ""
+ if artifactName != "" {
+ artifactPath = filepath.Join(workDir, artifactName)
+ }
+
+ runFlags := []string{}
+ if req.Run != nil && len(req.Run.Flags) > 0 {
+ runFlags = req.Run.Flags
+ }
+
+ // For execution arguments, we pass the relative artifactName (e.g. for Java classname: java Main)
+ runCmd := resolvePlaceholders(lang.Run.Args, srcPath, artifactName, runFlags)
+ fullCmd := append([]string{lang.Run.Cmd}, runCmd...)
+ // Resolve {{artifact}} in the command path itself (e.g. "./{{artifact}}" -> "/tmp/goboxd-xxx/solution")
+ if artifactPath != "" {
+ fullCmd[0] = strings.ReplaceAll(fullCmd[0], "{{artifact}}", artifactPath)
+ }
+ if strings.HasPrefix(fullCmd[0], "./") {
+ cleaned := strings.TrimPrefix(fullCmd[0], "./")
+ if filepath.IsAbs(cleaned) {
+ fullCmd[0] = cleaned
+ }
+ }
+
+ resp.Tests = make([]model.TestResult, len(req.Tests))
+ firstNonAccepted := ""
+
+ for i, tc := range req.Tests {
+ start := time.Now()
+ stdout, stderr, exitErr := runInSandbox(sandbox.Config{
+ WorkDir: workDir,
+ WallTimeSecs: runLimits.WallTimeS,
+ MemoryKB: runLimits.MemoryKB,
+ MaxPIDs: runLimits.MaxProcesses,
+ Command: fullCmd,
+ UID: reqUID,
+ }, tc.Stdin)
+ durationMs := time.Since(start).Milliseconds()
+
+ status := classifyResult(stdout, tc.ExpectedStdout, exitErr, runLimits.WallTimeS, durationMs)
+
+ resp.Tests[i] = model.TestResult{
+ Status: status,
+ Stdout: stdout,
+ Stderr: stderr,
+ DurationMs: durationMs,
+ MemoryPeakKB: 0,
+ }
+
+ if firstNonAccepted == "" && status != "accepted" {
+ firstNonAccepted = status
+ }
+ }
+
+ // Top-level status
+ if firstNonAccepted == "" {
+ resp.Status = "accepted"
+ } else {
+ resp.Status = firstNonAccepted
+ }
+
+ return resp, nil
+}
+
+// runInSandbox executes cmd inside nsjail, feeding stdin, and returns capped stdout/stderr.
+func runInSandbox(cfg sandbox.Config, stdin string) (stdout, stderr string, err error) {
+ cmd, err := sandbox.Build(cfg)
+ if err != nil {
+ return "", "", err
+ }
+
+ cmd.Stdin = strings.NewReader(stdin)
+
+ var outBuf, errBuf bytes.Buffer
+ cmd.Stdout = &cappedWriter{w: &outBuf, limit: maxOutputBytes}
+ cmd.Stderr = &cappedWriter{w: &errBuf, limit: maxOutputBytes}
+
+ // Use a context so we can detect timeout independently
+ ctx, cancel := context.WithTimeout(context.Background(),
+ time.Duration(cfg.WallTimeSecs+2)*time.Second)
+ defer cancel()
+ _ = ctx // nsjail enforces its own time limit; context is a safety net
+
+ runErr := cmd.Run()
+ return outBuf.String(), errBuf.String(), runErr
+}
+
+// classifyResult maps execution outcome to the brief's status vocabulary.
+func classifyResult(actual, expected string, exitErr error, wallTimeSecs int, durationMs int64) string {
+ if exitErr != nil {
+ // Check if it was a timeout (nsjail exits 1 on timeout but duration tells us)
+ if durationMs >= int64(wallTimeSecs)*1000 {
+ return "time_exceeded"
+ }
+ return "runtime_error"
+ }
+ if actual == expected {
+ return "accepted"
+ }
+ if strings.TrimSpace(actual) == strings.TrimSpace(expected) {
+ return "output_whitespace_mismatch"
+ }
+ return "wrong_output"
+}
+
+// resolvePlaceholders replaces {{source}}, {{artifact}}, and {{flags}} placeholders.
+func resolvePlaceholders(args []string, srcPath, artifactParam string, flags []string) []string {
+ var out []string
+ for _, a := range args {
+ if a == "{{flags}}" {
+ out = append(out, flags...)
+ } else {
+ a = strings.ReplaceAll(a, "{{source}}", srcPath)
+ a = strings.ReplaceAll(a, "{{artifact}}", artifactParam)
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+// mergeLimits applies non-zero fields from override onto base.
+func mergeLimits(base languages.Limits, override model.Limits) languages.Limits {
+ if override.WallTimeS > 0 {
+ base.WallTimeS = override.WallTimeS
+ }
+ if override.MemoryKB > 0 {
+ base.MemoryKB = override.MemoryKB
+ }
+ if override.MaxProcesses > 0 {
+ base.MaxProcesses = override.MaxProcesses
+ }
+ return base
+}
+
+// cappedWriter writes to w up to limit bytes, then appends a truncation marker.
+type cappedWriter struct {
+ w io.Writer
+ limit int
+ written int
+}
+
+func (c *cappedWriter) Write(p []byte) (int, error) {
+ if c.written >= c.limit {
+ return len(p), nil // silently drop
+ }
+ remaining := c.limit - c.written
+ if len(p) > remaining {
+ p = p[:remaining]
+ _, _ = c.w.Write(p)
+ _, _ = io.WriteString(c.w, truncMarker)
+ c.written = c.limit
+ return len(p), nil
+ }
+ n, err := c.w.Write(p)
+ c.written += n
+ return n, err
+}
+
+// SweepOrphans scans the system temp directory for stale goboxd-* directories
+// older than maxAge and removes them to clean up orphans from previous runs or crashes.
+func SweepOrphans(maxAge time.Duration) error {
+ tempDir := os.TempDir()
+ entries, err := os.ReadDir(tempDir)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ for _, entry := range entries {
+ if entry.IsDir() && strings.HasPrefix(entry.Name(), "goboxd-") {
+ path := filepath.Join(tempDir, entry.Name())
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ if now.Sub(info.ModTime()) > maxAge {
+ _ = os.RemoveAll(path) // Best effort removal
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
new file mode 100644
index 00000000..b027e12d
--- /dev/null
+++ b/internal/handler/handler.go
@@ -0,0 +1,228 @@
+// internal/handler/handler.go
+package handler
+
+import (
+ "encoding/json"
+ "log/slog"
+ "math"
+ "net/http"
+ "strconv"
+ "sync/atomic"
+ "time"
+
+ "github.com/thesouldev/goboxd/internal/executor"
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/model"
+ "github.com/thesouldev/goboxd/internal/validate"
+ "github.com/thesouldev/goboxd/internal/worker"
+)
+
+// RunHandler handles POST /run.
+type RunHandler struct {
+ Registry *languages.Registry
+ Stats *ServerStats
+ Pool *worker.ConcurrencyPool
+}
+
+func (h *RunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ slog.Info("incoming request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "remote_addr", r.RemoteAddr,
+ )
+
+ // Cap request body to 4 MiB
+ r.Body = http.MaxBytesReader(w, r.Body, validate.MaxRequestBodyBytes)
+
+ var req model.RunRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ slog.Warn("request decoding failed", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
+ return
+ }
+
+ // Validate language
+ if req.Language == "" {
+ slog.Warn("validation failed", "code", "missing_language", "msg", "language is required")
+ writeError(w, http.StatusBadRequest, "missing_language", "language is required")
+ return
+ }
+
+ lang, err := h.Registry.Get(req.Language)
+ if err != nil {
+ slog.Warn("validation failed", "code", "unknown_language", "language", req.Language)
+ writeError(w, http.StatusBadRequest, "unknown_language",
+ "language "+req.Language+" is not supported")
+ return
+ }
+
+ // Validate source
+ if err := validate.SourceSize(req.Source); err != nil {
+ slog.Warn("validation failed", "code", "invalid_source", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_source", err.Error())
+ return
+ }
+
+ // Validate test count
+ if err := validate.TestCount(len(req.Tests)); err != nil {
+ slog.Warn("validation failed", "code", "invalid_tests", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_tests", err.Error())
+ return
+ }
+
+ // Validate individual test case stdin/output sizes
+ if err := validate.TestInputs(req.Tests); err != nil {
+ slog.Warn("validation failed", "code", "invalid_tests", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_tests", err.Error())
+ return
+ }
+
+ // Validate optional filenames
+ if req.SourceFilename != "" {
+ if err := validate.Filename(req.SourceFilename); err != nil {
+ slog.Warn("validation failed", "code", "invalid_filename", "field", "SourceFilename", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_filename", err.Error())
+ return
+ }
+ }
+ if req.ArtifactFilename != "" {
+ if err := validate.Filename(req.ArtifactFilename); err != nil {
+ slog.Warn("validation failed", "code", "invalid_filename", "field", "ArtifactFilename", "err", err)
+ writeError(w, http.StatusBadRequest, "invalid_filename", err.Error())
+ return
+ }
+ }
+
+ // Validate flags against per-language allowlist
+ if req.Build != nil && lang.Build != nil {
+ if err := validate.Flags(req.Build.Flags, lang.Build.FlagAllowlist); err != nil {
+ slog.Warn("validation failed", "code", "disallowed_flag", "err", err)
+ writeError(w, http.StatusBadRequest, "disallowed_flag", err.Error())
+ return
+ }
+ }
+
+ // Apply load-adaptive limit checks & clamp overrides
+ var warnings []string
+ rate := h.Pool.RateTracker.GetRate()
+ if req.Build != nil && req.Build.Limits != nil {
+ warns := validate.ResourceLimits(req.Build.Limits, lang.ID, rate)
+ warnings = append(warnings, warns...)
+ }
+ if req.Run != nil && req.Run.Limits != nil {
+ warns := validate.ResourceLimits(req.Run.Limits, lang.ID, rate)
+ warnings = append(warnings, warns...)
+ }
+
+ // Estimate cost and memory to acquire slot in the priority queue
+ buildTime := 0
+ if lang.Build != nil {
+ buildTime = lang.Build.Limits.WallTimeS
+ if req.Build != nil && req.Build.Limits != nil && req.Build.Limits.WallTimeS > 0 {
+ buildTime = req.Build.Limits.WallTimeS
+ }
+ }
+ runTime := lang.Run.Limits.WallTimeS
+ if req.Run != nil && req.Run.Limits != nil && req.Run.Limits.WallTimeS > 0 {
+ runTime = req.Run.Limits.WallTimeS
+ }
+ cost := int64(buildTime + len(req.Tests)*runTime)
+
+ memKB := lang.Run.Limits.MemoryKB
+ if req.Run != nil && req.Run.Limits != nil && req.Run.Limits.MemoryKB > 0 {
+ memKB = req.Run.Limits.MemoryKB
+ }
+
+ active, queued, totalShed := h.Pool.GetStats()
+ slog.Info("queue status before acquire",
+ "active_workers", active,
+ "queued_jobs", queued,
+ "total_shed", totalShed,
+ "cost", cost,
+ )
+
+ // Acquire concurrency slot (block in priority queue or reject if queue is saturated)
+ err = h.Pool.Acquire(r.Context(), cost, memKB)
+ if err != nil {
+ if err == worker.ErrQueueFull {
+ _, queued, _ = h.Pool.GetStats()
+ avgDur := h.Pool.ExecutionTracker.GetAverage()
+ maxActive := h.Pool.GetMaxActive()
+
+ // Estimated Wait Time = (QueueSize * AvgDuration) / MaxConcurrency
+ estWaitSecs := int(math.Ceil(float64(queued) * avgDur.Seconds() / float64(maxActive)))
+ if estWaitSecs < 1 {
+ estWaitSecs = 1
+ }
+
+ slog.Warn("load shedding triggered",
+ "reason", "queue_full",
+ "queued_jobs", queued,
+ "retry_after_secs", estWaitSecs,
+ )
+
+ w.Header().Set("Retry-After", strconv.Itoa(estWaitSecs))
+ writeError(w, http.StatusServiceUnavailable, "service_unavailable", "server overload: queue is full")
+ return
+ }
+ // Request context cancelled / timeout
+ slog.Warn("acquire cancelled or timed out", "err", err)
+ writeError(w, http.StatusRequestTimeout, "request_timeout", err.Error())
+ return
+ }
+ defer h.Pool.Release()
+
+ active, queued, _ = h.Pool.GetStats()
+ slog.Info("slot acquired, starting execution",
+ "active_workers", active,
+ "queued_jobs", queued,
+ )
+
+ // Track active execution state
+ atomic.AddInt64(&h.Stats.InFlightJobs, 1)
+ startExec := time.Now()
+
+ resp, err := executor.Run(&req, lang)
+
+ execDur := time.Since(startExec)
+ atomic.AddInt64(&h.Stats.InFlightJobs, -1)
+
+ if err != nil {
+ atomic.AddUint64(&h.Stats.JobsFailedInternal, 1)
+ atomic.StoreInt64(&h.Stats.LastInternalErrorAt, time.Now().Unix())
+ slog.Error("execution failed internally",
+ "err", err,
+ "duration_ms", execDur.Milliseconds(),
+ )
+ writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
+ return
+ }
+
+ // Success tracking
+ atomic.AddUint64(&h.Stats.TotalRuns, 1)
+ h.Pool.ExecutionTracker.AddDuration(execDur)
+
+ resp.Warnings = warnings
+
+ active, queued, _ = h.Pool.GetStats()
+ slog.Info("execution completed",
+ "status", resp.Status,
+ "duration_ms", execDur.Milliseconds(),
+ "active_workers", active,
+ "queued_jobs", queued,
+ "warnings_count", len(warnings),
+ )
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+func writeError(w http.ResponseWriter, status int, code, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(model.ErrorResponse{
+ Error: model.ErrorDetail{Code: code, Message: message},
+ })
+}
+
diff --git a/internal/handler/info.go b/internal/handler/info.go
new file mode 100644
index 00000000..f8646ad1
--- /dev/null
+++ b/internal/handler/info.go
@@ -0,0 +1,157 @@
+// internal/handler/info.go
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "runtime"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/sandbox"
+ "github.com/thesouldev/goboxd/internal/validate"
+ "github.com/thesouldev/goboxd/internal/worker"
+)
+
+// ServerStats tracks service-level execution statistics.
+type ServerStats struct {
+ InFlightJobs int64 // atomic
+ TotalRuns uint64 // atomic (jobs_total)
+ JobsFailedInternal uint64 // atomic
+ LastInternalErrorAt int64 // atomic Unix timestamp (0 if none)
+}
+
+type InfoHandler struct {
+ Registry *languages.Registry
+ Stats *ServerStats
+ ReadyHandler *ReadyHandler
+ Pool *worker.ConcurrencyPool
+}
+
+type InfoResponse struct {
+ BuildInfo BuildInfoResponse `json:"build_info"`
+ Nsjail NsjailInfoResponse `json:"nsjail"`
+ Languages []LangInfoResponse `json:"languages"`
+ Limits ServerLimitsResponse `json:"limits"`
+ Stats StatsResponse `json:"stats"`
+}
+
+type BuildInfoResponse struct {
+ Version string `json:"version"`
+ Commit string `json:"commit"`
+ GoVersion string `json:"go_version"`
+}
+
+type NsjailInfoResponse struct {
+ Path string `json:"path"`
+ Version string `json:"version"`
+}
+
+type LangInfoResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ DefaultRunLimits languages.Limits `json:"default_run_limits"`
+}
+
+type ServerLimitsResponse struct {
+ MaxSourceBytes int `json:"max_source_bytes"`
+ MaxTests int `json:"max_tests"`
+ MaxConcurrentJobs int `json:"max_concurrent_jobs"`
+ MaxQueueSize int `json:"max_queue_size"`
+}
+
+type StatsResponse struct {
+ InFlightJobs int64 `json:"in_flight_jobs"`
+ QueuedJobs int `json:"queued_jobs"`
+ JobsTotal uint64 `json:"jobs_total"`
+ JobsFailedInternal uint64 `json:"jobs_failed_internal"`
+ JobsShedTotal uint64 `json:"jobs_shed_total"`
+ LastInternalErrorAt string `json:"last_internal_error_at,omitempty"`
+ DiskFreeBytesJailDir uint64 `json:"disk_free_bytes_jail_dir"`
+}
+
+func NewInfoHandler(registry *languages.Registry, stats *ServerStats, ready *ReadyHandler, pool *worker.ConcurrencyPool) *InfoHandler {
+ return &InfoHandler{
+ Registry: registry,
+ Stats: stats,
+ ReadyHandler: ready,
+ Pool: pool,
+ }
+}
+
+func (h *InfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ // Fetch language version mappings cached by ReadyHandler
+ langs := h.Registry.All()
+ respLangs := make([]LangInfoResponse, 0, len(langs))
+ for _, lang := range langs {
+ version := "unknown"
+ if status, ok := h.ReadyHandler.GetLanguageStatus(lang.ID); ok && status.OK {
+ version = status.Version
+ }
+ respLangs = append(respLangs, LangInfoResponse{
+ ID: lang.ID,
+ Name: lang.Name,
+ Version: version,
+ DefaultRunLimits: lang.Run.Limits,
+ })
+ }
+
+ nsjailVersion := "unknown"
+ if status := h.ReadyHandler.GetNsjailStatus(); status.OK {
+ nsjailVersion = status.Version
+ }
+
+ var lastErrStr string
+ if lastErrUnix := atomic.LoadInt64(&h.Stats.LastInternalErrorAt); lastErrUnix > 0 {
+ lastErrStr = time.Unix(lastErrUnix, 0).UTC().Format(time.RFC3339)
+ }
+
+ active, queued, shed := h.Pool.GetStats()
+
+ resp := InfoResponse{
+ BuildInfo: BuildInfoResponse{
+ Version: "0.1.0",
+ Commit: "abc1234", // Default placeholder
+ GoVersion: runtime.Version(),
+ },
+ Nsjail: NsjailInfoResponse{
+ Path: sandbox.NsjailPath,
+ Version: nsjailVersion,
+ },
+ Languages: respLangs,
+ Limits: ServerLimitsResponse{
+ MaxSourceBytes: validate.MaxSourceBytes,
+ MaxTests: validate.MaxTests,
+ MaxConcurrentJobs: h.Pool.GetMaxActive(),
+ MaxQueueSize: h.Pool.GetMaxQueue(),
+ },
+ Stats: StatsResponse{
+ InFlightJobs: int64(active),
+ QueuedJobs: queued,
+ JobsTotal: atomic.LoadUint64(&h.Stats.TotalRuns),
+ JobsFailedInternal: atomic.LoadUint64(&h.Stats.JobsFailedInternal),
+ JobsShedTotal: shed,
+ LastInternalErrorAt: lastErrStr,
+ DiskFreeBytesJailDir: diskFreeBytes(os.TempDir()),
+ },
+ }
+
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+func diskFreeBytes(path string) uint64 {
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err != nil {
+ return 0
+ }
+ return uint64(stat.Bavail) * uint64(stat.Bsize)
+}
+
+
diff --git a/internal/handler/info_test.go b/internal/handler/info_test.go
new file mode 100644
index 00000000..626f75a9
--- /dev/null
+++ b/internal/handler/info_test.go
@@ -0,0 +1,57 @@
+// internal/handler/info_test.go
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/worker"
+)
+
+func TestInfoHandler(t *testing.T) {
+ reg, err := languages.Load("../../configs/languages/languages.yaml")
+ if err != nil {
+ t.Fatalf("failed to load registry: %v", err)
+ }
+
+ stats := &ServerStats{
+ TotalRuns: 42,
+ }
+ pool := worker.NewConcurrencyPool(15, 500)
+ ready := NewReadyHandler(reg)
+ info := NewInfoHandler(reg, stats, ready, pool)
+
+ req := httptest.NewRequest("GET", "/info", nil)
+ rr := httptest.NewRecorder()
+
+ info.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("expected status 200, got %d", rr.Code)
+ }
+
+ var resp InfoResponse
+ if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if resp.BuildInfo.Version != "0.1.0" {
+ t.Errorf("expected version 0.1.0, got %s", resp.BuildInfo.Version)
+ }
+
+ if resp.Stats.JobsTotal != 42 {
+ t.Errorf("expected JobsTotal 42, got %d", resp.Stats.JobsTotal)
+ }
+
+ if len(resp.Languages) != len(reg.All()) {
+ t.Errorf("expected %d languages, got %d", len(reg.All()), len(resp.Languages))
+ }
+
+ // Verify schema fields
+ if resp.Nsjail.Path == "" {
+ t.Error("expected non-empty nsjail path")
+ }
+}
diff --git a/internal/handler/ready.go b/internal/handler/ready.go
new file mode 100644
index 00000000..45d1c78d
--- /dev/null
+++ b/internal/handler/ready.go
@@ -0,0 +1,172 @@
+// internal/handler/ready.go
+package handler
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/sandbox"
+)
+
+type ReadyHandler struct {
+ Registry *languages.Registry
+ response ReadyResponse
+ healthy bool
+}
+
+// GetNsjailStatus returns the cached status of nsjail.
+func (h *ReadyHandler) GetNsjailStatus() NsjailStatus {
+ return h.response.Nsjail
+}
+
+// GetLanguageStatus returns the cached status of a language by its ID.
+func (h *ReadyHandler) GetLanguageStatus(id string) (LangStatus, bool) {
+ status, ok := h.response.Languages[id]
+ return status, ok
+}
+
+type ReadyResponse struct {
+ Status string `json:"status"`
+ Nsjail NsjailStatus `json:"nsjail"`
+ Languages map[string]LangStatus `json:"languages"`
+}
+
+type NsjailStatus struct {
+ OK bool `json:"ok"`
+ Version string `json:"version,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type LangStatus struct {
+ OK bool `json:"ok"`
+ Version string `json:"version,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func NewReadyHandler(registry *languages.Registry) *ReadyHandler {
+ h := &ReadyHandler{
+ Registry: registry,
+ healthy: true,
+ }
+ h.response.Languages = make(map[string]LangStatus)
+ h.runChecks()
+ return h
+}
+
+func (h *ReadyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if h.healthy {
+ w.WriteHeader(http.StatusOK)
+ } else {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
+ _ = json.NewEncoder(w).Encode(h.response)
+}
+
+func (h *ReadyHandler) runChecks() {
+ // 1. Check nsjail
+ _, err := exec.LookPath(sandbox.NsjailPath)
+ if err != nil {
+ h.healthy = false
+ h.response.Nsjail = NsjailStatus{OK: false, Error: err.Error()}
+ } else {
+ h.response.Nsjail = NsjailStatus{OK: true, Version: "3.4"}
+ }
+
+ // 2. Check each language in registry
+ langs := h.Registry.All()
+ for _, lang := range langs {
+ var checkCmd string
+ var checkArgs []string
+
+ if lang.SmokeCheckCmd != "" {
+ checkCmd = lang.SmokeCheckCmd
+ } else if lang.Build != nil {
+ checkCmd = lang.Build.Cmd
+ } else {
+ checkCmd = lang.Run.Cmd
+ }
+
+ if len(lang.SmokeCheckArgs) > 0 {
+ checkArgs = lang.SmokeCheckArgs
+ } else {
+ // Tailor version arguments for typical runtimes
+ switch lang.ID {
+ case "py3", "c", "cpp", "bash":
+ checkArgs = []string{"--version"}
+ case "java":
+ checkArgs = []string{"-version"}
+ case "js":
+ checkArgs = []string{"--version"}
+ case "verilog":
+ checkArgs = []string{"-V"}
+ default:
+ checkArgs = []string{"--version"}
+ }
+ }
+
+ ver, err := getCmdVersion(checkCmd, checkArgs)
+ if err != nil {
+ h.healthy = false
+ h.response.Languages[lang.ID] = LangStatus{OK: false, Error: err.Error()}
+ } else {
+ h.response.Languages[lang.ID] = LangStatus{OK: true, Version: cleanVersionString(ver)}
+ }
+ }
+
+ if h.healthy {
+ h.response.Status = "ok"
+ } else {
+ h.response.Status = "degraded"
+ }
+}
+
+func getCmdVersion(cmdName string, args []string) (string, error) {
+ // First check if path is resolvable
+ path, err := exec.LookPath(cmdName)
+ if err != nil {
+ return "", err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, path, args...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ // Some utilities exit with error on version check (e.g. java -version exits with status, or iverilog outputs to stderr)
+ // If we got some output in stdout or stderr, we can still parse it
+ combined := stdout.String() + stderr.String()
+ if len(strings.TrimSpace(combined)) > 0 {
+ return combined, nil
+ }
+ return "", err
+ }
+
+ combined := stdout.String() + stderr.String()
+ return combined, nil
+}
+
+func cleanVersionString(s string) string {
+ lines := strings.Split(s, "\n")
+ if len(lines) == 0 {
+ return "unknown"
+ }
+ // Take the first non-empty line
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" {
+ return trimmed
+ }
+ }
+ return "unknown"
+}
diff --git a/internal/languages/config.go b/internal/languages/config.go
new file mode 100644
index 00000000..d98b5986
--- /dev/null
+++ b/internal/languages/config.go
@@ -0,0 +1,34 @@
+// internal/languages/config.go
+// Christiano Fernadnes
+// 31 May 26
+package languages
+
+type Language struct {
+ ID string `yaml:"id" json:"id"`
+ Name string `yaml:"name" json:"name"`
+ SourceFilename string `yaml:"source_filename,omitempty" json:"source_filename,omitempty"`
+ SourceFilenameStrategy string `yaml:"source_filename_strategy,omitempty" json:"source_filename_strategy,omitempty"`
+ Artifact string `yaml:"artifact,omitempty" json:"artifact,omitempty"`
+ ArtifactFilenameStrategy string `yaml:"artifact_filename_strategy,omitempty" json:"artifact_filename_strategy,omitempty"`
+ Build *PhaseConfig `yaml:"build,omitempty" json:"build,omitempty"`
+ Run PhaseConfig `yaml:"run" json:"run"`
+ SmokeCheckCmd string `yaml:"smoke_check_cmd,omitempty" json:"smoke_check_cmd,omitempty"`
+ SmokeCheckArgs []string `yaml:"smoke_check_args,omitempty" json:"smoke_check_args,omitempty"`
+}
+
+type PhaseConfig struct {
+ Cmd string `yaml:"cmd" json:"cmd"`
+ Args []string `yaml:"args" json:"args"`
+ Limits Limits `yaml:"limits" json:"limits"`
+ FlagAllowlist []string `yaml:"flag_allowlist,omitempty" json:"flag_allowlist,omitempty"`
+}
+
+type Limits struct {
+ WallTimeS int `yaml:"wall_time_s" json:"wall_time_s"`
+ MemoryKB int `yaml:"memory_kb" json:"memory_kb"`
+ MaxProcesses int `yaml:"max_processes" json:"max_processes"`
+}
+
+type RegistryFile struct {
+ Languages []Language `yaml:"languages" json:"languages"`
+}
diff --git a/internal/languages/registry.go b/internal/languages/registry.go
new file mode 100644
index 00000000..0115de0c
--- /dev/null
+++ b/internal/languages/registry.go
@@ -0,0 +1,127 @@
+// internal/languages/registry.go
+// Christiano Fernadnes
+// 31 May 26
+// language registry source and validate choice
+package languages
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Registry holds all loaded languages, keyed by ID.
+type Registry struct {
+ languages map[string]Language
+}
+
+// Load reads the YAML file at path and returns a Registry.
+func Load(path string) (*Registry, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read language config: %w", err)
+ }
+
+ var file RegistryFile
+ if err := yaml.Unmarshal(data, &file); err != nil {
+ return nil, fmt.Errorf("parse language config: %w", err)
+ }
+
+ if len(file.Languages) == 0 {
+ return nil, fmt.Errorf("language config has no entries")
+ }
+
+ m := make(map[string]Language, len(file.Languages))
+ for _, lang := range file.Languages {
+ if err := lang.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid language config: %w", err)
+ }
+ m[lang.ID] = lang
+ }
+
+ return &Registry{languages: m}, nil
+}
+
+// Get returns the Language for the given id, or an error if not found.
+func (r *Registry) Get(id string) (Language, error) {
+ lang, ok := r.languages[id]
+ if !ok {
+ return Language{}, fmt.Errorf("unknown language: %q", id)
+ }
+ return lang, nil
+}
+
+// All returns all registered languages.
+func (r *Registry) All() []Language {
+ out := make([]Language, 0, len(r.languages))
+ for _, l := range r.languages {
+ out = append(out, l)
+ }
+ return out
+}
+
+func (l Language) Validate() error {
+ if l.ID == "" {
+ return fmt.Errorf("language ID is required")
+ }
+ if l.Name == "" {
+ return fmt.Errorf("language name is required for ID %q", l.ID)
+ }
+ if l.Run.Cmd == "" {
+ return fmt.Errorf("run command is required for ID %q", l.ID)
+ }
+ if l.Run.Limits.WallTimeS <= 0 {
+ return fmt.Errorf("run limits.wall_time_s must be positive for ID %q", l.ID)
+ }
+ if l.Run.Limits.MemoryKB <= 0 {
+ return fmt.Errorf("run limits.memory_kb must be positive for ID %q", l.ID)
+ }
+ if l.Run.Limits.MaxProcesses <= 0 {
+ return fmt.Errorf("run limits.max_processes must be positive for ID %q", l.ID)
+ }
+
+ if l.Build != nil {
+ if l.Build.Cmd == "" {
+ return fmt.Errorf("build command is required for ID %q", l.ID)
+ }
+ if l.Build.Limits.WallTimeS <= 0 {
+ return fmt.Errorf("build limits.wall_time_s must be positive for ID %q", l.ID)
+ }
+ if l.Build.Limits.MemoryKB <= 0 {
+ return fmt.Errorf("build limits.memory_kb must be positive for ID %q", l.ID)
+ }
+ if l.Build.Limits.MaxProcesses <= 0 {
+ return fmt.Errorf("build limits.max_processes must be positive for ID %q", l.ID)
+ }
+ }
+
+ if l.SourceFilename != "" {
+ if err := validateFilename(l.SourceFilename); err != nil {
+ return fmt.Errorf("invalid source_filename for ID %q: %w", l.ID, err)
+ }
+ }
+ if l.Artifact != "" {
+ if err := validateFilename(l.Artifact); err != nil {
+ return fmt.Errorf("invalid artifact for ID %q: %w", l.ID, err)
+ }
+ }
+ return nil
+}
+
+func validateFilename(s string) error {
+ if s == "" {
+ return fmt.Errorf("filename must not be empty")
+ }
+ if strings.ContainsAny(s, `/\`) {
+ return fmt.Errorf("filename must be a single path component")
+ }
+ if strings.HasPrefix(s, ".") {
+ return fmt.Errorf("filename must not start with a dot")
+ }
+ if s == ".." {
+ return fmt.Errorf("filename must not be ..")
+ }
+ return nil
+}
diff --git a/internal/languages/registry_test.go b/internal/languages/registry_test.go
new file mode 100644
index 00000000..5ae4cbf6
--- /dev/null
+++ b/internal/languages/registry_test.go
@@ -0,0 +1,133 @@
+// internal/languages/registry_test.go
+// Christiano Fernandes
+// 31 May 26
+package languages_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/thesouldev/goboxd/internal/languages"
+)
+
+const testYAML = `
+languages:
+ - id: py3
+ name: Python 3
+ source_filename: solution.py
+ run:
+ cmd: /usr/bin/python3
+ args: ["{{source}}"]
+ limits:
+ wall_time_s: 9
+ memory_kb: 102400
+ max_processes: 100
+`
+
+func TestLoad(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "languages.yaml")
+ if err := os.WriteFile(path, []byte(testYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ reg, err := languages.Load(path)
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ lang, err := reg.Get("py3")
+ if err != nil {
+ t.Fatalf("Get(py3) error: %v", err)
+ }
+ if lang.Name != "Python 3" {
+ t.Errorf("expected name 'Python 3', got %q", lang.Name)
+ }
+ if lang.Run.Limits.WallTimeS != 9 {
+ t.Errorf("expected wall_time_s 9, got %d", lang.Run.Limits.WallTimeS)
+ }
+}
+
+func TestGetUnknown(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "languages.yaml")
+ if err := os.WriteFile(path, []byte(testYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ reg, _ := languages.Load(path)
+ if _, err := reg.Get("cobol"); err == nil {
+ t.Error("expected error for unknown language")
+ }
+}
+
+func TestLoadMissingFile(t *testing.T) {
+ if _, err := languages.Load("/nonexistent/path.yaml"); err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestValidationErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ }{
+ {
+ name: "missing ID",
+ yaml: `
+languages:
+ - name: Python 3
+ run:
+ cmd: /usr/bin/python3
+ limits: { wall_time_s: 5, memory_kb: 100, max_processes: 10 }
+`,
+ },
+ {
+ name: "missing command",
+ yaml: `
+languages:
+ - id: py3
+ name: Python 3
+ run:
+ limits: { wall_time_s: 5, memory_kb: 100, max_processes: 10 }
+`,
+ },
+ {
+ name: "negative limits",
+ yaml: `
+languages:
+ - id: py3
+ name: Python 3
+ run:
+ cmd: /usr/bin/python3
+ limits: { wall_time_s: -1, memory_kb: 100, max_processes: 10 }
+`,
+ },
+ {
+ name: "path traversal in filename",
+ yaml: `
+languages:
+ - id: py3
+ name: Python 3
+ source_filename: ../solution.py
+ run:
+ cmd: /usr/bin/python3
+ limits: { wall_time_s: 5, memory_kb: 100, max_processes: 10 }
+`,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "invalid.yaml")
+ if err := os.WriteFile(path, []byte(tc.yaml), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := languages.Load(path); err == nil {
+ t.Errorf("expected validation error for case %q", tc.name)
+ }
+ })
+ }
+}
diff --git a/internal/model/request.go b/internal/model/request.go
new file mode 100644
index 00000000..3265d7c6
--- /dev/null
+++ b/internal/model/request.go
@@ -0,0 +1,32 @@
+//internal/model/request.go
+//Christiano Fernadnes
+// 31 May 26
+// Model for the requsts,
+
+package model
+
+type RunRequest struct {
+ Language string `json:"language"`
+ Source string `json:"source"`
+ SourceFilename string `json:"source_filename,omitempty"`
+ ArtifactFilename string `json:"artifact_filename,omitempty"`
+ Build *PhaseOpts `json:"build,omitempty"`
+ Run *PhaseOpts `json:"run,omitempty"`
+ Tests []TestCase `json:"tests"`
+}
+
+type PhaseOpts struct {
+ Limits *Limits `json:"limits,omitempty"`
+ Flags []string `json:"flags,omitempty"`
+}
+
+type Limits struct {
+ WallTimeS int `json:"wall_time_s,omitempty"`
+ MemoryKB int `json:"memory_kb,omitempty"`
+ MaxProcesses int `json:"max_processes,omitempty"`
+}
+
+type TestCase struct {
+ Stdin string `json:"stdin"`
+ ExpectedStdout string `json:"expected_stdout"`
+}
diff --git a/internal/model/response.go b/internal/model/response.go
new file mode 100644
index 00000000..f526e6ae
--- /dev/null
+++ b/internal/model/response.go
@@ -0,0 +1,38 @@
+// internal/model/response.go
+// Christiano Fernadnes
+// 31 May 26
+// Model for the responses,
+//
+
+package model
+
+type RunResponse struct {
+ Status string `json:"status"`
+ Build *BuildResult `json:"build,omitempty"`
+ Tests []TestResult `json:"tests"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+type BuildResult struct {
+ Status string `json:"status"`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ DurationMs int64 `json:"duration_ms"`
+}
+
+type TestResult struct {
+ Status string `json:"status"`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ DurationMs int64 `json:"duration_ms"`
+ MemoryPeakKB int64 `json:"memory_peak_kb,omitempty"`
+}
+
+type ErrorResponse struct {
+ Error ErrorDetail `json:"error"`
+}
+
+type ErrorDetail struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
diff --git a/internal/sandbox/nsjail.go b/internal/sandbox/nsjail.go
new file mode 100644
index 00000000..e4df50a9
--- /dev/null
+++ b/internal/sandbox/nsjail.go
@@ -0,0 +1,90 @@
+// internal/sandbox/nsjail.go
+// Christiano Fernandes
+// 31 May 26
+package sandbox
+
+import (
+ "fmt"
+ "os/exec"
+ "strconv"
+)
+
+const NsjailPath = "/usr/local/bin/nsjail"
+
+// Config holds the parameters for one nsjail invocation.
+type Config struct {
+ // WorkDir is the temp directory bind-mounted into the sandbox (rw).
+ WorkDir string
+ // WallTimeSecs is the max execution time in seconds.
+ WallTimeSecs int
+ // MemoryKB is the memory limit in kilobytes.
+ MemoryKB int
+ // MaxPIDs is the maximum number of processes/threads.
+ MaxPIDs int
+ // Command is the program to run inside the sandbox (e.g. ["python3", "solution.py"]).
+ Command []string
+ // UID is the unique user ID mapped to this request execution.
+ UID int
+}
+
+// Build returns an exec.Cmd that runs Command inside an nsjail sandbox.
+func Build(cfg Config) (*exec.Cmd, error) {
+ if len(cfg.Command) == 0 {
+ return nil, fmt.Errorf("sandbox: command must not be empty")
+ }
+
+ // nsjail expects --rlimit_as in MB, not bytes or KB
+ memMB := (cfg.MemoryKB + 1023) / 1024
+ if cfg.MemoryKB > 0 && memMB == 0 {
+ memMB = 1
+ }
+
+ args := []string{
+ // One-shot mode: run once then exit
+ "--mode", "o",
+
+ // Resource limits
+ "--time_limit", strconv.Itoa(cfg.WallTimeSecs),
+ "--rlimit_as", strconv.Itoa(memMB),
+ "--rlimit_nproc", strconv.Itoa(cfg.MaxPIDs),
+
+ // Network: disabled
+ "--disable_clone_newnet",
+
+ // chroot to the host root (read-only)
+ "--chroot", "/",
+
+ // Writable temp space for compilers/interpreters
+ "--tmpfsmount", "/tmp",
+
+ // The working directory with submitted code (read-write)
+ "--bindmount", cfg.WorkDir,
+ "--cwd", cfg.WorkDir,
+
+ // Pass clean standard PATH for compiler sub-commands (like ld and as)
+ "--env", "PATH=/usr/bin:/bin",
+
+ // Disable mounting procfs to bypass Docker's /proc overmount restriction
+ "--disable_proc",
+
+ // Log only fatal errors from nsjail to stderr (default FD 2)
+ "--really_quiet",
+ }
+
+ if cfg.UID > 0 {
+ args = append(args,
+ "--uid_mapping", fmt.Sprintf("%d:%d:1", cfg.UID, cfg.UID),
+ "--gid_mapping", fmt.Sprintf("%d:%d:1", cfg.UID, cfg.UID),
+ )
+ }
+
+ args = append(args,
+ // Separator between nsjail args and the command to run
+ "--",
+ )
+
+ // Append the actual command
+ args = append(args, cfg.Command...)
+
+ return exec.Command(NsjailPath, args...), nil
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
new file mode 100644
index 00000000..6be8ba64
--- /dev/null
+++ b/internal/validate/validate.go
@@ -0,0 +1,205 @@
+// internal/validate/validate.go
+// Christiano Fernadnes
+// 31 May 26
+
+package validate
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/thesouldev/goboxd/internal/model"
+)
+
+const (
+ MaxSourceBytes = 256 * 1024 // 256 KiB
+ MaxTests = 50
+ MaxFilenameLen = 128
+ MaxStdinBytes = 64 * 1024 // 64 KiB per test stdin
+ MaxRequestBodyBytes = 4 * 1024 * 1024 // 4 MiB max HTTP body limit
+)
+
+func Filename(s string) error {
+ if s == "" {
+ return fmt.Errorf("filename must not be empty")
+ }
+ if len(s) > MaxFilenameLen {
+ return fmt.Errorf("filename too long (max %d chars)", MaxFilenameLen)
+ }
+ if strings.ContainsAny(s, `/\`) {
+ return fmt.Errorf("filename must be a single path component")
+ }
+ if strings.HasPrefix(s, ".") {
+ return fmt.Errorf("filename must not start with a dot")
+ }
+ if s == ".." {
+ return fmt.Errorf("filename must not be ..")
+ }
+ // Ensure it has no directory component at all
+ if filepath.Base(s) != s {
+ return fmt.Errorf("filename must be a single path component")
+ }
+ return nil
+}
+
+func SourceSize(source string) error {
+ if len(source) == 0 {
+ return fmt.Errorf("source must not be empty")
+ }
+ if len(source) > MaxSourceBytes {
+ return fmt.Errorf("source exceeds maximum size of %d bytes", MaxSourceBytes)
+ }
+ return nil
+}
+
+func TestCount(n int) error {
+ if n == 0 {
+ return fmt.Errorf("at least one test case is required")
+ }
+ if n > MaxTests {
+ return fmt.Errorf("too many test cases (max %d)", MaxTests)
+ }
+ return nil
+}
+
+func Flags(supplied []string, allowlist []string) error {
+ if len(supplied) == 0 {
+ return nil
+ }
+ if len(allowlist) == 0 {
+ return fmt.Errorf("this language does not accept custom flags")
+ }
+ for _, flag := range supplied {
+ if !flagAllowed(flag, allowlist) {
+ return fmt.Errorf("flag %q is not allowed for this language", flag)
+ }
+ }
+ return nil
+}
+
+func flagAllowed(flag string, allowlist []string) bool {
+ for _, pattern := range allowlist {
+ if pattern == flag {
+ return true
+ }
+ // Handle glob patterns like "-std=*"
+ if strings.HasSuffix(pattern, "*") {
+ prefix := strings.TrimSuffix(pattern, "*")
+ if strings.HasPrefix(flag, prefix) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// ResourceLimits validates and dynamically clamps resource overrides.
+// It returns a slice of warnings if any values were adjusted.
+func ResourceLimits(limits *model.Limits, langID string, reqRate float64) []string {
+ if limits == nil {
+ return nil
+ }
+
+ var warnings []string
+
+ // 1. Calculate load-adaptive maximum caps based on the current req rate
+ maxWallTime := 15
+ if reqRate > 5.0 {
+ // Reduce wall time by 0.5s per req/sec above 5, down to min 1s
+ reduced := 15 - int(0.5*(reqRate-5.0))
+ if reduced < 1 {
+ maxWallTime = 1
+ } else {
+ maxWallTime = reduced
+ }
+ }
+
+ maxProc := 128
+
+ // Memory caps depend on language compile/run requirements
+ maxMem := 524288 // 512 MB standard max
+ if langID == "java" || langID == "js" || langID == "c" || langID == "cpp" {
+ // JVM, Node, GCC/G++ build can use up to 2 GB
+ maxMem = 2097152
+ }
+ if reqRate > 5.0 {
+ // Reduce max memory by 50MB (51200KB) per req/sec above 5
+ reductionKB := int(51200 * (reqRate - 5.0))
+ if langID == "java" || langID == "js" {
+ // For Java/JS, never reduce below 1 GB (1048576 KB)
+ clamped := 2097152 - reductionKB
+ if clamped < 1048576 {
+ maxMem = 1048576
+ } else {
+ maxMem = clamped
+ }
+ } else {
+ clamped := maxMem - reductionKB
+ if clamped < 16384 { // absolute baseline 16 MB for other languages under load
+ maxMem = 16384
+ } else {
+ maxMem = clamped
+ }
+ }
+ }
+
+ // 2. Minimum baseline caps (under-allocation protection)
+ minMem := 16384 // 16 MB
+ if langID == "java" || langID == "js" {
+ minMem = 1048576 // 1 GB boot min
+ } else if langID == "c" || langID == "cpp" {
+ minMem = 262144 // 256 MB compile min
+ }
+
+ // Clamp WallTimeS
+ if limits.WallTimeS > 0 {
+ if limits.WallTimeS < 1 {
+ limits.WallTimeS = 1
+ warnings = append(warnings, "wall_time_s limit override is too low; clamped to minimum (1s)")
+ } else if limits.WallTimeS > maxWallTime {
+ limits.WallTimeS = maxWallTime
+ warnings = append(warnings, fmt.Sprintf("wall_time_s limit override exceeds load-adaptive cap; clamped to maximum (%ds)", maxWallTime))
+ }
+ }
+
+ // Clamp MemoryKB
+ if limits.MemoryKB > 0 {
+ if limits.MemoryKB < minMem {
+ limits.MemoryKB = minMem
+ warnings = append(warnings, fmt.Sprintf("memory_kb limit override is too low for runtime initialization; clamped to minimum (%d KB)", minMem))
+ } else if limits.MemoryKB > maxMem {
+ limits.MemoryKB = maxMem
+ warnings = append(warnings, fmt.Sprintf("memory_kb limit override exceeds load-adaptive cap; clamped to maximum (%d KB)", maxMem))
+ }
+ }
+
+ // Clamp MaxProcesses
+ if limits.MaxProcesses > 0 {
+ if limits.MaxProcesses < 1 {
+ limits.MaxProcesses = 1
+ warnings = append(warnings, "max_processes limit override is too low; clamped to minimum (1)")
+ } else if limits.MaxProcesses > maxProc {
+ limits.MaxProcesses = maxProc
+ warnings = append(warnings, fmt.Sprintf("max_processes limit override exceeds maximum cap; clamped to (%d)", maxProc))
+ }
+ }
+
+ return warnings
+}
+
+// TestInputs validates that the stdin and expected output sizes for all test cases
+// do not exceed MaxStdinBytes to prevent memory exhaustion attacks.
+func TestInputs(tests []model.TestCase) error {
+ for i, tc := range tests {
+ if len(tc.Stdin) > MaxStdinBytes {
+ return fmt.Errorf("test case %d stdin exceeds max size of %d bytes", i, MaxStdinBytes)
+ }
+ if len(tc.ExpectedStdout) > MaxStdinBytes {
+ return fmt.Errorf("test case %d expected stdout exceeds max size of %d bytes", i, MaxStdinBytes)
+ }
+ }
+ return nil
+}
+
+
diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go
new file mode 100644
index 00000000..add7b20f
--- /dev/null
+++ b/internal/validate/validate_test.go
@@ -0,0 +1,136 @@
+// internal/validate/validate_test.go
+// Christiano Fernandes
+// 31 May 26
+package validate_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/thesouldev/goboxd/internal/model"
+ "github.com/thesouldev/goboxd/internal/validate"
+)
+
+func TestFilename(t *testing.T) {
+ valid := []string{"solution.py", "main.go", "Solution.java"}
+ for _, name := range valid {
+ if err := validate.Filename(name); err != nil {
+ t.Errorf("Filename(%q) unexpected error: %v", name, err)
+ }
+ }
+
+ invalid := []string{
+ "",
+ "../etc/passwd",
+ "/etc/passwd",
+ ".hidden",
+ "a/b.py",
+ strings.Repeat("a", 200),
+ }
+ for _, name := range invalid {
+ if err := validate.Filename(name); err == nil {
+ t.Errorf("Filename(%q) expected error, got nil", name)
+ }
+ }
+}
+
+func TestSourceSize(t *testing.T) {
+ if err := validate.SourceSize("print('hello')"); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if err := validate.SourceSize(""); err == nil {
+ t.Error("expected error for empty source")
+ }
+ big := strings.Repeat("a", validate.MaxSourceBytes+1)
+ if err := validate.SourceSize(big); err == nil {
+ t.Error("expected error for oversized source")
+ }
+}
+
+func TestTestCount(t *testing.T) {
+ if err := validate.TestCount(1); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if err := validate.TestCount(0); err == nil {
+ t.Error("expected error for 0 tests")
+ }
+ if err := validate.TestCount(validate.MaxTests + 1); err == nil {
+ t.Error("expected error for too many tests")
+ }
+}
+
+func TestFlags(t *testing.T) {
+ allowlist := []string{"-O0", "-O1", "-O2", "-std=*", "-Wall"}
+
+ // Allowed exact match
+ if err := validate.Flags([]string{"-O2"}, allowlist); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ // Allowed glob
+ if err := validate.Flags([]string{"-std=c11"}, allowlist); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ // Disallowed
+ if err := validate.Flags([]string{"-fplugin=evil.so"}, allowlist); err == nil {
+ t.Error("expected error for disallowed flag")
+ }
+ // Empty flags — always ok
+ if err := validate.Flags(nil, allowlist); err != nil {
+ t.Errorf("unexpected error for nil flags: %v", err)
+ }
+ // No allowlist + flags supplied
+ if err := validate.Flags([]string{"-O2"}, nil); err == nil {
+ t.Error("expected error when allowlist is nil but flags supplied")
+ }
+}
+
+func TestResourceLimits(t *testing.T) {
+ // 1. Test Java under-allocation clamp (MemoryKB 256 -> 1048576)
+ limitsJava := &model.Limits{
+ MemoryKB: 256,
+ WallTimeS: 0, // no change
+ }
+ warns := validate.ResourceLimits(limitsJava, "java", 0.0)
+ if limitsJava.MemoryKB != 1048576 {
+ t.Errorf("expected MemoryKB to be clamped to 1048576, got %d", limitsJava.MemoryKB)
+ }
+ if len(warns) != 1 || !strings.Contains(warns[0], "clamped to minimum") {
+ t.Errorf("expected 1 warning regarding minimum clamp, got: %v", warns)
+ }
+
+ // 2. Test Java over-allocation clamp (MemoryKB 4GB -> 2GB)
+ limitsJavaOver := &model.Limits{
+ MemoryKB: 4194304,
+ WallTimeS: 50, // exceeds 15s max
+ }
+ warnsOver := validate.ResourceLimits(limitsJavaOver, "java", 0.0)
+ if limitsJavaOver.MemoryKB != 2097152 {
+ t.Errorf("expected MemoryKB to be clamped to 2097152, got %d", limitsJavaOver.MemoryKB)
+ }
+ if limitsJavaOver.WallTimeS != 15 {
+ t.Errorf("expected WallTimeS to be clamped to 15, got %d", limitsJavaOver.WallTimeS)
+ }
+ if len(warnsOver) != 2 {
+ t.Errorf("expected 2 warnings, got: %v", warnsOver)
+ }
+
+ // 3. Test Load-Adaptive Capping under high request rate (e.g. 15.0 req/sec)
+ // At rate 15.0:
+ // maxWallTime = 15 - 0.5 * (15 - 5) = 10s
+ // maxMem for python = 512MB - 50MB * (15 - 5) = 512MB - 500MB = 12MB -> minimum cap baseline 16MB (16384 KB)
+ limitsPy := &model.Limits{
+ MemoryKB: 524288, // requests standard 512MB
+ WallTimeS: 12, // requests 12s
+ }
+ warnsLoad := validate.ResourceLimits(limitsPy, "py3", 15.0)
+ if limitsPy.WallTimeS != 10 {
+ t.Errorf("expected load-adaptive WallTimeS 10, got %d", limitsPy.WallTimeS)
+ }
+ if limitsPy.MemoryKB != 16384 {
+ t.Errorf("expected load-adaptive MemoryKB 16384 (16MB), got %d", limitsPy.MemoryKB)
+ }
+ if len(warnsLoad) != 2 {
+ t.Errorf("expected 2 load-adaptive warnings, got: %v", warnsLoad)
+ }
+}
+
diff --git a/internal/worker/pool.go b/internal/worker/pool.go
new file mode 100644
index 00000000..1f67fd81
--- /dev/null
+++ b/internal/worker/pool.go
@@ -0,0 +1,249 @@
+// internal/worker/pool.go
+package worker
+
+import (
+ "container/heap"
+ "context"
+ "errors"
+ "sync"
+ "time"
+)
+
+var ErrQueueFull = errors.New("queue is full")
+
+// QueueItem represents a pending request waiting in the priority queue.
+type QueueItem struct {
+ Ctx context.Context
+ Cost int64 // Wall Time S * Memory multiplier
+ MemoryKB int // Memory requested
+ EnqueueTime time.Time
+ Ch chan struct{} // Closed when this item is popped to run
+ index int // Required for container/heap
+}
+
+// PriorityQueue implements heap.Interface and holds QueueItems.
+type PriorityQueue []*QueueItem
+
+func (pq PriorityQueue) Len() int { return len(pq) }
+
+func (pq PriorityQueue) Less(i, j int) bool {
+ now := time.Now()
+ score := func(item *QueueItem) float64 {
+ // Base score is expected duration in seconds + memory weight
+ base := float64(item.Cost) * (1.0 + float64(item.MemoryKB)/1048576.0)
+ // Wait time in seconds
+ waitSecs := now.Sub(item.EnqueueTime).Seconds()
+ // Starvation prevention: subtract weighted wait time (2.0 points/sec)
+ return base - 2.0*waitSecs
+ }
+ return score(pq[i]) < score(pq[j])
+}
+
+func (pq PriorityQueue) Swap(i, j int) {
+ pq[i], pq[j] = pq[j], pq[i]
+ pq[i].index = i
+ pq[j].index = j
+}
+
+func (pq *PriorityQueue) Push(x any) {
+ n := len(*pq)
+ item := x.(*QueueItem)
+ item.index = n
+ *pq = append(*pq, item)
+}
+
+func (pq *PriorityQueue) Pop() any {
+ old := *pq
+ n := len(old)
+ item := old[n-1]
+ old[n-1] = nil
+ item.index = -1
+ *pq = old[0 : n-1]
+ return item
+}
+
+// ConcurrencyPool manages concurrent job scheduling, queueing, and load shedding.
+type ConcurrencyPool struct {
+ mu sync.Mutex
+ pq PriorityQueue
+ maxActive int
+ maxQueue int
+ activeCount int
+ shedCount uint64
+
+ RateTracker *RateTracker
+ ExecutionTracker *ExecutionTracker
+}
+
+func NewConcurrencyPool(maxActive, maxQueue int) *ConcurrencyPool {
+ cp := &ConcurrencyPool{
+ maxActive: maxActive,
+ maxQueue: maxQueue,
+ RateTracker: NewRateTracker(10 * time.Second),
+ ExecutionTracker: NewExecutionTracker(100),
+ }
+ heap.Init(&cp.pq)
+ return cp
+}
+
+// Acquire blocks until an execution slot is free, respecting context cancellation.
+func (cp *ConcurrencyPool) Acquire(ctx context.Context, cost int64, memKB int) error {
+ cp.RateTracker.AddRequest()
+
+ cp.mu.Lock()
+ // Shed load if active + queue exceeds pool limit
+ if cp.activeCount+cp.pq.Len() >= cp.maxActive+cp.maxQueue {
+ cp.shedCount++
+ cp.mu.Unlock()
+ return ErrQueueFull
+ }
+
+ // Dispatch immediately if slots are open and nobody is queued
+ if cp.activeCount < cp.maxActive && cp.pq.Len() == 0 {
+ cp.activeCount++
+ cp.mu.Unlock()
+ return nil
+ }
+
+ // Enqueue
+ ch := make(chan struct{})
+ item := &QueueItem{
+ Ctx: ctx,
+ Cost: cost,
+ MemoryKB: memKB,
+ EnqueueTime: time.Now(),
+ Ch: ch,
+ }
+ heap.Push(&cp.pq, item)
+ cp.mu.Unlock()
+
+ // Wait for dispatch or client context cancellation
+ select {
+ case <-ch:
+ return nil
+ case <-ctx.Done():
+ cp.mu.Lock()
+ if item.index >= 0 {
+ heap.Remove(&cp.pq, item.index)
+ }
+ cp.mu.Unlock()
+ return ctx.Err()
+ }
+}
+
+// Release frees an execution slot and schedules the next job in queue.
+func (cp *ConcurrencyPool) Release() {
+ cp.mu.Lock()
+ defer cp.mu.Unlock()
+
+ cp.activeCount--
+ cp.scheduleNext()
+}
+
+func (cp *ConcurrencyPool) scheduleNext() {
+ // Re-initialize the heap to force re-sorting based on updated aging wait times
+ if cp.pq.Len() > 0 {
+ heap.Init(&cp.pq)
+ }
+
+ for cp.activeCount < cp.maxActive && cp.pq.Len() > 0 {
+ item := heap.Pop(&cp.pq).(*QueueItem)
+ if item.Ctx.Err() != nil {
+ continue // Already cancelled, skip
+ }
+ cp.activeCount++
+ close(item.Ch)
+ }
+}
+
+func (cp *ConcurrencyPool) GetStats() (active, queued int, shed uint64) {
+ cp.mu.Lock()
+ defer cp.mu.Unlock()
+ return cp.activeCount, cp.pq.Len(), cp.shedCount
+}
+
+func (cp *ConcurrencyPool) GetMaxActive() int {
+ return cp.maxActive
+}
+
+func (cp *ConcurrencyPool) GetMaxQueue() int {
+ return cp.maxQueue
+}
+
+// RateTracker computes the request rate (req/sec) over a sliding window.
+type RateTracker struct {
+ mu sync.Mutex
+ requests []time.Time
+ window time.Duration
+}
+
+func NewRateTracker(window time.Duration) *RateTracker {
+ return &RateTracker{
+ window: window,
+ }
+}
+
+func (rt *RateTracker) AddRequest() {
+ rt.mu.Lock()
+ defer rt.mu.Unlock()
+ rt.requests = append(rt.requests, time.Now())
+ rt.cleanup(time.Now())
+}
+
+func (rt *RateTracker) GetRate() float64 {
+ rt.mu.Lock()
+ defer rt.mu.Unlock()
+ now := time.Now()
+ rt.cleanup(now)
+ return float64(len(rt.requests)) / rt.window.Seconds()
+}
+
+func (rt *RateTracker) cleanup(now time.Time) {
+ cutoff := now.Add(-rt.window)
+ idx := 0
+ for i, t := range rt.requests {
+ if t.After(cutoff) {
+ idx = i
+ break
+ }
+ if i == len(rt.requests)-1 {
+ idx = len(rt.requests)
+ }
+ }
+ rt.requests = rt.requests[idx:]
+}
+
+// ExecutionTracker calculates a moving average duration of successful executions.
+type ExecutionTracker struct {
+ mu sync.RWMutex
+ durations []time.Duration
+ maxSize int
+}
+
+func NewExecutionTracker(maxSize int) *ExecutionTracker {
+ return &ExecutionTracker{
+ maxSize: maxSize,
+ }
+}
+
+func (et *ExecutionTracker) AddDuration(d time.Duration) {
+ et.mu.Lock()
+ defer et.mu.Unlock()
+ et.durations = append(et.durations, d)
+ if len(et.durations) > et.maxSize {
+ et.durations = et.durations[1:]
+ }
+}
+
+func (et *ExecutionTracker) GetAverage() time.Duration {
+ et.mu.RLock()
+ defer et.mu.RUnlock()
+ if len(et.durations) == 0 {
+ return 300 * time.Millisecond // Default fallback duration
+ }
+ var total time.Duration
+ for _, d := range et.durations {
+ total += d
+ }
+ return total / time.Duration(len(et.durations))
+}
diff --git a/internal/worker/pool_test.go b/internal/worker/pool_test.go
new file mode 100644
index 00000000..d810032f
--- /dev/null
+++ b/internal/worker/pool_test.go
@@ -0,0 +1,114 @@
+// internal/worker/pool_test.go
+package worker
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestRateTracker(t *testing.T) {
+ rt := NewRateTracker(1 * time.Second)
+ rt.AddRequest()
+ rt.AddRequest()
+
+ rate := rt.GetRate()
+ if rate != 2.0 {
+ t.Errorf("expected rate of 2.0, got %f", rate)
+ }
+
+ time.Sleep(1100 * time.Millisecond)
+ rate = rt.GetRate()
+ if rate != 0.0 {
+ t.Errorf("expected rate of 0.0 after expiration, got %f", rate)
+ }
+}
+
+func TestPoolPrioritySortingAndAging(t *testing.T) {
+ // 1 active, 5 queue size
+ cp := NewConcurrencyPool(1, 5)
+
+ // Block the pool with first job
+ ctx1 := context.Background()
+ err := cp.Acquire(ctx1, 10, 100)
+ if err != nil {
+ t.Fatalf("first acquire failed: %v", err)
+ }
+
+ // Enqueue a heavy job (cost=100, memory=500000)
+ ctx2 := context.Background()
+ ch2 := make(chan error, 1)
+ go func() {
+ ch2 <- cp.Acquire(ctx2, 100, 500000)
+ }()
+
+ // Enqueue a light job (cost=1, memory=100)
+ time.Sleep(50 * time.Millisecond) // Ensure clear enqueue order
+ ctx3 := context.Background()
+ ch3 := make(chan error, 1)
+ go func() {
+ ch3 <- cp.Acquire(ctx3, 1, 100)
+ }()
+
+ // Release first job. The light job (ch3) has lower cost and should run first!
+ time.Sleep(50 * time.Millisecond)
+ cp.Release()
+
+ select {
+ case err := <-ch3:
+ if err != nil {
+ t.Errorf("ch3 failed: %v", err)
+ }
+ case <-time.After(200 * time.Millisecond):
+ t.Error("timeout waiting for light job")
+ }
+
+ // Release second job. Now the heavy job (ch2) runs
+ cp.Release()
+
+ select {
+ case err := <-ch2:
+ if err != nil {
+ t.Errorf("ch2 failed: %v", err)
+ }
+ case <-time.After(200 * time.Millisecond):
+ t.Error("timeout waiting for heavy job")
+ }
+}
+
+func TestPoolQueueLimitsAndShedding(t *testing.T) {
+ // 1 active, 1 queue size
+ cp := NewConcurrencyPool(1, 1)
+
+ // Acquire active slot
+ err := cp.Acquire(context.Background(), 10, 100)
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+
+ // Enqueue one job (fills the queue of size 1)
+ ch2 := make(chan error, 1)
+ go func() {
+ ch2 <- cp.Acquire(context.Background(), 10, 100)
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+
+ // Try to enqueue a third job -> should immediately return ErrQueueFull
+ err = cp.Acquire(context.Background(), 10, 100)
+ if err != ErrQueueFull {
+ t.Errorf("expected ErrQueueFull, got %v", err)
+ }
+
+ // Release first, letting second job run
+ cp.Release()
+
+ select {
+ case err := <-ch2:
+ if err != nil {
+ t.Errorf("second job failed: %v", err)
+ }
+ case <-time.After(200 * time.Millisecond):
+ t.Error("timeout waiting for second job")
+ }
+}
diff --git a/scripts/loadtest.go b/scripts/loadtest.go
new file mode 100644
index 00000000..ec2cdd0b
--- /dev/null
+++ b/scripts/loadtest.go
@@ -0,0 +1,324 @@
+// scripts/loadtest.go
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "os"
+ "sort"
+ "sync"
+ "time"
+)
+
+type RunRequest struct {
+ Language string `json:"language"`
+ Source string `json:"source"`
+ SourceFilename string `json:"source_filename,omitempty"`
+ ArtifactFilename string `json:"artifact_filename,omitempty"`
+ Build *PhaseOpts `json:"build,omitempty"`
+ Run *PhaseOpts `json:"run,omitempty"`
+ Tests []TestCase `json:"tests"`
+}
+
+type PhaseOpts struct {
+ Limits *Limits `json:"limits,omitempty"`
+ Flags []string `json:"flags,omitempty"`
+}
+
+type Limits struct {
+ WallTimeS int `json:"wall_time_s,omitempty"`
+ MemoryKB int `json:"memory_kb,omitempty"`
+ MaxProcesses int `json:"max_processes,omitempty"`
+}
+
+type TestCase struct {
+ Stdin string `json:"stdin"`
+ ExpectedStdout string `json:"expected_stdout"`
+}
+
+type RunResponse struct {
+ Status string `json:"status"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+type PayloadConfig struct {
+ Language string
+ Source string
+ SourceFilename string
+ ArtifactFilename string
+ ExpectedStdout string
+}
+
+var payloads = map[string]PayloadConfig{
+ "py3": {
+ Language: "py3",
+ Source: "print('hello')",
+ ExpectedStdout: "hello\n",
+ },
+ "c": {
+ Language: "c",
+ Source: "#include \nint main() { printf(\"hello\\n\"); return 0; }",
+ ExpectedStdout: "hello\n",
+ },
+ "cpp": {
+ Language: "cpp",
+ Source: "#include \nint main() { std::cout << \"hello\\n\"; return 0; }",
+ ExpectedStdout: "hello\n",
+ },
+ "java": {
+ Language: "java",
+ Source: "public class Main { public static void main(String[] args) { System.out.println(\"hello\"); } }",
+ SourceFilename: "Main.java",
+ ArtifactFilename: "Main",
+ ExpectedStdout: "hello\n",
+ },
+ "bash": {
+ Language: "bash",
+ Source: "echo 'hello'",
+ ExpectedStdout: "hello\n",
+ },
+ "js": {
+ Language: "js",
+ Source: "console.log('hello')",
+ ExpectedStdout: "hello\n",
+ },
+ "php": {
+ Language: "php",
+ Source: " 0 {
+ warningsReceived += len(warnings)
+ }
+ mu.Unlock()
+
+ warningLog := ""
+ if len(warnings) > 0 {
+ warningLog = fmt.Sprintf(" | Warnings: %v", warnings)
+ }
+ fmt.Printf("[%d] Lang: %s | Code: %d | Status: %s | Latency: %v%s\n",
+ jobIdx, selectedLang, code, runStatus, reqDur, warningLog)
+ }
+ }()
+ }
+
+ wg.Wait()
+ totalDuration := time.Since(startTime)
+
+ if len(latencies) == 0 {
+ fmt.Println("No requests completed.")
+ return
+ }
+
+ sort.Slice(latencies, func(i, j int) bool {
+ return latencies[i] < latencies[j]
+ })
+
+ p50 := latencies[len(latencies)*50/100]
+ p95 := latencies[len(latencies)*95/100]
+ p99 := latencies[len(latencies)*99/100]
+
+ avg := time.Duration(0)
+ for _, l := range latencies {
+ avg += l
+ }
+ avg = avg / time.Duration(len(latencies))
+
+ rps := float64(*totalReqs) / totalDuration.Seconds()
+
+ fmt.Println("\n--- Results ---")
+ fmt.Printf("Total Time: %v\n", totalDuration)
+ fmt.Printf("Throughput: %.2f requests/sec\n", rps)
+ fmt.Printf("Average: %v\n", avg)
+ fmt.Printf("p50 (median): %v\n", p50)
+ fmt.Printf("p95: %v\n", p95)
+ fmt.Printf("p99: %v\n", p99)
+ fmt.Printf("Warnings Rec: %d\n", warningsReceived)
+
+ fmt.Println("\n--- HTTP Status Counts ---")
+ for code, count := range statusCounts {
+ statusText := http.StatusText(code)
+ if code == 999 {
+ statusText = "Connection Error"
+ }
+ fmt.Printf(" %d (%s): %d\n", code, statusText, count)
+ }
+
+ if len(runStatuses) > 0 {
+ fmt.Println("\n--- /run Outcomes ---")
+ for status, count := range runStatuses {
+ fmt.Printf(" %s: %d\n", status, count)
+ }
+ }
+}
+
diff --git a/scripts/memhog/memhog_loadtest.go b/scripts/memhog/memhog_loadtest.go
new file mode 100644
index 00000000..32f2681b
--- /dev/null
+++ b/scripts/memhog/memhog_loadtest.go
@@ -0,0 +1,487 @@
+// scripts/memhog/memhog_loadtest.go
+//
+// Open-loop, rate-stepped load generator for the MemoryHog Java benchmark.
+//
+// For each target request rate it holds an OPEN-LOOP attack for a fixed
+// duration (requests are issued on a fixed schedule regardless of whether
+// prior ones have completed — this is what makes "offered RPS" meaningful),
+// then records one CSV row. A request is a FAILURE if it returns a non-2xx
+// status OR exceeds the per-request timeout (default 10s) — matching the
+// challenge's definition. Between steps it drains the server queue so each
+// step starts from a clean slate.
+//
+// Output CSV schema (exact):
+// target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms
+//
+// Usage:
+// go run ./scripts/memhog \
+// -url http://localhost:8080/run -source docs/loadtest/MemoryHog.java \
+// -out docs/loadtest/results.csv
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// ---- request/response shapes (mirror the goboxd API) ----
+
+type runRequest struct {
+ Language string `json:"language"`
+ Source string `json:"source"`
+ SourceFilename string `json:"source_filename,omitempty"`
+ ArtifactFilename string `json:"artifact_filename,omitempty"`
+ Tests []testCase `json:"tests"`
+}
+
+type testCase struct {
+ Stdin string `json:"stdin"`
+ ExpectedStdout string `json:"expected_stdout"`
+}
+
+// ---- per-step result ----
+
+type stepResult struct {
+ targetRPS float64
+ throughput float64
+ durationS float64
+ requests int
+ success int
+ failed int
+ errorPct float64
+ p50, p95, p99, max float64 // milliseconds
+
+ // server-side work accounting (from /info jobs_total delta)
+ serverCompleted int // jobs the server actually finished this step
+ wasted int // finished but the client had already timed out (abandoned)
+
+ // extra signals
+ shed int // 503 load-shed responses
+ timeouts int // client-side >timeout failures
+ nonAccept int // HTTP 200 but run status != accepted
+}
+
+func main() {
+ url := flag.String("url", "http://localhost:8080/run", "POST /run endpoint")
+ infoURL := flag.String("info", "http://localhost:8080/info", "GET /info endpoint (queue drain + concurrency label)")
+ source := flag.String("source", "docs/loadtest/MemoryHog.java", "path to MemoryHog.java")
+ runsDir := flag.String("runs-dir", "docs/loadtest/runs", "directory for the per-run CSV + plots")
+ latest := flag.String("out", "docs/loadtest/results.csv", "canonical 'latest' CSV (latest plots are written beside it)")
+ label := flag.String("label", "", "run label for filenames; default auto = c from /info")
+ tag := flag.String("tag", "2vcpu-2gb", "extra tag baked into the run label")
+ ratesStr := flag.String("rates", "1,2,3,5,10,25,50,75,100,150,200,300,400", "comma-separated target RPS ladder")
+ duration := flag.Duration("duration", 30*time.Second, "hold time per step")
+ timeout := flag.Duration("timeout", 10*time.Second, "per-request timeout (>this counts as failed)")
+ stopAfterFail := flag.Int("stop-after-fail", 3, "stop this many steps after the first step that has a failure")
+ drainMax := flag.Duration("drain-max", 120*time.Second, "max time to wait for the queue to drain between steps")
+ expected := flag.String("expected", "MemoryHog OK mb=150 checksum=-101888\n", "expected stdout (only used to mark runs accepted; does not affect pass/fail)")
+ doPlot := flag.Bool("plot", true, "render plots after the run")
+ python := flag.String("python", "python3", "python interpreter with matplotlib (for plotting)")
+ plotScript := flag.String("plot-script", "docs/loadtest/plot.py", "matplotlib plot script")
+ flag.Parse()
+
+ srcBytes, err := os.ReadFile(*source)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "read source %q: %v\n", *source, err)
+ os.Exit(1)
+ }
+
+ body, err := json.Marshal(runRequest{
+ Language: "java",
+ Source: string(srcBytes),
+ SourceFilename: "MemoryHog.java",
+ ArtifactFilename: "MemoryHog",
+ Tests: []testCase{{Stdin: "", ExpectedStdout: *expected}},
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "marshal request: %v\n", err)
+ os.Exit(1)
+ }
+
+ rates, err := parseRates(*ratesStr)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "parse rates: %v\n", err)
+ os.Exit(1)
+ }
+
+ // A transport tuned for many concurrent short connections so the client
+ // itself is not the bottleneck.
+ tr := &http.Transport{
+ MaxIdleConns: 2000,
+ MaxIdleConnsPerHost: 2000,
+ MaxConnsPerHost: 0,
+ IdleConnTimeout: 30 * time.Second,
+ }
+ client := &http.Client{Timeout: *timeout, Transport: tr}
+
+ // Read the live concurrency limit so the run is auto-labelled (c) and the
+ // plot titles say which CONCURRENCY_LIMIT produced them.
+ maxConc, maxQueue := fetchLimits(client, *infoURL)
+ base := *label
+ if base == "" {
+ if maxConc > 0 {
+ base = fmt.Sprintf("memhog_c%d_%s", maxConc, *tag)
+ } else {
+ base = "memhog_" + *tag
+ }
+ }
+ // Timestamp keeps every run's files distinct (runs are sequential, never
+ // simultaneous), e.g. memhog_c8_2vcpu-2gb_20060102-150405.
+ runLabel := base + "_" + time.Now().Format("20060102-150405")
+ title := "2 vCPU / 2 GB"
+ if maxConc > 0 {
+ title = fmt.Sprintf("2 vCPU / 2 GB · CONCURRENCY_LIMIT=%d", maxConc)
+ }
+
+ if err := os.MkdirAll(*runsDir, 0o755); err != nil {
+ fmt.Fprintf(os.Stderr, "mkdir %q: %v\n", *runsDir, err)
+ os.Exit(1)
+ }
+ runCSV := filepath.Join(*runsDir, runLabel+".csv")
+
+ f, err := os.Create(runCSV)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "create %q: %v\n", runCSV, err)
+ os.Exit(1)
+ }
+ // First 11 columns are the challenge schema; the rest are extra work-accounting signals.
+ fmt.Fprintln(f, "target_rps,throughput_rps,duration_s,requests,success,failed,error_pct,p50_ms,p95_ms,p99_ms,max_ms,server_completed,wasted,wasted_pct,timeouts,shed_503")
+
+ fmt.Printf("MemoryHog load test → %s\n", *url)
+ fmt.Printf("run=%s concurrency_limit=%d max_queue=%d\n", runLabel, maxConc, maxQueue)
+ fmt.Printf("ladder=%v duration=%s timeout=%s stop_after_fail=%d\n\n", rates, *duration, *timeout, *stopAfterFail)
+
+ firstFailIdx := -1
+ breakingPoint := 0.0
+ for i, rate := range rates {
+ // Start each step from an empty queue so the measurement is clean.
+ drainQueue(client, *infoURL, *drainMax)
+ jobsBefore := fetchJobsTotal(client, *infoURL)
+
+ fmt.Printf("[step %d] offering %g rps for %s ...\n", i+1, rate, *duration)
+ res := attack(client, *url, body, rate, *duration, *timeout)
+
+ // Let any still-running (abandoned) jobs finish, then measure how much
+ // work the server actually completed vs what the client received.
+ drainQueue(client, *infoURL, *drainMax)
+ res.serverCompleted = fetchJobsTotal(client, *infoURL) - jobsBefore
+ res.wasted = res.serverCompleted - res.success
+ if res.wasted < 0 {
+ res.wasted = 0
+ }
+
+ writeRow(f, res)
+ f.Sync()
+ printStep(res)
+
+ if res.failed > 0 && firstFailIdx == -1 {
+ firstFailIdx = i
+ breakingPoint = rate
+ fmt.Printf(" >>> BREAKING POINT: first failure at %g rps\n", rate)
+ }
+ if firstFailIdx >= 0 && i-firstFailIdx >= *stopAfterFail {
+ fmt.Printf("\nStopped %d steps past the breaking point.\n", *stopAfterFail)
+ break
+ }
+ }
+ f.Close()
+
+ if firstFailIdx >= 0 {
+ fmt.Printf("\nBreaking point (offered RPS of first failure): %g\n", breakingPoint)
+ } else {
+ fmt.Printf("\nNo failures across the whole ladder — breaking point not reached.\n")
+ }
+
+ // Mirror this run to the canonical "latest" CSV for the top-level deliverable.
+ if err := copyFile(runCSV, *latest); err != nil {
+ fmt.Fprintf(os.Stderr, "warn: copy to %q failed: %v\n", *latest, err)
+ }
+ fmt.Printf("CSV: %s (latest copy: %s)\n", runCSV, *latest)
+
+ // Render plots: per-run (in runsDir, prefixed with the label) and the
+ // canonical "latest" pair beside the results CSV.
+ if *doPlot {
+ runPlot(*python, *plotScript, runCSV, *runsDir, runLabel, breakingPoint, title)
+ runPlot(*python, *plotScript, *latest, filepath.Dir(*latest), "", breakingPoint, title)
+ }
+}
+
+// fetchLimits reads the active concurrency limit and queue size from /info.
+func fetchLimits(client *http.Client, infoURL string) (maxConcurrent, maxQueue int) {
+ resp, err := client.Get(infoURL)
+ if err != nil {
+ return 0, 0
+ }
+ defer resp.Body.Close()
+ var info struct {
+ Limits struct {
+ MaxConcurrentJobs int `json:"max_concurrent_jobs"`
+ MaxQueueSize int `json:"max_queue_size"`
+ } `json:"limits"`
+ }
+ bb, _ := io.ReadAll(resp.Body)
+ _ = json.Unmarshal(bb, &info)
+ return info.Limits.MaxConcurrentJobs, info.Limits.MaxQueueSize
+}
+
+// fetchJobsTotal reads stats.jobs_total (count of server-completed runs) from /info.
+func fetchJobsTotal(client *http.Client, infoURL string) int {
+ resp, err := client.Get(infoURL)
+ if err != nil {
+ return 0
+ }
+ defer resp.Body.Close()
+ var info struct {
+ Stats struct {
+ JobsTotal int `json:"jobs_total"`
+ } `json:"stats"`
+ }
+ bb, _ := io.ReadAll(resp.Body)
+ _ = json.Unmarshal(bb, &info)
+ return info.Stats.JobsTotal
+}
+
+// copyFile copies src to dst (used to refresh the canonical latest CSV).
+func copyFile(src, dst string) error {
+ b, err := os.ReadFile(src)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(dst, b, 0o644)
+}
+
+// runPlot shells out to the matplotlib plot script. A plotting failure (e.g.
+// matplotlib not installed) is reported but does not fail the run — the CSV is
+// the critical artifact.
+func runPlot(python, script, csv, outdir, prefix string, breakingPoint float64, title string) {
+ args := []string{script, csv, "--outdir", outdir, "--title", title}
+ if prefix != "" {
+ args = append(args, "--prefix", prefix)
+ }
+ if breakingPoint > 0 {
+ args = append(args, "--breaking-point", strconv.FormatFloat(breakingPoint, 'g', -1, 64))
+ }
+ cmd := exec.Command(python, args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warn: plot (%s) failed: %v\n%s\n", python, err, out)
+ fmt.Fprintf(os.Stderr, " (install matplotlib, or run: python3 %s %s)\n", script, csv)
+ return
+ }
+ fmt.Print(string(out))
+}
+
+// attack issues requests at `rate` per second for `dur` (open-loop), then waits
+// for in-flight requests to finish (bounded by the client timeout).
+func attack(client *http.Client, url string, body []byte, rate float64, dur, timeout time.Duration) stepResult {
+ interval := time.Duration(float64(time.Second) / rate)
+
+ var (
+ mu sync.Mutex
+ lats []float64 // ms
+ wg sync.WaitGroup
+ success int64
+ failed int64
+ shed int64
+ timeouts int64
+ nonAccept int64
+ )
+
+ start := time.Now()
+ deadline := start.Add(dur)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ launch := func() {
+ defer wg.Done()
+ t0 := time.Now()
+ req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ lat := time.Since(t0).Seconds() * 1000.0
+
+ ok := false
+ if err != nil {
+ // Treat client timeout (and any transport error) as a failure.
+ if isTimeout(err) {
+ atomic.AddInt64(&timeouts, 1)
+ }
+ } else {
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ ok = true
+ // Peek at run status purely for insight (does not affect pass/fail).
+ var rr struct {
+ Status string `json:"status"`
+ }
+ bb, _ := io.ReadAll(resp.Body)
+ if json.Unmarshal(bb, &rr) == nil && rr.Status != "accepted" {
+ atomic.AddInt64(&nonAccept, 1)
+ }
+ } else {
+ if resp.StatusCode == http.StatusServiceUnavailable {
+ atomic.AddInt64(&shed, 1)
+ }
+ io.Copy(io.Discard, resp.Body)
+ }
+ resp.Body.Close()
+ }
+
+ if ok {
+ atomic.AddInt64(&success, 1)
+ } else {
+ atomic.AddInt64(&failed, 1)
+ }
+ mu.Lock()
+ lats = append(lats, lat)
+ mu.Unlock()
+ }
+
+ for now := range ticker.C {
+ if !now.Before(deadline) {
+ break
+ }
+ wg.Add(1)
+ go launch()
+ }
+ wg.Wait()
+ elapsed := time.Since(start).Seconds()
+
+ sort.Float64s(lats)
+ reqs := int(success + failed)
+ res := stepResult{
+ targetRPS: rate,
+ durationS: elapsed,
+ requests: reqs,
+ success: int(success),
+ failed: int(failed),
+ shed: int(shed),
+ timeouts: int(timeouts),
+ nonAccept: int(nonAccept),
+ p50: pct(lats, 50),
+ p95: pct(lats, 95),
+ p99: pct(lats, 99),
+ max: pct(lats, 100),
+ }
+ if elapsed > 0 {
+ res.throughput = float64(res.success) / elapsed
+ }
+ if reqs > 0 {
+ res.errorPct = float64(res.failed) / float64(reqs) * 100.0
+ }
+ return res
+}
+
+// drainQueue blocks until the server reports no in-flight and no queued jobs,
+// or until maxWait elapses, so the next step starts clean.
+func drainQueue(client *http.Client, infoURL string, maxWait time.Duration) {
+ type infoStats struct {
+ Stats struct {
+ InFlight int `json:"in_flight_jobs"`
+ Queued int `json:"queued_jobs"`
+ } `json:"stats"`
+ }
+ deadline := time.Now().Add(maxWait)
+ for time.Now().Before(deadline) {
+ resp, err := client.Get(infoURL)
+ if err != nil {
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+ var s infoStats
+ bb, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if json.Unmarshal(bb, &s) == nil && s.Stats.InFlight == 0 && s.Stats.Queued == 0 {
+ return
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+}
+
+func writeRow(w io.Writer, r stepResult) {
+ wastedPct := 0.0
+ if r.serverCompleted > 0 {
+ wastedPct = float64(r.wasted) / float64(r.serverCompleted) * 100.0
+ }
+ fmt.Fprintf(w, "%g,%.2f,%.1f,%d,%d,%d,%.2f,%.1f,%.1f,%.1f,%.1f,%d,%d,%.2f,%d,%d\n",
+ r.targetRPS, r.throughput, r.durationS, r.requests, r.success, r.failed,
+ r.errorPct, r.p50, r.p95, r.p99, r.max,
+ r.serverCompleted, r.wasted, wastedPct, r.timeouts, r.shed)
+}
+
+func printStep(r stepResult) {
+ fmt.Printf(" reqs=%d success=%d failed=%d err=%.1f%% thr=%.2f/s p50=%.0f p95=%.0f p99=%.0f max=%.0f ms\n",
+ r.requests, r.success, r.failed, r.errorPct, r.throughput, r.p50, r.p95, r.p99, r.max)
+ fmt.Printf(" server_completed=%d delivered=%d wasted=%d (work the server finished after the client gave up)\n",
+ r.serverCompleted, r.success, r.wasted)
+ if r.failed > 0 || r.nonAccept > 0 {
+ fmt.Printf(" failure modes: timeouts=%d shed_503=%d | non_accepted_200=%d\n", r.timeouts, r.shed, r.nonAccept)
+ }
+}
+
+// pct returns the nearest-rank percentile (p in [0,100]) from sorted vals (ms).
+func pct(sorted []float64, p float64) float64 {
+ n := len(sorted)
+ if n == 0 {
+ return 0
+ }
+ if p <= 0 {
+ return sorted[0]
+ }
+ if p >= 100 {
+ return sorted[n-1]
+ }
+ rank := int(math.Ceil(p/100.0*float64(n))) - 1
+ if rank < 0 {
+ rank = 0
+ }
+ if rank >= n {
+ rank = n - 1
+ }
+ return sorted[rank]
+}
+
+func parseRates(s string) ([]float64, error) {
+ var out []float64
+ for _, part := range strings.Split(s, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ v, err := strconv.ParseFloat(part, 64)
+ if err != nil {
+ return nil, fmt.Errorf("bad rate %q: %w", part, err)
+ }
+ if v <= 0 {
+ return nil, fmt.Errorf("rate must be positive, got %g", v)
+ }
+ out = append(out, v)
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("no rates given")
+ }
+ return out, nil
+}
+
+func isTimeout(err error) bool {
+ type timeout interface{ Timeout() bool }
+ if t, ok := err.(timeout); ok && t.Timeout() {
+ return true
+ }
+ // url.Error wraps the underlying error.
+ s := err.Error()
+ return strings.Contains(s, "Client.Timeout") || strings.Contains(s, "context deadline exceeded")
+}
diff --git a/stage2evalution/stage2evalution.md b/stage2evalution/stage2evalution.md
new file mode 100644
index 00000000..b46cc82f
--- /dev/null
+++ b/stage2evalution/stage2evalution.md
@@ -0,0 +1,45 @@
+# Payload Validation Report
+
+**Endpoint:** `http://localhost:8080/run`
+**Date:** 2026-06-12 14:27:19
+
+## Summary
+
+| Total | Passed | Failed | Skipped |
+|------:|-------:|-------:|--------:|
+| 13 | 10 | 3 | 0 |
+
+## Results
+
+| Status | Language | File | Expected | Got | Duration |
+|--------|----------|------|----------|-----|----------|
+| ✅ PASS | `php` | `accepted.json` | `accepted` | `accepted` | 118ms |
+| ✅ PASS | `php` | `wrong_output.json` | `wrong_output` | `wrong_output` | 14ms |
+| ✅ PASS | `php` | `runtime_error.json` | `runtime_error` | `runtime_error` | 14ms |
+| ✅ PASS | `php` | `time_exceeded.json` | `time_exceeded` | `time_exceeded` | 9.011s |
+| ❌ FAIL | `kotlin` | `accepted.json` | `accepted` | `runtime_error` | 1.671s |
+| ❌ FAIL | `kotlin` | `wrong_output.json` | `wrong_output` | `runtime_error` | 1.502s |
+| ✅ PASS | `kotlin` | `runtime_error.json` | `runtime_error` | `runtime_error` | 1.435s |
+| ❌ FAIL | `kotlin` | `time_exceeded.json` | `time_exceeded` | `runtime_error` | 1.388s |
+| ✅ PASS | `kotlin` | `build_failed.json` | `build_failed` | `build_failed` | 1.071s |
+| ✅ PASS | `lisp` | `accepted.json` | `accepted` | `accepted` | 50ms |
+| ✅ PASS | `lisp` | `wrong_output.json` | `wrong_output` | `wrong_output` | 7ms |
+| ✅ PASS | `lisp` | `runtime_error.json` | `runtime_error` | `runtime_error` | 18ms |
+| ✅ PASS | `lisp` | `time_exceeded.json` | `time_exceeded` | `time_exceeded` | 10.009s |
+
+## Failed Cases
+
+### `payloads/kotlin/accepted.json`
+
+- **Expected:** `accepted`
+- **Got:** `runtime_error`
+
+### `payloads/kotlin/wrong_output.json`
+
+- **Expected:** `wrong_output`
+- **Got:** `runtime_error`
+
+### `payloads/kotlin/time_exceeded.json`
+
+- **Expected:** `time_exceeded`
+- **Got:** `runtime_error`
diff --git a/tests/integration/run_test.go b/tests/integration/run_test.go
new file mode 100644
index 00000000..7c46c9a2
--- /dev/null
+++ b/tests/integration/run_test.go
@@ -0,0 +1,318 @@
+// tests/integration/run_test.go
+//go:build integration
+
+package integration_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/thesouldev/goboxd/internal/handler"
+ "github.com/thesouldev/goboxd/internal/languages"
+ "github.com/thesouldev/goboxd/internal/model"
+ "github.com/thesouldev/goboxd/internal/worker"
+)
+
+func setupServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ configPath := os.Getenv("LANGUAGE_CONFIG")
+ if configPath == "" {
+ if _, err := os.Stat("../../configs/languages/languages.yaml"); err == nil {
+ configPath = "../../configs/languages/languages.yaml"
+ } else {
+ configPath = "configs/languages/languages.yaml"
+ }
+ }
+ reg, err := languages.Load(configPath)
+ if err != nil {
+ t.Fatalf("load registry: %v", err)
+ }
+ mux := http.NewServeMux()
+ stats := &handler.ServerStats{}
+ pool := worker.NewConcurrencyPool(15, 500)
+ readyHandler := handler.NewReadyHandler(reg)
+ mux.Handle("GET /readyz", readyHandler)
+ mux.Handle("GET /info", handler.NewInfoHandler(reg, stats, readyHandler, pool))
+ mux.Handle("POST /run", &handler.RunHandler{Registry: reg, Stats: stats, Pool: pool})
+ return httptest.NewServer(mux)
+}
+
+func postRun(t *testing.T, srv *httptest.Server, req model.RunRequest) model.RunResponse {
+ t.Helper()
+ body, _ := json.Marshal(req)
+ resp, err := http.Post(srv.URL+"/run", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST /run: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ var result model.RunResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ return result
+}
+
+func TestPythonAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "py3",
+ Source: "print(input())",
+ Tests: []model.TestCase{
+ {Stdin: "hello\n", ExpectedStdout: "hello\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ var stderr string
+ if len(result.Tests) > 0 {
+ stderr = result.Tests[0].Stderr
+ }
+ t.Errorf("expected accepted, got %q (stderr: %q)", result.Status, stderr)
+ }
+ if len(result.Tests) > 0 && result.Tests[0].Status != "accepted" {
+ t.Errorf("test[0] expected accepted, got %q (stderr: %q)", result.Tests[0].Status, result.Tests[0].Stderr)
+ }
+}
+
+func TestPythonWrongOutput(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "py3",
+ Source: "print('wrong')",
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "right\n"},
+ },
+ })
+
+ if result.Status != "wrong_output" {
+ t.Errorf("expected wrong_output, got %q", result.Status)
+ }
+}
+
+func TestCAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "c",
+ Source: `#include
+int main() {
+ printf("hello\n");
+ return 0;
+}`,
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello\n"},
+ },
+ })
+
+ if result.Build == nil {
+ t.Fatal("expected build result for C")
+ }
+ if result.Build.Status != "ok" {
+ t.Errorf("build expected ok, got %q (stderr: %s)", result.Build.Status, result.Build.Stderr)
+ }
+ if result.Status != "accepted" {
+ var stderr string
+ if len(result.Tests) > 0 {
+ stderr = result.Tests[0].Stderr
+ }
+ t.Errorf("expected accepted, got %q (stderr: %q)", result.Status, stderr)
+ }
+}
+
+func TestCBuildFailed(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "c",
+ Source: `this is not valid C`,
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: ""},
+ },
+ })
+
+ if result.Status != "build_failed" {
+ t.Errorf("expected build_failed, got %q", result.Status)
+ }
+ if result.Tests[0].Status != "not_executed" {
+ t.Errorf("test[0] expected not_executed, got %q", result.Tests[0].Status)
+ }
+}
+
+func TestUnknownLanguage(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ body, _ := json.Marshal(model.RunRequest{
+ Language: "cobol",
+ Source: "x",
+ Tests: []model.TestCase{{Stdin: "", ExpectedStdout: ""}},
+ })
+ resp, _ := http.Post(srv.URL+"/run", "application/json", bytes.NewReader(body))
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestReadyzEndpoint(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/readyz")
+ if err != nil {
+ t.Fatalf("GET /readyz failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Inside tests (host has no nsjail unless inside Docker), readyz might be degraded or OK.
+ // But it must return a valid JSON response with status.
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusServiceUnavailable {
+ t.Errorf("expected 200 or 503, got %d", resp.StatusCode)
+ }
+
+ var data map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ t.Fatalf("decode readyz: %v", err)
+ }
+ if _, ok := data["status"]; !ok {
+ t.Error("missing status field in readyz response")
+ }
+}
+
+func TestInfoEndpoint(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/info")
+ if err != nil {
+ t.Fatalf("GET /info failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var data map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ t.Fatalf("decode info: %v", err)
+ }
+ if _, ok := data["nsjail"]; !ok {
+ t.Error("missing nsjail block in info response")
+ }
+ if _, ok := data["languages"]; !ok {
+ t.Error("missing languages list in info response")
+ }
+}
+
+func TestCppAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "cpp",
+ Source: `#include
+int main() {
+ std::cout << "hello C++" << std::endl;
+ return 0;
+}`,
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello C++\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ t.Errorf("expected accepted, got %q (build error: %v)", result.Status, result.Build)
+ }
+}
+
+func TestJavaAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "java",
+ Source: `public class Main {
+ public static void main(String[] args) {
+ System.out.println("hello Java");
+ }
+}`,
+ SourceFilename: "Main.java",
+ ArtifactFilename: "Main",
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello Java\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ t.Errorf("expected accepted, got %q (build error: %v)", result.Status, result.Build)
+ }
+}
+
+func TestBashAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "bash",
+ Source: "echo 'hello Bash'",
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello Bash\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ t.Errorf("expected accepted, got %q", result.Status)
+ }
+}
+
+func TestJsAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "js",
+ Source: "console.log('hello Node');",
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello Node\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ t.Errorf("expected accepted, got %q", result.Status)
+ }
+}
+
+func TestVerilogAccepted(t *testing.T) {
+ srv := setupServer(t)
+ defer srv.Close()
+
+ result := postRun(t, srv, model.RunRequest{
+ Language: "verilog",
+ Source: `module Main;
+ initial begin
+ $display("hello Verilog");
+ $finish;
+ end
+endmodule`,
+ Tests: []model.TestCase{
+ {Stdin: "", ExpectedStdout: "hello Verilog\n"},
+ },
+ })
+
+ if result.Status != "accepted" {
+ t.Errorf("expected accepted, got %q (build error: %v)", result.Status, result.Build)
+ }
+}