feat(materialize): worker supervisor with per-OS memory cap (TASK-2198 U3) (#1682)

* feat(materialize): parent-side worker supervisor with per-OS memory cap (TASK-2198 U3)

The Supervisor runs materializer jobs in ONE long-lived child process
(`pad __materialize-worker`), started lazily on the first job and fed
jobs serially. A job past PAD_MATERIALIZE_TIMEOUT (default 2s) kills the
child (goja's Interrupt, sent as timeout_ms a tenth earlier, is only the
soft layer); a dead child is respawned on the next job after an
exponential backoff (1s doubling, capped at 5 min, reset by a child that
lived a minute). Every stop is logged with its reason (deadline, memory,
exit, protocol, start, canceled, closed); every start logs the effective
timeout, memory limit and cap mechanism. The child's stderr goes to the
log, pid-tagged and bounded, never near the protocol. A job whose child
died is never retried here.

Memory cap (PAD_MATERIALIZE_MEM_LIMIT, default 2GiB, bytes or
KiB/MiB/GiB, clamped to 1536MiB-16GiB):
- Linux: RLIMIT_AS set on the child by prlimit before its first byte of
  input; the child only runs its own startup uncapped.
- Windows: Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY; the child is
  created suspended, assigned, then resumed: no uncapped window.
- macOS: parent-side RSS watchdog every 100ms (proc_info syscall, ps
  fallback).
- Other: deadline kill only.

Not wired into the server yet (U4).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* ci(materialize): run the supervisor on the real macOS and Windows caps (TASK-2198 U3)

native-smoke now builds the real materializer bundle (as the go job does)
and runs the supervisor, memory-cap and deadline tests on macos-latest
(proc_info RSS watchdog) and windows-latest (suspended start + Job
Object). PAD_MATERIALIZE_TEST_NO_SKIP turns the cap tests' skips into
failures there, so a cap that stops running cannot pass as skipped.

Tests: a real Windows processGone (OpenProcess + GetExitCodeProcess),
RSS samplers for darwin (the production proc_info path) and Windows
(K32GetProcessMemoryInfo), and an allocation helper that writes noise
into every byte so macOS memory compression cannot hide it from the
watchdog.

The worker-loop tests share one loaded Runner through serveWorker
(RunWorker's loop, split out); TestWorkerRoundTrip still goes through
RunWorker end to end. go test -race -count=3 on the package: 714s -> 413s.

docs: the materializer section says the worker and its knobs are inert
until U4 wires it in.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): a worker never outlives its supervising process (TASK-2198 U3, codex r1)

If the server died mid-job (SIGKILL, OOM kill, crash) its worker ran on
with no deadline kill and, on macOS, no memory watchdog. Two layers:

- Linux: Pdeathsig = SIGKILL. The fork runs on a fresh goroutine
  (startUnlocked), never locked to its thread, so it cannot land on a
  thread the runtime later retires (golang/go#27505); Go's forkExec
  re-checks getppid if the parent is already gone.
- Every Unix (the only layer on macOS): the worker command calls
  ExitWhenOrphaned before the bundle loads, polling its parent pid every
  200ms against PAD_MATERIALIZE_PARENT_PID and exiting (status 70) when
  it changes. It writes nothing.
- Windows was already covered by the Job Object's KILL_ON_JOB_CLOSE.

TestSupervisorOrphanedWorkerExits kills a helper process that supervises a
worker hung in a job and requires the worker gone within 1s: 0s on Linux,
105ms with the ppid watch alone; with both layers removed the worker
outlives its parent by 5s. It matches native-smoke's TestSupervisor.*
selection, so it runs on macOS and Windows too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): relative memory cap, refuse when uncappable, GOMAXPROCS=2; nix-safe ps test (TASK-2198 U3)

The absolute Linux RLIMIT_AS failed on CI: a 64 MiB allocation died under a
1600 MiB cap, because the runtime there reserved more address space than on
the machine the floor was measured on. The cap is now RELATIVE on every OS:
once the worker has loaded and before it is sent any job, its usage is read
as a baseline and the cap is set to baseline + PAD_MATERIALIZE_MEM_LIMIT —
address space on Linux (/proc/<pid>/stat vsize, prlimit, read back),
committed memory on Windows (the Job Object's per-process limit, set after
the suspended start and load), resident memory on macOS (the watchdog's
threshold). Every start logs baseline, limit and effective cap.

Lead ruling, REFUSE: if the baseline probe fails or is implausible (0, or
above MaxPlausibleBaseline = 8 GiB, ~4.8x the largest measured 1704 MiB),
onBaselineProbeFailure kills the worker without sending it the job and
fails the job with ErrNoMemoryCap; one WARN until a probe succeeds again.
An OS with no cap mechanism never starts a worker and refuses every job
with ErrNoMemoryCap, one WARN naming runtime.GOOS.

The worker runs with GOMAXPROCS=2: measured loaded-worker baseline
1357 MiB vs 1597 MiB at this host's default (8) and 1704 MiB at 64.
MinMemLimit drops from 1536 MiB (an artefact of the absolute cap) to
256 MiB: the corpus grows the worker by at most +10 MiB of address space and
+18 MiB resident, and a 4,000-deep nested document by +129..194 MiB.
A cgo worker's "pthread_create failed" at the cap is now classified as a
memory death.

Nix: TestParsePSRSS parses fixed strings only; the live `ps` leg is
TestPSRSSLive and skips when there is no `ps` (the Nix sandbox has none;
ps is only macOS's fallback), unless PAD_MATERIALIZE_TEST_NO_SKIP is set.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Windows kills on the job's memory notification; steady thread set before the baseline (TASK-2198 U3)

Windows (CI on c1866957): the Job Object refused the 1 GiB commit
(VirtualAlloc errno=1455), but the worker did not die and the job ran to its
deadline. The cap no longer depends on the runtime choosing to crash: the
job is associated with an I/O completion port, and
JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills it at once
(ErrMemoryLimit). errno=1455 on stderr is a second signal. On every OS a
cap mechanism that sees the limit hit calls onLimit, and the kill is
reported as "reached its <mechanism> cap".

Linux (CI's cgo test binary at GOMAXPROCS=64): threads created after the
baseline ate the growth budget. The worker now brings its thread set to
steady state before readiness (warmThreads: an allocating goroutine per P,
then a GC). Measured on a cgo build: threads added after the baseline are 0
at GOMAXPROCS=2 with or without it; at 64, +3 threads and +272 MiB of
address space during the corpus without it, +0 and +0 MiB with it. 256 MiB
stays the floor (largest growth at GOMAXPROCS=2: +193 MiB, a 4,000-deep
nested document; the corpus adds +0 MiB).

The cap test's second leg no longer uses GOMAXPROCS=64 (which also changed
thread creation) to stand in for a bigger host: the helper inflates its
own baseline before readiness, with never-touched address space on Linux,
committed memory on Windows and resident memory on macOS.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* feat(materialize): the worker stops after an idle timeout; the next job restarts it (TASK-2198 U3)

Dave's ruling on the worker process: GO, with an idle exit. A loaded worker
holds ~200 MB resident, which an install that rarely materializes should
not pay for all day.

PAD_MATERIALIZE_IDLE_TIMEOUT: default 5m, clamped to 30s-24h, 0 = never.
After each job the supervisor (re)arms a timer. When it fires, the stop
runs only while holding the job slot, so it never takes a worker with a job
in flight. It re-checks the idle time, forgets the worker WITHOUT recording
a death (backoff is untouched), and closes the worker's stdin so its read
loop ends with status 0, with a kill after a grace if it does not go. The
stop is logged at INFO, reason=idle. A job that arrives meanwhile waits for
the slot, finds no worker, and starts a new one with no backoff wait.

Legs: the idle exit after the timeout (INFO, status 0, no WARN); the next
job respawns and succeeds with no wait; an idle stop leaves the backoff
streak alone (crash, idle, crash waits 2s, not 4s); a job arriving during
the stop runs on a new worker; a timer that fires mid-job does not stop that
worker; NeverIdle arms no timer; env parsing and clamps. Mutations, each red
on its own: recording a death (3 legs), logging as a kill (1), not holding
the slot (1), never arming (3). Docs: the knob beside the other two, and
`idle` in the stop reasons.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Close does not return while an idle-stopping worker still runs (TASK-2198 U3, codex r1)

An idle stop forgets the worker before it has exited, so a Close in that
window found no worker and returned with it still alive. The supervisor now
tracks the worker an idle stop is ending, and Close kills and reaps it. A
leg pins it, and goes red without the fix.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): an idle stop that Close ended says so (TASK-2198 U3, codex r2)

The idle stop stored its kill reason unconditionally, so it overwrote the
'closed' of a Close that had already killed the worker, and its stop line
said idle. It now claims the reason only if none is set, and the line
reports reason=closed. The Close-during-idle-stop leg asserts it.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
This commit is contained in:
xarmian
2026-09-30 09:52:49 -04:00
committed by GitHub
parent a61f50421f
commit d78ff9bdc5
37 changed files with 3769 additions and 9 deletions
+37 -6
View File
@@ -201,7 +201,10 @@ jobs:
native-smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
# 10 for the smoke itself, plus the materializer bundle build and the
# supervisor tests (TASK-2198 U3), which spawn real workers and compile
# the package's test binary.
timeout-minutes: 35
defaults:
run:
shell: pwsh
@@ -240,14 +243,24 @@ jobs:
go-version-file: go.mod
- name: Create web build placeholder
# The materializer bundle (TASK-2198) is stubbed here too: this job
# builds and smoke-tests the binary, and nothing it runs loads the
# bundle. internal/materialize.New refuses a placeholder by name.
run: |
New-Item -ItemType Directory -Force web/build | Out-Null
Set-Content web/build/index.html '<!doctype html>'
New-Item -ItemType Directory -Force web/build-materializer | Out-Null
Set-Content web/build-materializer/materializer.js '// placeholder'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# The materializer bundle (TASK-2198) is built for real here, as in the
# go job, and not stubbed: the supervisor tests at the end of this job
# spawn real workers that load it, and internal/materialize.New refuses
# a placeholder by name. Only the bundle, not the whole web build (the
# go job's shape): web/build stays the placeholder above.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Build binary
run: go build -o "${{ matrix.binary }}" ./cmd/pad
@@ -349,6 +362,24 @@ jobs:
}
$global:LASTEXITCODE = 0
# The memory-cap tests below allocate 2 GiB against a 1600 MiB cap, and
# the adversarial one drives a real worker to a 2 GiB cap. Logged so a
# failure there can be read against what the runner actually had.
- name: Runner memory
run: |
if ($IsMacOS) { sysctl hw.memsize hw.ncpu } else { Get-CimInstance Win32_ComputerSystem | Select-Object TotalPhysicalMemory, NumberOfLogicalProcessors | Format-List }
# The materializer supervisor (TASK-2198 U3) on each OS's REAL memory
# cap, which only these runners have: macOS's parent-side RSS watchdog
# (proc_info) and Windows' Job Object with a suspended start. The Go job
# covers Linux's RLIMIT_AS. PAD_MATERIALIZE_TEST_NO_SKIP turns the cap
# tests' skips into failures, so a cap that stopped running cannot pass
# as "skipped".
- name: Materializer supervisor and memory cap (TASK-2198 U3)
env:
PAD_MATERIALIZE_TEST_NO_SKIP: "1"
run: go test ./internal/materialize/ -run '^(TestSupervisor.*|TestParseMemLimit|TestConfigFromEnv|TestParsePSRSS|TestPSRSSLive|TestParseStatVsize|TestStderrOOMMarkers|TestDeepNestFrameShallow)$' -count=1 -timeout 20m -v
go-postgres:
# Backstop only. A hung TEST is caught by -timeout=45m on the steps
# below, which fails fast AND prints the goroutine dump that tells
+3
View File
@@ -26,6 +26,9 @@ func materializeWorkerCmd() *cobra.Command {
SilenceUsage: true,
SilenceErrors: false,
RunE: func(cmd *cobra.Command, args []string) error {
// Before the bundle loads: a worker whose server died must not
// run on without its deadline kill and memory watchdog.
materialize.ExitWhenOrphaned()
return materialize.RunWorker(os.Stdin, os.Stdout, pad.MaterializerJS)
},
}
+66
View File
@@ -1222,6 +1222,72 @@ address inside `PAD_TRUSTED_PROXIES` (the proxy sends no `X-Forwarded-For` or
- **The host check does not replace TLS.** It closes DNS rebinding; it does not
protect tokens in transit. That is why http is token-only.
### Op-log materializer worker
**Not active yet.** The worker and the two variables below take effect once
the materializer is wired into the server (TASK-2198 U4); until then they are
inert, and setting them changes nothing.
When a browser tab closes without saving, its last edits are only in the
item's collaborative op-log. Pad can turn that op-log back into the item's
markdown on the server by running the editor's own JavaScript. It does this in
a **separate worker process** (the same `pad` binary, started as
`pad __materialize-worker`), never inside the server: a crafted update can
make that JavaScript use gigabytes of memory, and in a separate process that
kills one worker instead of the server.
- The worker starts on the first job, not at server start, and then stays up
while jobs keep coming (loading it takes a few seconds). Jobs run one at a
time.
- A job that runs past the timeout is failed and the worker is killed. The
next job starts a new one.
- If the server process dies, its worker exits too, so it never runs on
without its time and memory limits. On Linux it exits at once; on macOS
within about 200ms; on Windows at once.
- A worker with no job for the idle timeout (5 minutes by default) is stopped
cleanly, because a loaded worker holds about 200 MB of resident memory for as
long as it runs. The next job starts a new one, with no wait. An idle stop is
not a death and does not count toward the restart wait below.
- A worker that dies is restarted on the next job, waiting 1s after the first
death and doubling after each further death, up to 5 minutes. A worker that
stayed up for a minute resets the wait.
- Every worker start logs the timeout, the worker's measured memory
baseline, the memory limit and the resulting cap, and every stop logs why
(`deadline`, `memory`, `exit`, `protocol`, `start`, `canceled`, `closed`,
or `idle`, which is logged at INFO rather than as a warning). The worker's
stderr goes to the server log, one line per record, tagged with the worker's
pid.
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_MATERIALIZE_TIMEOUT` | `2s` | Per-job time limit, in Go duration syntax (`2s`, `1500ms`). Clamped to 250ms–60s. |
| `PAD_MATERIALIZE_IDLE_TIMEOUT` | `5m` | How long the worker may sit with no job before it is stopped (it holds about 200 MB resident while it runs), in Go duration syntax. `0` means never stop it. Clamped to 30s–24h. |
| `PAD_MATERIALIZE_MEM_LIMIT` | `2GiB` | How much memory one job may add to the loaded worker (see below): a whole number of bytes, or a whole number followed by `KiB`, `MiB` or `GiB` with no space (`2048MiB`, `2GiB`, `2147483648`). Decimal units such as `GB` are refused. Clamped to 256MiB–16GiB. |
A value that cannot be parsed is ignored with a warning, and the default is
used. A value outside the range is clamped with a warning.
The memory limit is **growth beyond the loaded worker**. Once the worker has
loaded, and before it is given any job, Pad measures its memory use (the
baseline) and caps it at baseline + limit. What "memory" means depends on the
OS, and the three are not the same measure:
- **Linux:** virtual **address space** (`RLIMIT_AS`, set on the worker process
only). A loaded worker already holds about 1.4 GB of address space, more on
hosts with many CPUs; the limit is added on top of whatever it holds.
- **macOS:** **resident memory** (RSS), watched by Pad every 100ms, because
macOS does not enforce `RLIMIT_AS`. A worker can go over the cap for up to
one check interval before it is stopped.
- **Windows:** the worker's **committed memory**, as a Job Object limit. The
worker is started suspended and put in the job before it runs.
Linux, macOS and Windows are the supported platforms. **If no memory cap can
be established** (any other OS, or the baseline measurement failing or
reading an implausible value), Pad does not materialize at all: no worker is
given a job, one warning is logged, and items keep today's behaviour until a
worker can be capped. That is safe, because materialization is a recovery
enhancement; the server does not need it to run.
### Email (Optional)
Email enables sending workspace invitation links. Without it, users can still join via CLI invite codes.
+123
View File
@@ -0,0 +1,123 @@
package materialize
import (
"context"
"errors"
"os"
"strconv"
"testing"
"time"
)
func putVarUint(b []byte, n uint64) []byte {
for n >= 0x80 {
b = append(b, byte(n)|0x80)
n >>= 7
}
return append(b, byte(n))
}
func putVarString(b []byte, s string) []byte {
b = putVarUint(b, uint64(len(s)))
return append(b, s...)
}
// deepNestFrame builds one y-protocols Update frame whose Yjs update (v1
// encoding) nests depth XmlElements named node, each the only child of the
// one before, under the 'default' fragment the editor binds. The update is
// tiny — about 12 bytes per level — and what it costs to materialize is not:
// every level is a recursion in the Yjs → ProseMirror conversion and in the
// markdown serializer, and nested blockquotes repeat their "> " prefix on
// every line, so the output is quadratic in depth.
func deepNestFrame(depth int, node string) []byte {
const client = 7
var u []byte
u = putVarUint(u, 1) // one client's structs
u = putVarUint(u, uint64(depth+3))
u = putVarUint(u, client)
u = putVarUint(u, 0) // first clock
parent := func(i int) {
if i == 0 {
u = putVarUint(u, 1) // parent is a root type, named:
u = putVarString(u, "default")
return
}
u = putVarUint(u, 0) // parent is the item (client, i-1)
u = putVarUint(u, client)
u = putVarUint(u, uint64(i-1))
}
element := func(i int, name string) {
u = append(u, 7) // info: ContentType, no origins, no parentSub
parent(i)
u = putVarUint(u, 3) // YXmlElement
u = putVarString(u, name)
}
for i := 0; i < depth; i++ {
element(i, node)
}
// Innermost: a paragraph holding the text "x", so the tree is valid
// content all the way down and nothing is dropped as empty.
element(depth, "paragraph")
u = append(u, 7) // ContentType
parent(depth + 1)
u = putVarUint(u, 6) // YXmlText
u = append(u, 4) // info: ContentString
parent(depth + 2)
u = putVarString(u, "x")
u = putVarUint(u, 0) // empty delete set
f := []byte{0, 2} // MESSAGE_SYNC, messageYjsUpdate
f = putVarUint(f, uint64(len(u)))
return append(f, u...)
}
// The generator is well-formed: a shallow nest materializes to what the
// editor makes of it.
func TestDeepNestFrameShallow(t *testing.T) {
r := runner(t)
md, err := r.Materialize(context.Background(), Job{Rows: [][]byte{deepNestFrame(3, "blockquote")}, SchemaVersion: r.SchemaVersion()})
if err != nil {
t.Fatal(err)
}
if md != "> > > x" {
t.Fatalf("3 nested blockquotes: %q, want %q", md, "> > > x")
}
}
// TestSupervisorAdversarialDeepNesting sends the spike's shape — a ~1 MB
// deep-nesting update — to a REAL capped worker. In-process this is the input
// that drove Go past 5 GB into its fatal out-of-memory. Here the worker must
// fail the job (killed on its cap, or on the deadline), the parent must be
// untouched, and the next job must succeed on a fresh worker.
func TestSupervisorAdversarialDeepNesting(t *testing.T) {
if raceEnabled {
skipCapTest(t, "the race runtime cannot start under an address-space cap")
}
if testing.Short() {
t.Skip("spawns a real worker and drives it to its cap")
}
depth := 60_000 // a 1,063,536-byte frame: the spike's ~1 MB
if v := os.Getenv("PAD_TEST_NEST_DEPTH"); v != "" {
depth, _ = strconv.Atoi(v)
}
frame := deepNestFrame(depth, "blockquote")
h := newHarness(t, "worker", func(c *SupervisorConfig) {
c.MemLimit = 1 << 30
c.Timeout = 30 * time.Second
c.capOverride = nil
})
r := runner(t)
start := time.Now()
md, err := h.s.Materialize(context.Background(), Job{Rows: [][]byte{frame}, SchemaVersion: r.SchemaVersion()})
t.Logf("depth %d, frame %d bytes: %d bytes of markdown, err %v, after %s", depth, len(frame), len(md), err, time.Since(start).Round(time.Millisecond))
if !errors.Is(err, ErrMemoryLimit) && !errors.Is(err, ErrDeadline) && !errors.Is(err, ErrChildDied) {
t.Fatalf("got %d bytes, %v; want the worker to fail the job", len(md), err)
}
for _, l := range h.log.find("materialize worker stopped") {
t.Log(l)
}
ok := loadCorpus(t)[0]
got, err := h.s.Materialize(context.Background(), ok.job(t, r.SchemaVersion()))
if err != nil || got != ok.Expected {
t.Fatalf("job after the adversarial one: %q, %v", got, err)
}
}
@@ -0,0 +1,30 @@
package materialize
import "syscall"
var inflated []byte
// inflateBaseline raises the worker's macOS cap measure — resident memory —
// by mib: an anonymous mapping, every page written with noise (macOS
// compresses idle pages of zeros out of the resident set).
func inflateBaseline(mib int) error {
b, err := syscall.Mmap(-1, 0, mib<<20, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
if err != nil {
return err
}
x := uint64(0x9E3779B97F4A7C15)
for i := 0; i+8 <= len(b); i += 8 {
x ^= x << 13
x ^= x >> 7
x ^= x << 17
for k := 0; k < 8; k++ {
b[i+k] = byte(x >> (8 * k))
}
}
inflated = b
return nil
}
// inflateMiB is how much the big-baseline leg inflates on this OS (resident,
// on a 7.5 GB runner, beside the leg's own 1 GiB allocation).
const inflateMiB = 512
@@ -0,0 +1,17 @@
package materialize
import "syscall"
var inflated []byte
// inflateBaseline raises the worker's Linux cap measure — address space —
// by mib without touching it or creating threads: one PROT_NONE,
// MAP_NORESERVE mapping, never touched.
func inflateBaseline(mib int) error {
b, err := syscall.Mmap(-1, 0, mib<<20, syscall.PROT_NONE, syscall.MAP_PRIVATE|syscall.MAP_ANON|syscall.MAP_NORESERVE)
inflated = b
return err
}
// inflateMiB is how much the big-baseline leg inflates on this OS.
const inflateMiB = 1536
@@ -0,0 +1,7 @@
//go:build !linux && !darwin && !windows
package materialize
func inflateBaseline(int) error { return nil }
const inflateMiB = 0
@@ -0,0 +1,14 @@
package materialize
import "golang.org/x/sys/windows"
// inflateBaseline raises the worker's Windows cap measure — committed memory
// — by mib: one committed, never-touched region (commit charge counts it
// whether or not it is touched).
func inflateBaseline(mib int) error {
_, err := windows.VirtualAlloc(0, uintptr(mib)<<20, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE)
return err
}
// inflateMiB is how much the big-baseline leg inflates on this OS.
const inflateMiB = 1536
@@ -0,0 +1,88 @@
package materialize
import (
"context"
"os"
"strconv"
"strings"
"testing"
"time"
)
// procThreads reads the Threads: count of /proc/<pid>/status.
func procThreads(t *testing.T, pid int) int {
t.Helper()
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
if err != nil {
t.Fatal(err)
}
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Threads:") {
n, _ := strconv.Atoi(strings.Fields(l)[1])
return n
}
}
return -1
}
// procStatusKB reads one "Name: N kB" line of /proc/<pid>/status.
func procStatusKB(t *testing.T, pid int, name string) uint64 {
t.Helper()
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
if err != nil {
t.Fatal(err)
}
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, name+":") {
n, _ := strconv.ParseUint(strings.Fields(l)[1], 10, 64)
return n << 10
}
}
t.Fatalf("no %s", name)
return 0
}
// TestSupervisorBaselineMeasurements records, for a REAL loaded worker, its
// address-space baseline and how much address space and resident memory the
// corpus and a moderate adversarial document add — the evidence behind
// MinMemLimit and MaxPlausibleBaseline. Run with PAD_MATERIALIZE_MEASURE=1.
func TestSupervisorBaselineMeasurements(t *testing.T) {
if os.Getenv("PAD_MATERIALIZE_MEASURE") == "" {
t.Skip("measurement; set PAD_MATERIALIZE_MEASURE=1")
}
r := runner(t)
cases := loadCorpus(t)
for _, procs := range []string{"2", "8", "64"} {
var pid int
h := newHarness(t, "worker", func(c *SupervisorConfig) {
c.Timeout = 60 * time.Second
c.capOverride = fakeCap
c.extraEnv = []string{"GOMAXPROCS=" + procs}
c.onSpawn = func(p int) { pid = p }
})
if _, err := h.s.Materialize(context.Background(), Job{SchemaVersion: r.SchemaVersion()}); err != nil {
t.Fatal(err)
}
vsz0, rss0 := procStatusKB(t, pid, "VmSize"), procStatusKB(t, pid, "VmRSS")
th0 := procThreads(t, pid)
for _, tc := range cases {
if _, err := h.s.Materialize(context.Background(), tc.job(t, r.SchemaVersion())); err != nil {
t.Fatal(err)
}
}
vszC, rssC := procStatusKB(t, pid, "VmPeak"), procStatusKB(t, pid, "VmHWM")
line := "GOMAXPROCS=" + procs + ": baseline VmSize " + formatBytes(vsz0) + " (" + strconv.FormatUint(vsz0>>20, 10) + " MiB), VmRSS " + strconv.FormatUint(rss0>>20, 10) + " MiB; after " + strconv.Itoa(len(cases)) + " corpus cases: +" + strconv.FormatUint((vszC-vsz0)>>20, 10) + " MiB address space (peak), +" + strconv.FormatInt((int64(rssC)-int64(rss0))>>20, 10) + " MiB resident (peak)"
for _, depth := range []int{1000, 2000, 4000} {
_, err := h.s.Materialize(context.Background(), Job{Rows: [][]byte{deepNestFrame(depth, "blockquote")}, SchemaVersion: r.SchemaVersion()})
if err != nil {
line += "; depth " + strconv.Itoa(depth) + ": " + err.Error()
break
}
line += "; depth " + strconv.Itoa(depth) + ": +" + strconv.FormatUint((procStatusKB(t, pid, "VmPeak")-vsz0)>>20, 10) + " MiB AS, +" + strconv.FormatInt((int64(procStatusKB(t, pid, "VmHWM"))-int64(rss0))>>20, 10) + " MiB RSS"
}
th1 := procThreads(t, pid)
line += "; threads " + strconv.Itoa(th0) + " at baseline -> " + strconv.Itoa(th1) + " after all jobs (+" + strconv.Itoa(th1-th0) + ")"
t.Log(line)
_ = h.s.Close()
}
}
+106
View File
@@ -0,0 +1,106 @@
package materialize
import (
"errors"
"log/slog"
"strconv"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/unix"
)
// macOS: RLIMIT_AS is accepted but not enforced, so the cap is a parent-side
// watchdog. Once the worker has loaded its bundle and before it is sent any
// job, the parent reads its resident set size as the BASELINE; from then on,
// every 100 ms, it kills the worker when its resident set exceeds baseline +
// limit (ErrMemoryLimit). What it measures is RESIDENT
// memory, where Linux caps address space and Windows committed memory.
//
// The window is the poll interval: a child can allocate for up to 100 ms
// past the cap before it is seen. An allocation fast enough to exhaust the
// machine inside 100 ms is not stopped; the kill still ends it.
//
// The read is proc_info(PROC_INFO_CALL_PIDINFO, pid, PROC_PIDTASKINFO) — the
// syscall behind libproc's proc_pidinfo — with no fork. If it fails, the
// watchdog falls back to `ps -o rss= -p <pid>` (a fork+exec per poll) and
// says so once in the log.
var platformMemCap = memCapImpl{
mechanism: "rss_watchdog",
rss: darwinRSS,
}
const (
procInfoCallPidinfo = 2
procPidTaskInfo = 4
)
// procTaskInfo is struct proc_taskinfo (sys/proc_info.h), 96 bytes.
type procTaskInfo struct {
VirtualSize uint64
ResidentSize uint64
TotalUser uint64
TotalSystem uint64
ThreadsUser uint64
ThreadsSystem uint64
Policy int32
Faults int32
Pageins int32
CowFaults int32
MessagesSent int32
MessagesReceived int32
SyscallsMach int32
SyscallsUnix int32
Csw int32
Threadnum int32
Numrunning int32
Priority int32
}
var (
psFallbackOnce sync.Once
useSyscall = true
useSyscallMu sync.Mutex
)
func darwinRSS(pid int) (uint64, error) {
useSyscallMu.Lock()
sys := useSyscall
useSyscallMu.Unlock()
if sys {
n, err := procPidRSS(pid)
if err == nil {
return n, nil
}
if errors.Is(err, syscall.ESRCH) {
return 0, err // the child is gone; not a reason to fall back
}
useSyscallMu.Lock()
useSyscall = false
useSyscallMu.Unlock()
psFallbackOnce.Do(func() {
logPSFallback(err)
})
}
return psRSS(pid)
}
func procPidRSS(pid int) (uint64, error) {
var ti procTaskInfo
size := unsafe.Sizeof(ti)
r1, _, errno := syscall.Syscall6(unix.SYS_PROC_INFO, procInfoCallPidinfo, uintptr(pid), procPidTaskInfo, 0,
uintptr(unsafe.Pointer(&ti)), size)
if errno != 0 {
return 0, errno
}
if r1 != size {
return 0, errors.New("proc_info: short PROC_PIDTASKINFO read of " + strconv.Itoa(int(r1)) + " bytes")
}
return ti.ResidentSize, nil
}
func logPSFallback(err error) {
slog.Warn("materialize: proc_info unavailable; the memory watchdog falls back to ps (a fork per 100 ms poll)",
"error", err)
}
+63
View File
@@ -0,0 +1,63 @@
package materialize
import (
"fmt"
"os"
"os/exec"
"strconv"
"golang.org/x/sys/unix"
)
// Linux: RLIMIT_AS on the CHILD, RELATIVE to the loaded worker. Once the
// worker has loaded its bundle (the readiness probe has answered) and before
// it is sent any job, the parent reads the worker's address-space size
// (/proc/<pid>/stat vsize) and sets RLIMIT_AS to that baseline plus the
// limit with prlimit(2), then reads it back. The limit therefore bounds how
// much ADDRESS SPACE a job may add to the loaded worker, whatever the host's
// runtime reserved before it (an absolute cap measured on one machine failed
// a 64 MiB allocation on CI runners, whose baseline was larger).
//
// Not setrlimit in the parent before exec: that would cap the server. Not the
// child capping itself: the parent could not verify it.
//
// The window: the worker's own startup and bundle load run uncapped. That is
// trusted code; the worker holds no job until the cap is set and confirmed.
// If the baseline cannot be read, the job is refused (ErrNoMemoryCap).
//
// A breach fails the allocation and the Go runtime dies with "fatal error:
// runtime: out of memory", which the stderr log recognises.
var platformMemCap = memCapImpl{
mechanism: "rlimit_as",
attach: func(cmd *exec.Cmd, _ func()) (*capHandle, error) {
pid := cmd.Process.Pid
return &capHandle{
baseline: func() (uint64, error) { return procVsize(pid) },
set: func(capBytes uint64) error {
lim := unix.Rlimit{Cur: capBytes, Max: capBytes}
if err := unix.Prlimit(pid, unix.RLIMIT_AS, &lim, nil); err != nil {
return err
}
var got unix.Rlimit
if err := unix.Prlimit(pid, unix.RLIMIT_AS, nil, &got); err != nil {
return err
}
if got.Cur != capBytes {
return fmt.Errorf("RLIMIT_AS reads %d after setting %d", got.Cur, capBytes)
}
return nil
},
}, nil
},
}
// procVsize reads a process's virtual memory size in bytes: field 23 of
// /proc/<pid>/stat, counted after the parenthesised command name (which may
// itself hold spaces or parentheses).
func procVsize(pid int) (uint64, error) {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return 0, err
}
return parseStatVsize(string(b))
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !linux && !windows && !darwin
package materialize
// Any other OS: no way to cap the worker, so the Supervisor never starts one
// and refuses every job with ErrNoMemoryCap (one WARN per Supervisor).
var platformMemCap = memCapImpl{mechanism: "none", unsupported: true}
+34
View File
@@ -0,0 +1,34 @@
//go:build darwin || linux
package materialize
import (
"errors"
"os/exec"
"strconv"
"strings"
)
// psRSS reads a process's resident set size with `ps -o rss= -p <pid>`, the
// macOS watchdog's fallback when proc_info is unavailable. Built on Linux too
// so its parser and cost are tested where the tests run.
func psRSS(pid int) (uint64, error) {
out, err := exec.Command("ps", "-o", "rss=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return 0, err
}
return parsePSRSS(out)
}
// parsePSRSS reads ps's rss column: KiB, one number, padded.
func parsePSRSS(out []byte) (uint64, error) {
s := strings.TrimSpace(string(out))
if s == "" {
return 0, errors.New("ps: no such process")
}
kib, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return kib << 10, nil
}
+50
View File
@@ -0,0 +1,50 @@
//go:build darwin || linux
package materialize
import (
"os"
"os/exec"
"testing"
)
// The parser alone: fixed strings, nothing executed, so it runs everywhere
// including the Nix build sandbox.
func TestParsePSRSS(t *testing.T) {
if n, err := parsePSRSS([]byte(" 123456\n")); err != nil || n != 123456<<10 {
t.Fatalf("%d %v", n, err)
}
for _, bad := range []string{"", "\n", "abc", "12 34"} {
if _, err := parsePSRSS([]byte(bad)); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
// TestPSRSSLive runs the real `ps` fallback against this process. It skips
// when there is no `ps` to run (the Nix build sandbox has none), which is
// acceptable: `ps` is only the macOS watchdog's FALLBACK, the production path
// is proc_info. PAD_MATERIALIZE_TEST_NO_SKIP still turns the skip into a
// failure where CI sets it.
func TestPSRSSLive(t *testing.T) {
if _, err := exec.LookPath("ps"); err != nil {
skipCapTest(t, "no `ps` on PATH (the Nix build sandbox has none): "+err.Error())
}
n, err := psRSS(os.Getpid())
if err != nil || n == 0 {
t.Fatalf("ps on self: %d %v", n, err)
}
}
// BenchmarkPSRSS is the cost of one poll by the macOS watchdog's ps fallback.
func BenchmarkPSRSS(b *testing.B) {
if _, err := exec.LookPath("ps"); err != nil {
b.Skip("no `ps` on PATH: " + err.Error())
}
pid := os.Getpid()
for b.Loop() {
if _, err := psRSS(pid); err != nil {
b.Fatal(err)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package materialize
import (
"fmt"
"strconv"
"strings"
)
// parseStatVsize extracts vsize (field 23) from a /proc/<pid>/stat line.
// Platform-neutral so it is tested everywhere.
func parseStatVsize(stat string) (uint64, error) {
i := strings.LastIndexByte(stat, ')')
if i < 0 {
return 0, fmt.Errorf("stat: no command name in %q", stat)
}
f := strings.Fields(stat[i+1:])
// f[0] is field 3 (state), so field 23 is f[20].
if len(f) < 21 {
return 0, fmt.Errorf("stat: %d fields after the command name", len(f))
}
return strconv.ParseUint(f[20], 10, 64)
}
+205
View File
@@ -0,0 +1,205 @@
package materialize
import (
"fmt"
"math"
"os/exec"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// Windows: a Job Object. The worker is created SUSPENDED
// (CREATE_SUSPENDED), assigned to a job with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
// (so it dies with the server), and only then resumed: it never runs outside
// the job. Once it has loaded its bundle and before it is sent any job, the
// parent reads its COMMITTED memory (the commit charge,
// PROCESS_MEMORY_COUNTERS.PagefileUsage) and sets
// JOB_OBJECT_LIMIT_PROCESS_MEMORY to that baseline plus the limit — the same
// quantity the job limit measures. The bundle load itself runs without the
// memory limit; it is trusted and holds no job.
//
// os/exec does not hand back the primary thread's handle, so the thread is
// found by a toolhelp snapshot of the (brand new, single-threaded) process
// and resumed by id. Any failure on the way kills the still-suspended child.
//
// A breach fails the allocation (VirtualAlloc, errno=1455). The Go runtime
// does NOT reliably die of that — CI saw a worker whose 1 GiB allocation was
// refused sit until the job deadline — so the supervisor does not wait for
// it: the job is associated with an I/O completion port, and
// JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills the worker
// at once and fails the job with ErrMemoryLimit. The runtime's own "out of
// memory" / errno=1455 on stderr remains a second signal.
var platformMemCap = memCapImpl{
mechanism: "job_object",
prepare: func(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED
},
attach: attachJobObject,
}
type processMemoryCounters struct {
Cb uint32
PageFaultCount uint32
PeakWorkingSetSize uintptr
WorkingSetSize uintptr
QuotaPeakPagedPoolUsage uintptr
QuotaPagedPoolUsage uintptr
QuotaPeakNonPagedPoolUsage uintptr
QuotaNonPagedPoolUsage uintptr
PagefileUsage uintptr
PeakPagefileUsage uintptr
}
var procK32GetProcessMemoryInfo = windows.NewLazySystemDLL("kernel32.dll").NewProc("K32GetProcessMemoryInfo")
// Job-object notification messages (winnt.h) and the association struct,
// which x/sys/windows does not define.
const (
jobObjectMsgProcessMemoryLimit = 9
jobObjectMsgJobMemoryLimit = 10
)
type jobObjectAssociateCompletionPort struct {
CompletionKey uintptr
CompletionPort windows.Handle
}
func attachJobObject(cmd *exec.Cmd, onLimit func()) (h *capHandle, err error) {
pid := uint32(cmd.Process.Pid)
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, fmt.Errorf("CreateJobObject: %w", err)
}
var proc, port windows.Handle
defer func() {
if err != nil {
if proc != 0 {
windows.CloseHandle(proc)
}
if port != 0 {
windows.CloseHandle(port)
}
windows.CloseHandle(job)
}
}()
if err = setJobLimits(job, 0); err != nil {
return nil, err
}
port, err = windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 1)
if err != nil {
return nil, fmt.Errorf("CreateIoCompletionPort: %w", err)
}
assoc := jobObjectAssociateCompletionPort{CompletionKey: uintptr(job), CompletionPort: port}
if _, err = windows.SetInformationJobObject(job, windows.JobObjectAssociateCompletionPortInformation,
uintptr(unsafe.Pointer(&assoc)), uint32(unsafe.Sizeof(assoc))); err != nil {
return nil, fmt.Errorf("associate completion port: %w", err)
}
proc, err = windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
return nil, fmt.Errorf("OpenProcess: %w", err)
}
if err = windows.AssignProcessToJobObject(job, proc); err != nil {
return nil, fmt.Errorf("AssignProcessToJobObject: %w", err)
}
if err = resumeProcess(pid); err != nil {
return nil, err
}
go watchJobPort(port, pid, onLimit)
return &capHandle{
baseline: func() (uint64, error) {
var c processMemoryCounters
c.Cb = uint32(unsafe.Sizeof(c))
r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(proc), uintptr(unsafe.Pointer(&c)), uintptr(c.Cb))
if r == 0 {
return 0, fmt.Errorf("K32GetProcessMemoryInfo: %w", e)
}
return uint64(c.PagefileUsage), nil
},
set: func(capBytes uint64) error { return setJobLimits(job, capBytes) },
release: func() {
windows.CloseHandle(port) // ends watchJobPort
windows.CloseHandle(proc)
windows.CloseHandle(job)
},
}, nil
}
// setJobLimits sets KILL_ON_JOB_CLOSE, plus a per-process committed-memory
// limit when limit > 0.
func setJobLimits(job windows.Handle, limit uint64) error {
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if limit > 0 {
if limit > uint64(math.MaxUint) {
limit = uint64(math.MaxUint) // a 32-bit build: the most it can express
}
info.BasicLimitInformation.LimitFlags |= windows.JOB_OBJECT_LIMIT_PROCESS_MEMORY
info.ProcessMemoryLimit = uintptr(limit)
}
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
return fmt.Errorf("SetInformationJobObject: %w", err)
}
return nil
}
// resumeProcess resumes every thread of pid (a CREATE_SUSPENDED process has
// exactly one).
func resumeProcess(pid uint32) error {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return fmt.Errorf("CreateToolhelp32Snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var te windows.ThreadEntry32
te.Size = uint32(unsafe.Sizeof(te))
resumed := 0
for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) {
if te.OwnerProcessID != pid {
continue
}
th, oerr := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID)
if oerr != nil {
return fmt.Errorf("OpenThread: %w", oerr)
}
_, rerr := windows.ResumeThread(th)
windows.CloseHandle(th)
if rerr != nil {
return fmt.Errorf("ResumeThread: %w", rerr)
}
resumed++
}
if resumed == 0 {
return fmt.Errorf("no thread of process %d to resume", pid)
}
return nil
}
// watchJobPort reads the job's notifications until the port is closed and
// calls onLimit when the worker (pid) hits its memory limit. For job
// messages the OVERLAPPED pointer carries the process id.
func watchJobPort(port windows.Handle, pid uint32, onLimit func()) {
for {
var msg uint32
var key uintptr
var ov *windows.Overlapped
if err := windows.GetQueuedCompletionStatus(port, &msg, &key, &ov, windows.INFINITE); err != nil {
if ov == nil {
return // the port was closed (ERROR_ABANDONED_WAIT_0) or failed
}
continue
}
switch msg {
case jobObjectMsgProcessMemoryLimit, jobObjectMsgJobMemoryLimit:
if uint32(uintptr(unsafe.Pointer(ov))) == pid {
onLimit()
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package materialize
import (
"os"
"os/exec"
"strconv"
)
// EnvParentPID carries the supervising process's pid to the worker, so the
// worker's orphan watch (ExitWhenOrphaned) knows which parent it must keep.
const EnvParentPID = "PAD_MATERIALIZE_PARENT_PID"
// guardAgainstOrphaning prepares cmd so the worker cannot outlive the
// supervising process. If the server dies mid-job (SIGKILL, the OOM killer,
// a crash), no deadline kill and no RSS watchdog will ever reach the worker
// again, so it would run on uncapped (macOS) or unbounded in time.
//
// - Every Unix: the worker polls its parent pid (ExitWhenOrphaned) against
// the pid passed here, and exits when it changes. The only layer on
// macOS.
// - Linux, in addition: Pdeathsig (setParentDeathSignal), so the kernel
// sends SIGKILL at once.
// - Windows: the Job Object's KILL_ON_JOB_CLOSE (memcap_windows.go).
func guardAgainstOrphaning(cmd *exec.Cmd) {
env := cmd.Env
if env == nil {
env = os.Environ()
}
cmd.Env = append(env, EnvParentPID+"="+strconv.Itoa(os.Getpid()))
setParentDeathSignal(cmd)
}
+26
View File
@@ -0,0 +1,26 @@
package materialize
import (
"os/exec"
"syscall"
)
// setParentDeathSignal asks the kernel to SIGKILL the worker when its parent
// dies.
//
// The Go caveat (golang/go#27505): PR_SET_PDEATHSIG fires when the THREAD
// that forked the child exits, not the process, and Go may run the fork on
// any thread. The runtime never retires an idle thread; the one case where it
// ends a thread is a goroutine that exits while locked to it
// (runtime.LockOSThread). spawn therefore runs cmd.Start on a FRESH goroutine
// (startUnlocked), which is never locked whatever its caller is, and a thread
// that is running a locked goroutine runs nothing else, so the fork cannot
// land on a thread that will be retired. And if the parent is already gone by
// the time the child sets the flag, Go's forkExec re-checks getppid and
// signals the child itself.
func setParentDeathSignal(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package materialize
import "os/exec"
// setParentDeathSignal: Linux only; see orphan_linux.go.
func setParentDeathSignal(*exec.Cmd) {}
@@ -0,0 +1,7 @@
//go:build !unix
package materialize
// ExitWhenOrphaned: nothing to do on Windows, where the Job Object's
// KILL_ON_JOB_CLOSE ends the worker with its parent (memcap_windows.go).
func ExitWhenOrphaned() {}
+40
View File
@@ -0,0 +1,40 @@
//go:build unix
package materialize
import (
"os"
"strconv"
"time"
)
// OrphanPollInterval is how often the worker checks its parent.
const OrphanPollInterval = 200 * time.Millisecond
// ExitWhenOrphaned starts a goroutine that exits the process (status 70)
// as soon as its parent pid is no longer the supervising process: the parent
// died and the worker was reparented (to init, launchd or a subreaper). The
// expected parent is EnvParentPID when set, else the parent at the time of
// the call, so a worker whose parent died before this ran still exits.
//
// Called first thing by the worker command, before the bundle loads. It
// writes nothing: stdout is the protocol channel, and nobody reads stderr
// once the parent is gone.
func ExitWhenOrphaned() {
want := os.Getppid()
if v, err := strconv.Atoi(os.Getenv(EnvParentPID)); err == nil && v > 0 {
want = v
}
check := func() {
if os.Getppid() != want {
os.Exit(70)
}
}
check()
go func() {
for {
time.Sleep(OrphanPollInterval)
check()
}
}()
}
@@ -0,0 +1,5 @@
//go:build !unix && !windows
package materialize
func processGone(pid int) error { return nil }
@@ -0,0 +1,27 @@
//go:build unix
package materialize
import (
"errors"
"fmt"
"os"
"strings"
"syscall"
)
// processGone reports nil once pid no longer exists. The supervisor reaps
// what it kills; an ORPHAN is reaped by whoever adopted it, so a zombie
// (Linux /proc state Z) also counts as gone: it runs nothing.
func processGone(pid int) error {
err := syscall.Kill(pid, 0)
if errors.Is(err, syscall.ESRCH) {
return nil
}
if b, rerr := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); rerr == nil {
if i := strings.LastIndexByte(string(b), ')'); i >= 0 && strings.HasPrefix(strings.TrimSpace(string(b[i+1:])), "Z") {
return nil
}
}
return fmt.Errorf("pid %d still exists (kill 0: %v)", pid, err)
}
@@ -0,0 +1,32 @@
package materialize
import (
"errors"
"fmt"
"golang.org/x/sys/windows"
)
// processGone reports nil once pid no longer names a live process. The
// supervisor waits on (and so closes its handle to) every child it kills, so
// a killed worker leaves no process object behind; one that is somehow still
// open must at least have exited.
func processGone(pid int) error {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return nil // no such process
}
return fmt.Errorf("OpenProcess(%d): %w", pid, err)
}
defer windows.CloseHandle(h)
var code uint32
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return fmt.Errorf("GetExitCodeProcess(%d): %w", pid, err)
}
const stillActive = 259
if code == stillActive {
return fmt.Errorf("pid %d is still running", pid)
}
return nil
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !race
package materialize
const raceEnabled = false
+5
View File
@@ -0,0 +1,5 @@
//go:build race
package materialize
const raceEnabled = true
+160
View File
@@ -0,0 +1,160 @@
package materialize
import (
"context"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
)
// probeCap is a cap whose baseline probe answers what the test says.
func probeCap(probe func() (uint64, error)) *memCapImpl {
return &memCapImpl{
mechanism: "probe(test)",
attach: func(*exec.Cmd, func()) (*capHandle, error) {
return &capHandle{baseline: probe, set: func(uint64) error { return nil }}, nil
},
}
}
func journal(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
t.Fatal(err)
}
return strings.Fields(string(b))
}
// A worker whose memory baseline cannot be established is killed WITHOUT
// being sent a job, and the job fails with ErrNoMemoryCap. One WARN names the
// cause across any number of such refusals; each refused job's successor
// starts a new worker and probes again, and a working probe ends the refusals.
func TestSupervisorBaselineProbeFailureRefuses(t *testing.T) {
for name, bad := range map[string]func() (uint64, error){
"unreadable": func() (uint64, error) { return 0, errors.New("probe exploded") },
"zero": func() (uint64, error) { return 0, nil },
"implausible": func() (uint64, error) { return MaxPlausibleBaseline + 1, nil },
} {
t.Run(name, func(t *testing.T) {
var broken atomic.Bool
broken.Store(true)
jpath := filepath.Join(t.TempDir(), "journal")
h := newHarness(t, "script", func(c *SupervisorConfig) {
c.capOverride = probeCap(func() (uint64, error) {
if broken.Load() {
return bad()
}
return 100 << 20, nil
})
c.extraEnv = []string{journalEnv + "=" + jpath}
})
const n = 4
for i := range n {
_, err := h.s.Materialize(context.Background(), script("echo:refused"))
if got := journal(t, jpath); len(got) != 0 {
t.Fatalf("job %d reached an uncapped worker: %q", i, got)
}
if !errors.Is(err, ErrNoMemoryCap) {
t.Fatalf("job %d: got %v, want ErrNoMemoryCap", i, err)
}
if err := processGone(h.lastPid(t)); err != nil {
t.Fatalf("job %d: the uncapped worker was left running: %v", i, err)
}
}
if got := h.f.calls.Load(); got != n {
t.Fatalf("%d spawns for %d refused jobs; each must start and probe a new worker", got, n)
}
if got := journal(t, jpath); len(got) != 0 {
t.Fatalf("jobs reached an uncapped worker: %q", got)
}
warns := h.log.warns()
if len(warns) != 1 || !strings.Contains(warns[0], "could not establish") || !strings.Contains(warns[0], "cause=") {
t.Fatalf("want exactly one WARN naming the cause across %d refusals, got %d:\n%s", n, len(warns), strings.Join(warns, "\n"))
}
t.Log(warns[0])
// The probe works again: the next start re-probes and the job runs.
broken.Store(false)
if md, err := h.s.Materialize(context.Background(), script("echo:ok")); err != nil || md != "echo:ok" {
t.Fatalf("after the probe recovered: %q, %v", md, err)
}
if got := journal(t, jpath); len(got) != 1 || got[0] != "echo:ok" {
t.Fatalf("journal %q, want only the job after recovery", got)
}
// A failure after a success is news again: one more WARN.
broken.Store(true)
h.s.mu.Lock()
c := h.s.child
h.s.mu.Unlock()
if c != nil {
c.kill("test")
c.awaitExit()
h.s.stoppedQuiet(c)
}
if _, err := h.s.Materialize(context.Background(), script("echo:refused")); !errors.Is(err, ErrNoMemoryCap) {
t.Fatalf("after re-breaking: %v", err)
}
if got := len(h.log.warns()); got != 2 {
t.Fatalf("%d WARNs; a failure after a success must warn again", got)
}
})
}
}
// On an OS with no cap mechanism, no worker is ever started and every job is
// refused, with one WARN naming the OS.
func TestSupervisorUnsupportedOSRefuses(t *testing.T) {
h := newHarness(t, "script", func(c *SupervisorConfig) {
c.capOverride = &memCapImpl{mechanism: "none", unsupported: true}
})
for i := range 3 {
if _, err := h.s.Materialize(context.Background(), script("echo:x")); !errors.Is(err, ErrNoMemoryCap) {
t.Fatalf("job %d: %v", i, err)
}
}
if n := h.f.calls.Load(); n != 0 {
t.Fatalf("%d workers started on an OS with no cap", n)
}
warns := h.log.warns()
if len(warns) != 1 || !strings.Contains(warns[0], "goos="+runtime.GOOS) {
t.Fatalf("want one WARN naming the OS, got %q", warns)
}
}
func TestParseStatVsize(t *testing.T) {
line := "1234 (a (weird) name) S 1 1234 1234 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 7 0 555 1423151104 18000 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0"
if n, err := parseStatVsize(line); err != nil || n != 1423151104 {
t.Fatalf("%d %v", n, err)
}
for _, bad := range []string{"", "1234 no parens", "1 (x) S 1 2"} {
if _, err := parseStatVsize(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestStderrOOMMarkers(t *testing.T) {
for line, want := range map[string]bool{
"fatal error: runtime: out of memory": true,
"runtime: mmap: cannot allocate memory": true,
"runtime/cgo: pthread_create failed: Resource temporarily unavailable": true,
"helper: exiting on purpose": false,
"panic: boom": false,
} {
l := &stderrLog{log: slog.New(&logSink{})}
_, _ = l.Write([]byte(line + "\n"))
if l.oom.Load() != want {
t.Errorf("%q: oom=%v, want %v", line, l.oom.Load(), want)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package materialize
// testRSSSampler on macOS IS the production watchdog's sampler (proc_info
// PROC_PIDTASKINFO, ps fallback).
var testRSSSampler = darwinRSS
+26
View File
@@ -0,0 +1,26 @@
package materialize
import (
"fmt"
"os"
"strconv"
"strings"
)
// testRSSSampler reads /proc/<pid>/statm's resident pages, so the macOS
// watchdog mechanism can be exercised on Linux.
var testRSSSampler = func(pid int) (uint64, error) {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/statm", pid))
if err != nil {
return 0, err
}
f := strings.Fields(string(b))
if len(f) < 2 {
return 0, fmt.Errorf("statm: %q", b)
}
pages, err := strconv.ParseUint(f[1], 10, 64)
if err != nil {
return 0, err
}
return pages * uint64(os.Getpagesize()), nil
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !linux && !darwin && !windows
package materialize
var testRSSSampler func(pid int) (uint64, error)
+25
View File
@@ -0,0 +1,25 @@
package materialize
import (
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
// testRSSSampler reads the working set, so the watchdog mechanism (macOS's
// cap) is exercised on Windows too. Windows' own cap is the Job Object.
var testRSSSampler = func(pid int) (uint64, error) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, uint32(pid))
if err != nil {
return 0, err
}
defer windows.CloseHandle(h)
var c processMemoryCounters
c.Cb = uint32(unsafe.Sizeof(c))
r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&c)), uintptr(c.Cb))
if r == 0 {
return 0, fmt.Errorf("K32GetProcessMemoryInfo: %w", e)
}
return uint64(c.WorkingSetSize), nil
}
+19
View File
@@ -0,0 +1,19 @@
package materialize
import (
"os"
"testing"
)
// noSkipEnv, set by CI's native-smoke job on macOS and Windows, turns the
// memory-cap tests' skips into failures, so a platform cap that silently
// stopped running cannot pass as "skipped".
const noSkipEnv = "PAD_MATERIALIZE_TEST_NO_SKIP"
func skipCapTest(t *testing.T, why string) {
t.Helper()
if os.Getenv(noSkipEnv) != "" {
t.Fatalf("%s is set, but this test would skip: %s", noSkipEnv, why)
}
t.Skip(why)
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
package materialize
import (
"fmt"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"time"
)
// Environment knobs for the materializer worker (TASK-2198). Documented in
// docs/deployment.md, "Op-log materializer worker".
const (
// EnvTimeout is the per-job hard deadline, in Go duration syntax
// ("2s", "1500ms"). Past it the worker process is KILLED.
EnvTimeout = "PAD_MATERIALIZE_TIMEOUT"
// EnvMemLimit is how much memory one job may add to the loaded worker
// (the cap is baseline + limit; what "memory" is differs per OS, see
// memCapImpl): a whole number of bytes, or a whole number followed by a
// BINARY unit, KiB, MiB or GiB ("2048MiB", "2GiB"). Decimal units (MB,
// GB) are refused rather than guessed at.
EnvMemLimit = "PAD_MATERIALIZE_MEM_LIMIT"
// EnvIdleTimeout is how long the worker may sit with no job before it is
// stopped, in Go duration syntax ("5m"). "0" means never. A loaded worker
// holds ~200 MB resident, which an install that rarely materializes should
// not pay for all day; the next job starts a new one, as the first did.
EnvIdleTimeout = "PAD_MATERIALIZE_IDLE_TIMEOUT"
)
const (
DefaultTimeout = 2 * time.Second
MinTimeout = 250 * time.Millisecond
MaxTimeout = 60 * time.Second
DefaultMemLimit = 2 << 30 // 2 GiB
// MinMemLimit: the limit is GROWTH beyond the loaded worker (see
// memCapImpl), and the measured growth of real work is small
// (TestSupervisorBaselineMeasurements, real worker, cgo build,
// GOMAXPROCS=2 as the supervisor sets it): the 50-case corpus adds
// +0 MiB of address space and +24 MiB resident, and a 4,000-deep nested
// document — pathological, not a real item — at most +193 MiB of address
// space. Threads created after the baseline: 0 in every run at
// GOMAXPROCS=2 (warmThreads makes sure of it at higher counts too). 256 MiB
// lets all of that through. The old 1536 MiB floor belonged to the
// absolute cap, which had to clear the runtime's own reservation.
MinMemLimit = 256 << 20
MaxMemLimit = 16 << 30
DefaultIdleTimeout = 5 * time.Minute
// MinIdleTimeout keeps a busy install from paying the ~2.6 s bundle load
// between jobs that arrive seconds apart.
MinIdleTimeout = 30 * time.Second
MaxIdleTimeout = 24 * time.Hour
// NeverIdle, as SupervisorConfig.IdleTimeout, keeps the worker until Close
// (EnvIdleTimeout "0"). The zero value is the default, as for the other
// fields.
NeverIdle time.Duration = -1
)
var memLimitRE = regexp.MustCompile(`^([0-9]+)(KiB|MiB|GiB)?$`)
// ParseMemLimit reads the EnvMemLimit syntax: a whole number of bytes, or a
// whole number with a KiB, MiB or GiB suffix (case-sensitive, no space).
func ParseMemLimit(s string) (uint64, error) {
m := memLimitRE.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0, fmt.Errorf("%q is not a byte count or a whole number of KiB, MiB or GiB", s)
}
n, err := strconv.ParseUint(m[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("%q: %w", s, err)
}
shift := map[string]uint{"": 0, "KiB": 10, "MiB": 20, "GiB": 30}[m[2]]
if n > math.MaxUint64>>shift {
return 0, fmt.Errorf("%q overflows", s)
}
return n << shift, nil
}
// ConfigFromEnv reads EnvTimeout, EnvMemLimit and EnvIdleTimeout through getenv (os.Getenv in
// production). An unset variable takes the default; an unparseable one takes
// the default and is logged as a warning. Out-of-range values are clamped by
// NewSupervisor, not here, so a config built by hand gets the same clamp.
func ConfigFromEnv(getenv func(string) string, logger *slog.Logger) SupervisorConfig {
if logger == nil {
logger = slog.Default()
}
cfg := SupervisorConfig{Logger: logger}
if v := getenv(EnvTimeout); v != "" {
d, err := time.ParseDuration(strings.TrimSpace(v))
if err != nil {
logger.Warn("materialize: ignoring invalid "+EnvTimeout+"; using the default",
"value", v, "error", err, "default", DefaultTimeout.String())
} else {
cfg.Timeout = d
}
}
if v := getenv(EnvMemLimit); v != "" {
n, err := ParseMemLimit(v)
if err != nil {
logger.Warn("materialize: ignoring invalid "+EnvMemLimit+"; using the default",
"value", v, "error", err, "default", formatBytes(DefaultMemLimit))
} else {
cfg.MemLimit = n
}
}
if v := getenv(EnvIdleTimeout); v != "" {
d, err := time.ParseDuration(strings.TrimSpace(v))
switch {
case err != nil:
logger.Warn("materialize: ignoring invalid "+EnvIdleTimeout+"; using the default",
"value", v, "error", err, "default", DefaultIdleTimeout.String())
case d < 0:
logger.Warn("materialize: ignoring negative "+EnvIdleTimeout+"; using the default (0 means never)",
"value", v, "default", DefaultIdleTimeout.String())
case d == 0:
cfg.IdleTimeout = NeverIdle
default:
cfg.IdleTimeout = d
}
}
return cfg
}
// effectiveIdleTimeout applies the default and the clamp; a negative request
// (NeverIdle) is returned as NeverIdle. The second result says whether a
// positive request was changed.
func effectiveIdleTimeout(d time.Duration) (time.Duration, bool) {
switch {
case d < 0:
return NeverIdle, false
case d == 0:
return DefaultIdleTimeout, false
case d < MinIdleTimeout:
return MinIdleTimeout, true
case d > MaxIdleTimeout:
return MaxIdleTimeout, true
}
return d, false
}
// effectiveTimeout applies the default and the clamp. The second result says
// whether a non-zero request was changed.
func effectiveTimeout(d time.Duration) (time.Duration, bool) {
switch {
case d == 0:
return DefaultTimeout, false
case d < MinTimeout:
return MinTimeout, true
case d > MaxTimeout:
return MaxTimeout, true
}
return d, false
}
func effectiveMemLimit(n uint64) (uint64, bool) {
switch {
case n == 0:
return DefaultMemLimit, false
case n < MinMemLimit:
return MinMemLimit, true
case n > MaxMemLimit:
return MaxMemLimit, true
}
return n, false
}
// formatBytes renders n in the EnvMemLimit syntax, exactly when it can.
func formatBytes(n uint64) string {
switch {
case n != 0 && n%(1<<30) == 0:
return fmt.Sprintf("%dGiB", n>>30)
case n != 0 && n%(1<<20) == 0:
return fmt.Sprintf("%dMiB", n>>20)
}
return strconv.FormatUint(n, 10)
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -9,6 +9,8 @@ import (
"errors"
"fmt"
"io"
"runtime"
"sync"
"time"
)
@@ -57,6 +59,42 @@ func RunWorker(r io.Reader, w io.Writer, js []byte) error {
if err != nil {
return err
}
warmThreads()
return serveWorker(r, w, runner)
}
// warmThreads brings the worker's thread set to steady state BEFORE it reads
// the readiness probe, i.e. before the supervisor measures the baseline the
// memory cap is relative to (supervisor.go armCap). Every P runs an
// allocating goroutine at once, so the runtime starts an M for each, and a
// GC starts its workers. Threads created only later — during a job — would
// otherwise come out of that job's budget: on a cgo build each reserves an
// 8 MiB stack, and CI measured the growth this closes.
func warmThreads() {
n := runtime.GOMAXPROCS(0)
var wg sync.WaitGroup
start := make(chan struct{})
for range n {
wg.Add(1)
go func() {
defer wg.Done()
<-start
var keep [][]byte
for range 32 {
keep = append(keep, make([]byte, 64<<10))
}
runtime.KeepAlive(keep)
}()
}
close(start)
wg.Wait()
runtime.GC()
}
// serveWorker is RunWorker's loop over an already-loaded Runner. Split out so
// the tests of the loop share one loaded bundle instead of paying the load
// (~25 s under the race detector) per test.
func serveWorker(r io.Reader, w io.Writer, runner *Runner) error {
br := bufio.NewReader(r)
bw := bufio.NewWriter(w)
for {
+3 -3
View File
@@ -96,7 +96,7 @@ func TestWorkerRoundTrip(t *testing.T) {
func TestWorkerEmptyInput(t *testing.T) {
var out bytes.Buffer
if err := RunWorker(bytes.NewReader(nil), &out, pad.MaterializerJS); err != nil {
if err := serveWorker(bytes.NewReader(nil), &out, runner(t)); err != nil {
t.Fatal(err)
}
if out.Len() != 0 {
@@ -111,7 +111,7 @@ func TestWorkerTruncatedFrame(t *testing.T) {
"body": append(append([]byte{}, good...), good[:len(good)-3]...),
} {
var out bytes.Buffer
err := RunWorker(bytes.NewReader(in), &out, pad.MaterializerJS)
err := serveWorker(bytes.NewReader(in), &out, runner(t))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("%s: got %v, want ErrUnexpectedEOF", name, err)
}
@@ -125,7 +125,7 @@ func TestWorkerOversizedFrame(t *testing.T) {
in := make([]byte, 4)
binary.BigEndian.PutUint32(in, MaxFrameBytes+1)
var out bytes.Buffer
if err := RunWorker(bytes.NewReader(in), &out, pad.MaterializerJS); !errors.Is(err, errFrameTooLarge) {
if err := serveWorker(bytes.NewReader(in), &out, runner(t)); !errors.Is(err, errFrameTooLarge) {
t.Fatalf("got %v", err)
}
resps := readResponses(t, out.Bytes())