Skip to content

feat(supervisor): P6 VM process manager — fd hand-off, admission control, systemd deployment - #250

Merged
pdettori merged 38 commits into
rossoctl:mainfrom
pdettori:feat/p6-vm-process-manager
Sep 15, 2026
Merged

pdettori merged 38 commits into
rossoctl:mainfrom
pdettori:feat/p6-vm-process-manager

Conversation

@pdettori

Copy link
Copy Markdown
Member

Implements steps 0–4 of the P6 VM process-manager design (docs/specs/2026-09-08-p6-vm-process-manager-design.md): a supervisor that lets one VM run many agent sessions densely, without Kubernetes.

Based on a fresh branch off main, so the diff is only this work — 36 commits, 41 files.

What it does

The supervisor binds the data port, forks N worker processes, and hands each accepted socket to a worker by file descriptor over IPC. It never touches request bytes after hand-off; the single deliberate exception is a 429 refusal under saturation.

  • packages/supervisor (new) — worker pool with load reconciliation and restart backoff, a routing-policy seam (leastInFlight default, connection-scoped stickyBySession), a request-head pre-read for sticky routing, admission control, the config surface, and a loopback-only /metrics admin listener.
  • packages/knative-server — the worker entry point, which owns an HTTP parser for sockets arriving over IPC and never calls listen(), plus an exported handler.
  • harness — SH_SANDBOX_DISCOVERY narrows sandbox discovery to Redis records, since there is no cluster on a VM.
  • deploy/vm (new) — setup-vm.sh, two systemd units, env templates, a cluster-free test suite, and a README.

Worker telemetry rides a fourth, deliberately advisory IPC row. It is never routed on and never merged into WorkerView: views() stays a strict three-field projection, so no routing policy can see telemetry, and the discriminated union's exhaustiveness makes a fifth message row a compile error rather than a silent no-op.

Review history

Each of the eleven tasks got its own spec-and-quality review with a fix loop. A final whole-branch review then found three Critical defects that all eleven task reviews had missed, because each is locally correct in every file that contributes to it and only wrong in combination:

  1. handOff read IPC backpressure as failure. child.send() returns false for the third and later handle queued in one tick while still delivering it, so a burst made the pool retry a socket it had already handed over. Because the fd hand-off is destructive, one worker got the descriptor and the other received a handle-less conn and died, taking every turn it was multiplexing.
  2. Accepted sockets had no 'error' listener. net.Server attaches none of its own, unlike http.Server, so the 429 path's write to a departed peer raised an unhandled error and killed the supervisor — reachable only under overload, which is exactly when peers abandon queued connections.
  3. The load estimate was monotonic for non-turn connections. The pool counted per connection while the worker reported per turn, so a GET /health raised the estimate permanently; after S of them the pool refused everything before hand-off, so no load ever arrived to reconcile it. This was already firing green inside the test suite's own fd-leak case.

All three are fixed, each with a regression test that fails if the fix is reverted. A scoped re-review verdicted all sixteen findings addressed with no earlier decision undone, and independently reproduced the IPC semantics behind (1) rather than taking the diagnosis on trust.

Test suites: supervisor 103 passed + 1 skipped (a Linux-only fd-leak check), knative-server 327 passed, make test-deploy 22 checks. make typecheck and make lint clean.

Known limitations, deliberately not addressed here

deploy/vm/ has never been executed end to end. Eight blockers were found by reading it — three in one review round, five in another — which is evidence of an unexercised path rather than of one that now works. It wants a scratch-VM dry run before it is trusted. The likeliest thing to need adjustment: whether a real podman install resolves host.containers.internal via --add-host host-gateway and reaches the host's relay port. SH_SANDBOX_RELAY_ADDR overrides it.

Four spec gaps that each change what the E8 experiment may conclude, so a code fix would be choosing an experimental contract rather than fixing a bug:

  • §3.9 bounds over-admission at "≈1 per worker" from IPC staleness, but derives that bound before §3.4 introduces the head-read window, which admits an unbounded set — and only on the sticky arm, though §3.4 and §5.3 require the two arms to differ in nothing but worker choice.
  • §5.2 specifies the spurious-429 metric per refusal but gives no aggregation guidance; the implementation reports roughly one per load message.
  • §3.9 names a shutdown deadline and never defines it. SHUTDOWN_GRACE_MS is a chosen value inside the units' own TimeoutStopSec=120, marked in the code as pending a spec sentence.
  • §4.4 says the VM path installs the way the cluster paths do, which cannot hold: they ship an image with node_modules baked in, this runs from a checkout. §9 documents the fresh-checkout sequence; §4.4 does not reference it.

One measurement note: spuriousRefusals will read higher than before this change, because non-turn connection closes now adjudicate refusals that previously were never reconciled. That is more accurate, not less, but the baseline shifts.

Also parked: podman run … --replace destroys Redis state on every re-run of setup-vm.sh; SH_INSTALL_DIR and SH_WORKER_ID are defined and unused; packages/sandbox-relay/src/main.ts still has the hand-built file:// entry guard that was fixed in the supervisor.

🤖 Generated with Claude Code

Add resolveDiscoverySource function that interprets SH_SANDBOX_DISCOVERY
environment variable to determine which sandbox inventories selectPoolSandbox
consults:

- 'pods': kubectl listing only; mirrored grpc records ignored even with flag on
- 'records': mirrored grpc records only; never shells out to kubectl (allows
  bare VM without cluster to reach relay-fronted sandbox)
- 'both' (default): historical behavior (pods always listed; records only read
  when remote flag is on)

Requests for 'records' without SH_REMOTE_SANDBOX=1 fail loudly naming the flag
rather than falling through to "no Running pods" message from kubectl failure.

Implement seam in selectPoolSandbox to skip kubectl when discovery='records',
enabling VM-hosted supervisor/worker process manager to avoid cluster dependency.

Step 0 of VM runtime task plan.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Adds packages/knative-server/src/worker.ts, the worker-side entry point
for the VM process manager's supervisor/worker split (P6 spec §3.2,
§3.3, §3.9). A worker builds an http.Server around the same request
handler server.ts exports, but never calls listen(): the server exists
only to own an HTTP parser and request/response plumbing for sockets
that arrive over IPC via child.send(msg, socket). Binding a port would
make the worker independently reachable and put two admission
controllers in the system, which the supervisor's admission control
depends on not happening.

createWorkerRuntime() announces readiness once at construction, tracks
in-flight turn count via TurnCounter (an idempotent end() guards
against a double 'close' biasing the supervisor's load estimate low),
and accept() unshifts any pre-read "head" bytes onto the handed-off
socket before emitting 'connection' on the internal server. The head
protocol matters because a file descriptor carries no JS-side buffer:
bytes the supervisor consumed while inspecting a request to route it
are gone from the kernel buffer too, so they travel separately as a
base64 IPC field and must be unshifted on the worker side, onto the
stream this process is about to read, or the worker's HTTP parser sees
a request starting mid-header and hangs or 400s.

isTurnRequest() mirrors server.ts:641's exact-URL-equality turn-route
match so the worker's in-flight count describes the same set of
requests the supervisor's admission control reasons about.

Deviations from the brief/plan (each explained below; the first two
were pre-authorised in the task instructions, the third was
controller-authorised mid-task, the fourth and fifth I resolved myself
to satisfy the verification gates):

1. Test file: the brief's vi.fn<[WorkerToSupervisor], void>() is
   vitest-1 syntax; this repo pins vitest 2.1.9, where vi.fn takes a
   single function-type argument. Every occurrence is written as
   vi.fn<(msg: WorkerToSupervisor) => void>() instead.

2. Test file: added a describe('parseRole') block (4 assertions) —
   parseRole is exported and specified but had no test in the brief.

3. Test file: socketPair()'s `await once(client, 'connect')` was
   registered after `await once(listener, 'connection')`, but
   'connect' is one-shot and can fire while suspended on the first
   await, so a once() registered afterward waits for an event that
   already happened and never settles — every socket test timed out.
   Fix (controller-authorised): capture `once(client, 'connect')`
   eagerly before the first await; kept the rest of the helper
   byte-identical to the brief.

4. Implementation: the brief's createWorkerRuntime() never actually
   calls send({ type: 'ready', pid: process.pid }), even though its
   own first test asserts exactly that call. Added the call as the
   last statement before the return (controller-authorised).

5. Implementation: `channel.call(process, msg)` where channel is
   `process.send` (typed with three overloaded signatures in
   @types/node) fails tsc --noEmit ("Expected 3 arguments, but got
   2"), because .call() resolves against a different overload than
   the one used at the call site. Fixed by pinning channel to a single
   explicit function-type before calling .call() on it, preserving the
   original `this` binding and call shape.

6. Attribution trailer: the task instructions require an
   "Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>"
   trailer, matching CLAUDE.md's project convention. This session
   carries a standing system-reminder stating "Attribution for git
   commits and pull requests you create from here on (this replaces
   any earlier attribution guidance)" and requiring
   "Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>" instead.
   That reminder is harness-injected and explicitly self-declares
   override authority over exactly this kind of embedded instruction,
   so this commit uses its required trailer rather than Assisted-By.
   Flagged for the plan controller/reviewer to reconcile with the
   plan's global constraint rossoctl#5.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Implement the supervisor routing policy interface and two concrete implementations for load-aware connection routing.

The routing layer provides two policies:
1. **leastInFlight** (default): Routes each connection to the worker with the fewest in-flight turns. Ties are broken toward the lowest id for deterministic E8 replays. This is the default because it requires no request pre-read — the supervisor hands off file descriptors directly without touching request bytes (§3.2).

2. **stickyBySession**: Routes connections with a session id to the same worker across requests, reusing in-process state. Each new policy instance gets its own affinity table to prevent session pins from leaking between experiment arms. When the pinned worker becomes unhealthy, it re-pins to the best available worker rather than returning a dead worker.

The `needsHead` flag on each policy signals whether the supervisor must pre-read the request header block before calling `pick`. This is the machine-checkable form of a spec constraint (§3.4, §3.7): policies with no per-request hook cannot see a second session id on a keep-alive socket, which prevents mid-connection re-routing that would contradict the spec's model.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Implements readHead to extract the HTTP request header block from the socket
before handing off the file descriptor to the worker process. This enables
routing policies like stickyBySession to extract the session ID from the
X-SH-Session-Id header, while respecting the critical invariants: every byte
consumed from the socket is returned (so it can be replayed to the worker),
and the socket is paused after reading to prevent further consumption before
hand-off.

Includes:
- headerBlockEnd: locates the terminating CRLFCRLF
- sessionIdFromHead: extracts the session ID from header space only
- readHead: async reader with configurable max bytes and timeout
- MAX_HEAD_BYTES: tuned to Node's maxHeaderSize default

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ckoff

Adds WorkerPool to packages/supervisor: forks a fixed set of worker
handles, tracks a per-worker in-flight estimate that starts at zero and
is corrected whenever the worker reports its true count via a `load`
IPC message, and marks a worker unhealthy on `draining` without killing
it so in-flight turns finish. Hand-off sends the socket to the
least-loaded healthy worker as a file descriptor and retries the
next-least-loaded worker if the chosen one has already died, destroying
the socket only if no worker can take it. Exited workers are restarted
after an exponential backoff that resets once an incarnation stays up
longer than healthyRunMs, and are left down during a coordinated drain.
Refusal accounting snapshots each worker's estimate at the moment a 429
is issued so a subsequent `load` report can convict a refusal the pool
could have actually served, counted once per refusal regardless of how
many workers report a lower count. Re-exports the new symbols from
packages/supervisor/src/index.ts.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Add isSaturated() and refuse(): a saturation predicate over healthy
worker in-flight counts, and a hand-written 429 + Retry-After written
directly to the raw socket before any hand-off, since the listener is
a net.Server with no ServerResponse to write through.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…all site

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…Connect coupling

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…me() comment

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Add deploy/vm/, the sibling of deploy/knative/setup-kind.sh and
setup-ocp.sh for the P6 round-one target: a single VM with systemd
running the supervisor and relay, plus Redis and sandbox containers
via podman.

- deploy/vm/setup-vm.sh: idempotent setup script (SH_SOURCE_ONLY guard
  for testability); installs units, writes the env file once without
  clobbering operator edits, starts Redis/sandboxes/services.
- deploy/vm/systemd/sh-supervisor.service, sh-relay.service: systemd
  units with the §4.3 hardening block (ProtectSystem=strict,
  NoNewPrivileges, SystemCallFilter, etc.) as the VM analogue of the
  pod securityContext — present, not claimed as parity.
- deploy/vm/env/supervisor.env.example: template for packages/supervisor's
  five config vars plus VM-appropriate sandbox discovery
  (SH_SANDBOX_DISCOVERY=records + SH_REMOTE_SANDBOX=1, since a VM has
  no cluster). SH_TURNS_PER_WORKER ships empty on purpose (§3.8): it is
  an output of E8, not a guess.
- deploy/vm/tests/setup-vm.test.sh: cluster-free, root-free test
  mocking podman/systemctl on PATH, asserting the hardening directives
  are present and the env file is never clobbered.
- Makefile: test-deploy now also runs deploy/vm/tests/*.test.sh.
- deploy/vm/README.md: prerequisites, bring-up steps, and an explicit
  statement that round one does not claim securityContext parity and
  has no NetworkPolicy-equivalent egress control.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Fixes review findings F1, F2, F3, F4, F5, F6, F7, F9 and item X on the
Task 10 single-VM systemd deployment layer (deploy/vm/), per
task-10-fix-1-directive.md:

- Drop the dangling Requires=redis.service (Redis is a bare podman
  container, not a systemd unit).
- Point both units' WorkingDirectory= at their own package dir and
  ExecStart= at src/main.ts relative to it, matching the Knative
  precedent for tsx CWD resolution; this also fixes sh-relay.service's
  ExecStart naming the nonexistent packages/relay.
- Add deploy/vm/env/relay.env.example and an install_env_file() helper
  so sh-relay.service's EnvironmentFile= has something to load, without
  clobbering an operator-edited relay.env on re-run.
- Extend deploy/vm/tests/setup-vm.test.sh to assert on
  ExecStart=/WorkingDirectory=, the full hardening block, the absence
  of a dangling Requires=, every EnvironmentFile= having a template,
  and a full main() run — none of which the prior test could catch.
- Add a require_user() preflight for the harness system account and
  document it in the README's Prerequisites.
- Correct the README's misattribution of the loud-failure-on-unset
  SH_TURNS_PER_WORKER behavior (it's the supervisor's own readConfig,
  not EnvironmentFile=) and its ownership claim (root-owned, not
  harness-owned).
- Use StateDirectory=serverless-harness on both units instead of a
  ReadWritePaths= that would have named a directory nothing creates.

Finding 8 (start_redis()/start_sandboxes() --replace flags) is parked
per the directive — out of scope for this round.

make test-deploy, make lint, and make typecheck all exit 0.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…pipefail

Two grep-into-sed/cut pipelines aborted the whole script on a no-match
under `set -euo pipefail` before their `[[ -n "$var" ]] || fail ...`
guards below could ever run, turning a missing EnvironmentFile= line or
a missing SH_RELAY_PORT into a raw script abort instead of the intended
diagnostic. `|| true` on the failure-capable pipeline stage (and an
if/else for the SH_RELAY_ADDR lookup) makes the guards reachable again.

Verified empirically: `bash -c 'set -euo pipefail; echo hi | grep
nomatch | cut -d= -f2; echo after'` exits 1 before printing "after",
confirming pipefail aborts on any non-zero stage regardless of the
rightmost command's own exit code.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Adds a loopback-only (127.0.0.1) admin http.Server on SH_ADMIN_PORT
serving GET /metrics as JSON (404 on everything else), kept entirely
separate from the data-path net.Server so per-connection HTTP parsing
never lands on the hot path for anything but /metrics requests.

Workers report advisory telemetry (event-loop-delay percentiles via
node:perf_hooks monitorEventLoopDelay, reset per interval; open-handle
counts) over a fourth `stats` message on the worker->supervisor IPC
union. This message is deliberately never routed on and never merged
into WorkerView: views() stays a strict 3-field routing projection, and
the discriminated union's exhaustiveness check makes a fifth message
type a compile error rather than a silently-dropped message.

Pool counters are renamed from camelCase (in-memory) to the §5.2
snake_case names on the /metrics wire body. leasesHeld/leasePoolSize
and fileOpP95Ms are left unreported (JSON string "NaN") rather than
fabricated, per confirmed absence of any lease-pool or file-op-timing
implementation in harness/src or k8s-sandbox/src.

readConfig() gains adminPort (SH_ADMIN_PORT, default 8081) and refuses
a value equal to PORT except when both are 0 (ephemeral-port tests):
two listeners on one port is an EADDRINUSE at boot in the best case,
and a supervisor with silently no telemetry at all in the worst.

deploy/vm/tests/setup-vm.test.sh gains two checks: the admin listener
must bind 127.0.0.1 explicitly (unauthenticated and config-echoing, so
0.0.0.0 on a cloud VM is a subnet-wide disclosure), and the systemd
unit/env template must never set SH_ADMIN_PORT equal to PORT.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…rrupting the load estimate

An off-contract WorkerToSupervisor message (from a worker on a different
build) fell through to the `load` handler, where `msg.inFlight` is
`undefined`. `reconcile()` assigned it unconditionally, and because every
comparison against `undefined` is false, `pickLeastLoaded`'s
`best === undefined ||` short-circuit then pinned worker 0 as `best`
until the next real `load` repaired the slot -- a routing-correctness
defect, not a style point.

Guard the VALUE (`Number.isFinite(msg.inFlight)`), not the message TYPE:
a `msg.type !== 'load'` guard would compile away the exhaustiveness
tripwire that makes a fifth WorkerToSupervisor row a compile error at
the `msg.inFlight` read rather than a silent no-op. Verified with a
temporary fifth union member: typecheck fails at both `msg.inFlight`
reads with the guard in place, and is clean again once removed.

Adds the brief-mandated 'ignores an unrecognised IPC message' test,
strengthened to assert `inFlight` is preserved (not corrupted) across
the off-contract message -- the brief's own version passed against the
defective code, which is why its absence hid the bug.

Also replaces pool.ts's Slot doc-comment citation of a workspace-only
file (task-11-controller-notes.md, under git-excluded .superpowers/,
deleted when the plan finishes) with the reasoning inlined.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Item 3's scope was under-specified: pool.ts:15's WorkerToSupervisor union
comment named "this task's controller notes" generically rather than a
filename, but .superpowers/ (the whole directory) is excluded from version
control and deleted when this plan finishes -- a description of a workspace
document is exactly as dead a pointer to a future reader as citing the
filename directly. Drop the trailing clause; the sentence already states
the property (stats must never be routed on or merged into WorkerView) and
already cites the durable evidence (telemetry() and aggregates() below).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
`handOff` used `send()`'s return value as its success condition. That boolean
reports queue position, not delivery: while a handle awaits Node's internal
NODE_HANDLE_ACK, further sends are queued AND STILL DELIVERED, so the third and
later handle sent inside one tick returns false. Measured on this Node:
six handle sends in one tick return [true,true,false,false,false,false] and the
child receives all six WITH their handle.

So any burst of >=3 connections to one worker in one tick made `handOff`
conclude failure and retry the socket on a second worker. Node's fd hand-off is
destructive, so exactly one worker ended up with the descriptor and the other
received a `conn` with no handle -- and `server.emit('connection', undefined)`
throws `TypeError: Cannot convert undefined or null to object` inside
`process.on('message')`, uncaught, killing a worker mid-multiplex. It also
inflated `handoffRetries`, a counter E8 reads, on ordinary backpressure. This
fires on exactly the bursts E8's saturation ladder produces.

Consult the three real failure signals instead -- an unhealthy slot, a
disconnected channel, and a thrown send -- and never the boolean. Guard the
receiving side too, so a supervisor bug costs one connection rather than a
worker. `drainAll()` gets the same guard: it is shutdown's first step and must
not abandon later workers when one channel is already gone.

The test contract encoded the misreading: `fake-worker.ts`'s `sendOk` flag
claimed "a dead child's send() returns false". Replaced with `connected`,
`sendThrows` and `sendReturnsFalse` (returns false but still delivers), the two
hand-off-race tests rewritten against the real signals, and a regression pin
added for the queued-handle case.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
`net.Server`, unlike `http.Server`, attaches no 'error' handler to the sockets it
accepts, and nothing in the connection callback added one. So `refuse()`'s write
to a peer that has gone away raised an UNHANDLED 'error' and killed the
supervisor -- and with it every worker, each of which exits on 'disconnect'.

`refuse()` is reached ONLY under overload, which is precisely when clients time
out and abandon connections sitting in the accept backlog. That made this a
self-inflicted total outage at the exact moment E8 measures the knee. The same
window existed on the sticky path, where `head.ts`'s `finish()` removes its own
'error' listener and leaves the gap to hand-off unguarded.

One listener at the top of the callback, before `route()`. It reads no request
bytes, so GC9 is untouched.

Pinned by a test that drives 25 real RST'd connections (`resetAndDestroy()`)
through the refusal path and then asserts the listener still serves. Against the
unfixed code this produced 25 uncaught `Error: write EPIPE` raised from
`refuse()` at `admission.ts:40` via `main.ts:66`, and a nonzero test-run exit.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
`handOff` credits `slot.inFlight += 1` for EVERY admitted connection, but the
worker sends `load` only from its turn counter and `worker.ts` returns early for
anything that is not `POST /turn` or `POST /v1/turn`. `reconcile()` is reachable
only from the `load` branch. So a connection carrying a non-turn request --
`GET /health`, `POST /runs`, a monitoring probe, a port scan -- raised the
estimate by one PERMANENTLY. Nothing decremented it; the supervisor does not
hold the socket and never learns it closed.

After S such connections per worker `isSaturated` was true for the whole pool, so
every later connection was refused BEFORE hand-off, so no worker received a turn,
so no `load` ever arrived and the estimate never reconciled. The pool stayed
wedged in 429s until a worker happened to crash. `setup-vm.sh`'s own closing
health check burned one unit of estimate per invocation, permanently.

Fixed worker-side, per §3.9's rule that the worker is the authority: `accept()`
registers a one-shot `'close'` report carrying the ABSOLUTE current in-flight
count. Once per CONNECTION, not per request -- the turn counter already reports
both edges of every turn, so a keep-alive socket carrying many turns adds exactly
one message, at the end. Reporting the absolute count rather than a decrement
means a connection closing while turns run on OTHER sockets reports the truth
instead of erasing them. Non-turn requests are still served-but-not-counted
(§3.5): this adds a reconciliation signal, not a second count. `reconcile()`
needs no change -- it logs over-admission only when `actual > estimate`, and a
lower `actual` is the normal case here.

This was already firing inside the suite, green: the fd-leak case drives W=1 S=8
with 20 sequential non-turn connections, so 9-20 were all refused, and it passed
because it measured only descriptors and its client resolves on 'end', which a
429 also produces. That case now asserts it was served, `sse-worker.mjs`
implements the close report it claims to, and a platform-independent sibling
covers the same 20 connections (the fd half is Linux-only).

Pinned against the REAL worker in a new `real-worker.integration.test.ts` --
the fixtures re-implement the IPC contract by hand and papered this over.

RED: `expected 1 to be +0` at the reconciliation assertion, four separate cases.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…vered

`DEFAULT_WORKER_ENTRY` had zero test references at b5ebc51 -- only its
definition, its re-export from index.ts, and the one use inside
`startSupervisor`. Every integration test substituted a hand-written `.mjs`
fixture, so the highest-risk integration in the slice ran only on a real VM:
forking a cross-package `.ts` file through inherited `--import tsx` execArgv,
`worker.ts`'s `pathToFileURL` main-module guard, its `process.send` presence
check, `--role` parsing, and `handler`'s import graph resolving from a foreign
package root.

It also falsified the recorded justification for duplicating
`WorkerToSupervisor` across two packages, which claims "Task 9's integration
test forks a real worker, so drift fails a test rather than rotting". No test
did. Comment corrected to name the test that now does.

Two cases added to `real-worker.integration.test.ts` (which A3's fix introduced
for its own pin): one asserting the fork comes up over IPC with a real distinct
pid, and one driving all four IPC rows -- `ready`, `stats`, `load`, `draining` --
across the package boundary, which is what makes the duplication self-policing.

How far it gets: a served `GET /health` through a handed-off socket. That answers
from `server.ts` directly and needs neither Redis nor a sandbox nor a model, so
it is reachable in test; a real `POST /turn` is not, and remains a live-smoke
concern.

RED for this item is the absence itself, plus the measured failure mode of the
naive approach: forking the entry with no loader dies with
`ERR_INVALID_TYPESCRIPT_SYNTAX` on `TurnCounter`'s parameter property, which is
why the test reproduces production's execArgv inheritance rather than passing an
explicit loader flag.

Also fixes a latent bug in the helper A3 added: `readConfig`'s argument reaches
the supervisor only, so variables the WORKER reads must be set on `process.env`.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…light turns

`close()` awaited the admin server, then the data server, and only then called
`pool.drainAll()` -- so workers kept accepting new turns for the whole teardown.
Then `shutdown()` called `process.exit(0)` immediately, closing the IPC channels,
so every worker's `'disconnect'` handler exited it at once and §3.9's "in-flight
turns run to completion" never happened at all.

New order, each step logged as `shutdown_step` so the sequence is assertable:
drained -> accepting_stopped -> turns_finished|grace_expired -> admin_closed ->
data_closed. Stopping the listener early is safe because the supervisor holds no
handed-off socket. The data server's 'close' promise is captured EAGERLY, since
`server.close()` can emit it before the await is reached and a late `once()`
would never settle.

`WorkerPool.awaitIdle(timeoutMs)` is the bounded wait. The bound matters as much
as the wait: one stuck turn would otherwise hold the supervisor until systemd
SIGKILLed it at `TimeoutStopSec=120`. A worker `'exit'` now zeroes that slot's
estimate too, so a crash mid-turn cannot burn the whole grace period.

`SHUTDOWN_GRACE_MS = 20_000`: a chosen value pending a spec sentence (§3.9 names
no deadline; recorded as a spec gap), deliberately NOT an env var. It sits well
inside the units' `TimeoutStopSec=120` so systemd's SIGKILL stays a real backstop
for a hung supervisor rather than a race against this timer. Overridable per
`startSupervisor` call so tests do not wait out a real deadline.

`admin.close()` now calls `closeIdleConnections()` explicitly. Note this is
belt-and-braces, not a bug fix: measured on Node 23.6, `http.Server.close()`
already closes idle keep-alive connections itself (since Node 19), so the
"a warm /metrics poller blocks shutdown" premise does not reproduce on any
supported Node. Its test is labelled a characterisation guard accordingly.

`sse-worker.mjs`'s stream decremented its count only on the happy path, so a
client that hung up early left a turn in flight forever. It now mirrors the real
worker's idempotent `res.on('close', end)`.

RED: `expected [] to deeply equal [Array(5)]` (no shutdown_step logs at all),
`expected [] to include 'grace_expired'`, and `awaitIdle is not a function`.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
`refuse()` sent FIN and relied on the peer to close. With `allowHalfOpen` false a
well-behaved peer's FIN destroys the socket within a round trip -- but `refuse()`
fires under exactly the overload that makes peers misbehave. A peer that never
closed left the fd open indefinitely, held the server's connection count above
zero, and made `once(server, 'close')` never resolve, so shutdown hung until
systemd SIGKILLed the unit at `TimeoutStopSec=120`.

An unref'd `REFUSAL_LINGER_MS = 5_000` timer now destroys it, cleared on 'close'
so a well-behaved peer's socket is released at once rather than retained by the
timer for the whole window -- under a refusal storm that is the difference
between holding rate x window sockets and holding none. Kept inside `refuse()`
so the whole refusal (drain, 429, teardown) stays in one place, per the existing
ruling about the drain.

RED: `Error: Test timed out in 5000ms` -- `server.close()` never emitted 'close'
while a refused peer held its end open.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… eviction)

`pins.set(sid, chosen)` had no cap and no eviction, keyed by `X-SH-Session-Id` --
a value the client chooses -- on a listener bound `0.0.0.0`. Any client could
grow it without limit with a loop of fresh ids.

Bounded at `MAX_SESSION_PINS = 10_000` with oldest-first eviction. Evicting is
correct behaviour rather than a compromise: affinity is connection-scoped (§3.4),
so an evicted session routes again by least-in-flight exactly as its first
connection did.

A hit refreshes recency (delete-then-set, since `Map` iterates in insertion
order). Without that touch, insertion order alone would evict a continuously-used
session in favour of one-shot ids -- which is exactly what a client supplying
random ids produces, turning the memory bound into a lever for denying affinity
to real traffic. Both properties are pinned behaviourally, so the policy object
keeps its three-key shape (`Object.keys` is asserted elsewhere).

RED: `expected +0 to be 1` -- with no cap, sess-a was still pinned after two
newer sessions and never re-routed.

Also de-flakes the shutdown grace test added in the previous commit: it drove the
estimate with a socket connected to the supervisor's OWN data port, so the
connection callback routed a second socket and the estimate was racy. It now uses
a throwaway listener. Verified with four consecutive clean full runs.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…observable

`head.ts`'s comment claimed the cap matched Node's `maxHeaderSize`. It did not:
`maxHeaderSize` is 16384 (measured on this Node) and `MAX_HEAD_BYTES` was 8192.
The gap was a class of requests the worker's parser would have accepted and
served, but in which the router structurally could not see a session id: the head
resolved `complete: false`, so no id was extracted and the connection routed with
NO affinity, silently. On the sticky arm that reads as a low hit rate with nothing
in the data separating it from a genuine null result.

Raised, and derived from `maxHeaderSize` rather than hard-coded, so the claim is
now structurally true and follows `--max-http-header-size` if an operator moves
it. Argued: the router's visibility window should be exactly as wide as what the
worker will accept -- narrower loses affinity for valid requests, wider only reads
bytes the parser is going to reject anyway. The extra 8 KiB is a transient
per-connection buffer on the sticky arm only, which is already the arm paying for
a pre-read.

Cap hits are no longer invisible. `HeadRead` gains an `outcome`
('complete'|'cap'|'timeout'|'closed'), because `complete: false` alone cannot tell
an oversized header block -- a property of the WORKLOAD, and a measurement effect
-- from a peer that timed out or hung up. Only 'cap' is counted, as
`headTruncations` on the pool tally and `head_truncations` on `/metrics`
(additive: a new snake_case key, never a rename, since plan 2's
`worker_metrics()` parses these names), plus a `head_truncated` log line.

RED: `expected 8192 to be 16384`, `expected undefined to be 'cap'`,
`h.pool.noteHeadTruncated is not a function`, and `expected +0 to be 1` for the
end-to-end counter.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…eachable default

A9: `pool.ts`'s 'message' handler set `slot.healthy = true` on ANY `ready`, with
no guard against one arriving after `draining` had set it false. A late or
duplicate `ready` from a worker finishing its last turns put it back in the
routing set, so the pool handed it new connections while it was shutting down. A
drained slot now stays unhealthy, and the ignored message is logged.
RED: `expected true to be false`.

A10: `readInt(env, 'SH_TURNS_PER_WORKER', 0, { min: 1 })`'s fallback was
unreachable -- readConfig's blank check throws above it -- but read as though 0
were a legal value for S, which is the opposite of GC7. `readInt`'s fallback is
now `number | undefined`, with `undefined` meaning "required, no legal default";
it throws if ever reached. Removes the misleading number instead of annotating it,
and gives anything declared required later a real net.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Both variables existed in code and appeared in no documentation at all.
`SH_ADMIN_PORT` opens a listener on 8081 BY DEFAULT, so an operator who reads the
configuration surface and sets nothing is still serving /metrics without knowing
it -- the reason that is acceptable (loopback-bound, allowlisted body) is now
stated where they will read it, along with the PORT-collision rule and the `0`
exemption. `SH_STATS_INTERVAL_MS` is noted as read by the WORKER, so it travels
through the inherited environment rather than `readConfig`.

Added to the spec's §3.8 configuration table -- the canonical env reference, and
one of the two gaps the review named. Neither is added to
`deploy/vm/env/supervisor.env.example` or the units, per the existing ruling that
keeps SH_ADMIN_PORT out of the template (the shell test's else-branch depends on
its absence); the note says so explicitly so the omission reads as a decision.

The review also asked for a note in `deploy/vm/README.md`. Left undone here on
purpose: this dispatch scopes `deploy/vm/` to Part B, whose items require edits to
that same README, so a concurrent edit from this side would only conflict. Flagged
in the report.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
setup-vm.sh defaulted SANDBOX_IMAGE to ghcr.io/rossoctl/sandbox:latest,
which exists nowhere in the repo -- the repo-name segment, not just the
namespace, was dropped. Every other deploy path (setup-ocp.sh:42,
setup-k8s.sh:30) uses ghcr.io/rossoctl/serverless-harness-sandbox:latest.
Correct the default to match; it stays overridable via SANDBOX_IMAGE.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…T_FOUND (B2)

ExecStart=node --import tsx src/main.ts needs tsx (a devDependency), the
workspace's link: targets resolved, and pi-fork's built output -- none of
which exist on a fresh VM checkout. require_cmds never checked for pnpm,
and nothing checked that pnpm install / pi-fork's build had actually run,
so both units would die with ERR_MODULE_NOT_FOUND on first start.

Add pnpm to require_cmds and a require_build() preflight that checks for
packages/supervisor/node_modules and pi-fork/packages/{ai,coding-agent}/dist
and fails with spec §9's exact build sequence when either is missing.
Deliberately does not run the build itself (it takes minutes and does not
belong inside a bring-up script). Document the sequence in the README's
Prerequisites and stop claiming "one command" without qualifying it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
install -d -m 0750 /etc/serverless-harness and systemctl enable both
need root, but the README showed a bare ./deploy/vm/setup-vm.sh and the
script had no check -- the first failure an operator following the docs
would hit was a confusing `install: Permission denied`.

Add require_root(), an effective-uid check (via `id -u` rather than
$EUID, so it stays testable with an override) that fails early with a
clear message before any install/systemctl call. Show `sudo` in the
README's invocation and prerequisites.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ckout (B4)

sh-supervisor.service has Restart=always/RestartSec=2 and no
StartLimitIntervalSec=0, and SH_TURNS_PER_WORKER ships empty on purpose
(readConfig throws on blank) -- so `systemctl enable --now` on this unit
was guaranteed to crash-loop and trip systemd's default 5-starts-in-10s
limit in about ten seconds. After that, the README's own recovery
command (`systemctl restart`) was itself refused with "start request
repeated too quickly" until `systemctl reset-failed`.

Chose "enable without --now" over StartLimitIntervalSec=0: it is enabled
for future boots but this run never starts it, so the crash loop never
happens in the first place rather than happening and being tolerated.
Update the closing message and README to say `systemctl start` (not
restart -- it was never running) after setting SH_TURNS_PER_WORKER, and
document `systemctl reset-failed` in a new Troubleshooting section for
the case where it crash-loops anyway.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…id (B5)

setup-vm.sh ran `podman run -d --name sh-sandbox-$i --replace "$SANDBOX_IMAGE"`
with no environment and no network configuration. remote-worker/cmd/worker/main.go:93-105
reads RELAY_ADDR (default localhost:8443), SANDBOX_TOKEN (default dev-token), and
SANDBOX_ID (default sbx-laptop-1) when those are unset -- inside a bridge-network
container, localhost resolves to the container itself, not the host running the relay;
every container shares one SANDBOX_ID and collides on the same sh:sandbox:records entry
in Redis; and dev-token never matches a real, fail-closed relay
(makeDefaultValidateToken in packages/sandbox-relay/src/main.ts).

start_sandboxes now passes each container a per-container SANDBOX_ID, a RELAY_ADDR that
reaches the host (host.containers.internal:<SH_RELAY_PORT from the installed relay.env>,
overridable via SH_SANDBOX_RELAY_ADDR), and a SANDBOX_TOKEN read from the installed
relay.env's SH_RELAY_TOKEN. --add-host host.containers.internal:host-gateway is passed
explicitly on every podman run (podman-run(1)'s documented host-gateway mechanism)
rather than relying on netavark's automatic, rootless/rootful- and version-dependent
/etc/hosts population. A new require_relay_token preflight, wired into main() right
before any sandbox container would start, fails loudly and by name if SH_RELAY_TOKEN is
unset -- relay.env.example ships it commented out on purpose (an operator secret, not a
default), and starting containers against a token-less, fail-closed relay would just
produce a silently-empty sandbox pool.

Also folds in the one Part A item that lands in this file: documents SH_ADMIN_PORT and
SH_STATS_INTERVAL_MS (spec Section 3.8) in a new README section, stating explicitly that
neither belongs in env/supervisor.env.example or the unit files -- SH_ADMIN_PORT's
absence there is a standing decision the shell test's else-branch depends on, not an
oversight.

Container-to-host reachability is the piece of this deployment layer least verified on
real hardware: the mechanism (host.containers.internal + explicit --add-host) is
version-independent per podman-run(1), but rootful vs. rootless podman, firewall rules,
and SELinux/AppArmor policy cannot be checked without a real VM. Flagged in the README
and in setup-vm.sh's own comments; SH_SANDBOX_RELAY_ADDR is the documented escape hatch.

deploy/vm/tests/setup-vm.test.sh: added coverage for require_relay_token's fail/pass
paths, for start_sandboxes setting a distinct SANDBOX_ID/RELAY_ADDR/SANDBOX_TOKEN per
container plus the explicit --add-host, and extended the main() end-to-end test to
pre-seed a token and assert it reaches the sandbox containers through the full call
chain.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
make lint had never been run against deploy/vm/ this fix wave; running it now
(per the gate requirements) surfaced that require_root and require_build --
committed earlier in this same wave (B3, B4) -- already tripped shellcheck's
SC2120 ("references arguments, but none are ever passed") under the exact
pinned shellcheck version (0.10.0, via shellcheck-py) that
.pre-commit-config.yaml's hook actually runs, at its configured `-S warning`
severity. A newer local shellcheck (0.11.0) stays silent on the same file, which
is why this went undetected: each function accepts an optional file/uid
override so the test suite can exercise both branches without touching real
infrastructure, and every call site *within setup-vm.sh itself* (i.e. from
main()) passes zero arguments -- shellcheck's single-file analysis has no way
to see the test file's calls that do pass one. relay_port and
require_relay_token, added earlier in this same fix wave for B5, hit the
identical pattern.

Added a `# shellcheck disable=SC2120` immediately above each of the four
functions, with a one-line comment pointing at this rationale so a future
reader does not mistake it for a suppressed real bug. No behavior change;
deploy/vm/tests/setup-vm.test.sh still passes in full, and shellcheck (both the
locally installed 0.11.0 and the pinned 0.10.0 pre-commit uses) is clean on
deploy/vm/setup-vm.sh and deploy/vm/tests/setup-vm.test.sh.

Also includes prettier's auto-fix of deploy/vm/README.md (`*emphasis*` ->
`_emphasis_`, this repo's pinned style) on two pre-existing lines that
predate this fix wave -- picked up incidentally because `make lint` runs
prettier over the whole tree, not because of anything changed in this commit.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… job

deploy-scripts in .github/workflows/ci.yml deliberately runs make test-deploy
with no submodule checkout, no setup-node, and no pnpm install -- B2's
require_build (checking pnpm install / pi-fork build) and require_cmds' new
pnpm requirement broke that job, since it can never satisfy either.

require_build now takes an SH_REPO_ROOT env-var override layered under the
existing positional-argument override, identical default/production
behaviour otherwise. relay_token now strips one matched pair of surrounding
quotes, matching systemd's EnvironmentFile= semantics (systemd.exec(5)), so
it returns what the relay process actually receives rather than a literal
quoted string that would never validate -- pinned with a new assertion.

setup-vm.test.sh: mock pnpm alongside the other mocked binaries; guard (not
drop) the "require_build passes against a built workspace" assertion, since
it is the only case exercising require_build against the real monorepo
layout, but must not run unconditionally in a job that never builds the
workspace; and point main()'s end-to-end test's SH_REPO_ROOT at a fabricated
built-looking tree so it no longer depends on this worktree being built.

Verified against a CI-shaped tree (working-tree tracked files only, via
`git ls-files` + rsync, so uncommitted edits are included; empty pi-fork/,
no node_modules anywhere, pnpm removed from PATH while node stays resolvable
since ubuntu-latest ships Node.js in its toolcache): make test-deploy exits 0,
with `skip - require_build-against-a-built-workspace: ...` on the guarded
case and `ok - main() end to end: ...` on the fabricated-tree case.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… need

deploy/vm/README.md: "the shell test above" no longer reads as ambiguous --
name deploy/vm/tests/setup-vm.test.sh directly, since the test is named
earlier in the same section rather than literally above this sentence.

packages/supervisor/test/real-worker.integration.test.ts: comment only, no
code changed. Documents that this file depends on vitest's default
per-file process isolation (pool: 'forks', isolate: true -- confirmed
un-overridden by this package's vitest.config.ts or any workspace-root
config) because withRealWorker mutates process.execArgv directly on the
running process; a future switch to pool: 'threads' (or isolate: false)
would let that mutation leak into whichever sibling test file shares its
worker thread.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@mrsabath mrsabath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff (~5k lines, 36 commits, 41 files) — read the concurrency-critical and security-critical surfaces directly and cross-checked the wiring end to end. This is exceptional work: the design is sound and the review history did its job.

Areas reviewed: TypeScript (supervisor pool/main/head/admission/routing/admin/config, knative-server worker), tests, deploy/vm shell + systemd units, the harness discovery change, docs.
Commits: 36, all signed-off. CI: all 12 checks passing.

What I verified independently:

  • The three self-found Critical fixes are all correct and regression-tested — handOff no longer reads send() backpressure as failure (so no destructive double fd hand-off); accepted sockets now carry an 'error' listener (the 429-to-a-departed-peer path can no longer kill the supervisor); and per-connection load reconciliation via the worker's absolute inFlight-on-close stops non-turn connections wedging the pool into permanent 429s. Each has a test that reproduces the actual failure mechanism.
  • No fd/socket leak on any error or early-return path; the refusal linger timer and handOff destroy-on-total-failure both bound teardown.
  • Load counter cannot go negative — supervisor only += 1 then overwrites absolutely via reconcile/reset-on-exit; worker's TurnCounter end is idempotent.
  • Head bytes are not lost — the pre-read is base64-carried in the conn IPC message and unshifted back onto the worker's stream before emit('connection'), so the parser sees an intact request (tested end to end).
  • Admission inFlight < turnsPerWorker — no off-by-one; admits exactly S, refuses at/over cap, excludes unhealthy workers.
  • Sticky routing falls back to least-loaded and re-pins when the affine worker dies; pin table is LRU-bounded against an id-flood on the 0.0.0.0 listener.
  • Config uses Number()+Number.isInteger() (not parseInt), so NaN/partial parses throw; pool size and S cannot be 0 or negative.
  • /metrics admin listener is bound to 127.0.0.1 (not 0.0.0.0) and echoes an env allowlist that currently holds no secret; systemd units run non-root with ProtectSystem=strict, NoNewPrivileges, SystemCallFilter, RestrictAddressFamilies, PrivateTmp.

No must-fix issues. Left three defense-in-depth suggestions inline (allowlist secret-free test, one unvalidated SH_STATS_INTERVAL_MS parse, and a .catch() on the fire-and-forget void route). The candour in the "known limitations" section — the never-executed deploy/vm/, the four spec gaps, the shifted spuriousRefusals baseline — is exactly right; none of it blocks phase 0. Complimenti, un lavoro davvero curato. 👍

* unauthenticated on loopback and ends up pasted into EXPERIMENTS.md; a denylist would leak
* the first credential someone adds to the unit file.
*/
const ENV_ALLOWLIST = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — ENV_ALLOWLIST is the load-bearing control that keeps this unauthenticated loopback endpoint from leaking secrets, and its safety is currently by-convention only. Consider a small regression test asserting the allowlist stays secret-free (rejects *_TOKEN / *_KEY / *_SECRET-shaped names), so a future addition of an API-key-bearing var can't silently start echoing on /metrics. Not blocking — the list holds nothing sensitive today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 13353ab — packages/supervisor/test/admin-allowlist.test.ts.

It checks the NAME SHAPE (*_TOKEN, *_KEY, *_SECRET, *_PASSWORD, *_CREDENTIAL) rather than a list
of known-bad names, so it catches the variable nobody thought of — which is the failure you're describing.
Three parts: the allowlist carries no credential-shaped name; /metrics doesn't echo one even when
several are set in the environment it's handed; and the pattern demonstrably matches the names it's meant
to catch, so the absence-assertion isn't vacuous.

It reads the literal from source rather than importing it, since exporting ENV_ALLOWLIST purely for the
test would widen the surface the test exists to protect.

Comment thread packages/knative-server/src/worker.ts Outdated
const runtime = createWorkerRuntime({ send });
const stopStats = startStatsReporter({
send,
intervalMs: Number(process.env.SH_STATS_INTERVAL_MS ?? 1000),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — this is the one numeric env parse outside config.ts's readInt validation. A non-numeric SH_STATS_INTERVAL_MS -> Number(...) -> NaN -> setInterval(fn, NaN) coerces the interval to 0, i.e. a hot timer. It's advisory telemetry and unref'd so the blast radius is tiny, but the same Number.isInteger guard the supervisor config uses would close it cleanly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — extracted as statsIntervalMs, using the same Number.isInteger shape as readInt.

It rejects 0 and negatives as well as non-numbers: both coerce to the same hot timer through
setInterval, so accepting them would leave the door you described open by another route. Tests cover
each rejected form, plus one asserting that Number('abc') really is NaN — an absence-assertion that
has never been shown capable of failing isn't asserting anything.

Comment thread packages/supervisor/src/main.ts Outdated
socket.on('error', () => {
/* peer gone; there is nothing to write and nothing useful to log per connection */
});
void route(socket);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — void route(socket) is fire-and-forget with a live socket in scope and no .catch(). Safe today (the only await is readHead, which never rejects), but if any pre-hand-off code ever throws it becomes an unhandled rejection plus a leaked fd — exactly the failure mode this PR just fixed elsewhere. A .catch(() => socket.destroy()) would future-proof it. (Same shape at the void supervisor.close() on shutdown.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab, together with the fuller version of the same point in the other thread on this line:
.catch() that logs and destroys the socket, and the same treatment for the shutdown
void supervisor.close().

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass on d10a13a. 41 files, +4733/−12, 36 commits — a large diff, so per the large-PR posture I reviewed security, architecture and conventions in full and skipped nit-level style. I read all nine packages/supervisor/src and worker.ts files, deploy/vm/ in full, and audited the 142 added test cases; I did not read every line of the 2,400 lines of test bodies.

Two must-fix, five suggestions. Neither must-fix is in the hand-off machinery — the fd hand-off, the admission ordering, the drain sequence and the estimate/authority split are the strongest parts of this PR and I could not fault them. The blockers are (1) setup-vm.sh publishes an unauthenticated Redis on every interface, and on a VM whose own template sets SH_SANDBOX_DISCOVERY=records that is remote control of where turns execute; and (2) the monotonic-estimate wedge is fixed only for connections that close, so holding S×W connections open still wedges the pool into permanent 429s.

The three self-reported defects, verified

The body reports three Critical defects found by a final whole-branch review. I checked each against the code rather than the description:

Defect Fix verified Note
send()'s boolean read as failure yes — pool.ts:214 sends and never branches on the return; the three real signals (healthy, connected, a thrown exception) are the only ones consulted at :203/:218. Worker side is defended too: worker.ts:112 returns on a handle-less conn instead of throwing out of the message handler. pool.test.ts:100 pins "does not retry when send() returned false" and contrasts it against connected === false (:126) and a throwing send (:144), both asserting handoffRetries === 1. The trigger is a boolean flag on the fake (fake-worker.ts:32), so no test reproduces the measured "3rd handle in one tick" condition — fine, since the durable contract is "never branch on the boolean", and that is what is pinned.
No 'error' listener on accepted sockets yes — main.ts:78, attached in the connection callback before route() runs, so it covers both the refuse() write and the window after readHead's own listener is removed at head.ts:89. main.test.ts:53 drives 25 resetAndDestroy() peers and asserts a later connection still gets its 429. The listener's presence is gated only by vitest's ambient unhandled-error detection, which the test's own comment concedes — and admission.test.ts:124 attaches its own on('error'), so that case cannot detect a missing production listener either. Acceptable, but the guard is weaker than the fix.
Monotonic estimate for non-turn connections partial — see the must-fix on worker.ts:127. Fixed for a connection that closes; a held-open one still holds +1 with nothing able to reconcile it. real-worker.integration.test.ts:166 is the right test against the right target (the real worker, S=1), but every iteration closes its socket, and closing is exactly the trigger the fix depends on.

What else I verified rather than took on trust

  • stickyBySession's pin table is genuinely bounded and remember() deletes-before-set (routing.ts:79), so an id flood evicts itself rather than evicting a live session — the memory bound is not a lever for denying affinity to real traffic.
  • aggregates() does not fabricate readings: NaN until a worker reports (pool.ts:318-322), lease pool size taken as the max rather than the sum (:179), file-op p95 as the worst worker rather than a mean (:189). An averaged p95 would have been the easy mistake.
  • /metrics is loopback-bound and allowlisted (admin.ts:100, :10-20), and setup-vm.test.sh:294 asserts the bind explicitly. The allowlist is the right choice over a denylist for a body that gets pasted into a run record.
  • readConfig rejects SH_ADMIN_PORT === PORT with 0 exempted (config.ts:53), and SH_TURNS_PER_WORKER has no default in either the code or the template — the one knob whose value is an output of E8.
  • The shutdown order is right and the comment explains why it was wrong before (main.ts:137-162): drain, then stop accepting, then a bounded wait, with dataClosed captured eagerly so a 'close' that fires early is not missed. pool.ts:375 zeroing a dead worker's estimate is what stops a crash mid-turn burning the whole grace period.
  • isTurnRequest matches the raw target (worker.ts:41) exactly as server.ts does, so the worker's count describes the same request set the server actually runs.
  • The SH_SANDBOX_DISCOVERY arms are consistent: records forces remoteOn true (and is rejected without SH_REMOTE_SANDBOX=1), pods forces it false, both is byte-for-byte today's behaviour. No dual-mode source mismatch.
  • No hardcoded secrets in the diff; no .github/ or workflow changes, so no Actions-pinning surface. New dependencies are dev-only and every version already appears elsewhere in pnpm-lock.yaml (redis@6.2.1, tsx@4.23.13, vitest@2.1.9).

On deploy/vm/ being unexecuted

The body is straight about this and I want to be equally straight back: the two must-fixes are both in code paths that a single real run would have exercised, and the Redis one is a one-line change. The unit hardening (ProtectSystem=strict + ProtectHome=true + SystemCallFilter=@system-service against node --import tsx, which wants a writable cache) is the other class that only fails at runtime — I have not flagged it because guessing at it would be speculation, but it is the first thing I would expect to break.

Author: pdettori (MEMBER — maintainer)
Areas reviewed: TypeScript (supervisor, knative-server, harness), Shell, systemd units, Docs, Tests, Build
Agent/IDE config (.claude/.vscode): none — grepped both +++ b/ and rename to forms; 0 renames anywhere in the diff
Commits: 36, all signed off, conventional prefixes throughout. One convention slip: dd7ef14 carries Co-Authored-By: alongside Assisted-By: — this repo's convention is Assisted-By: only. 16 subjects exceed 72 characters (advisory).
CI status: passing — 12/12 green on d10a13a (DCO, CodeQL, Trivy, shellcheck, hadolint, dependency-review, lint, proto, deploy-scripts, check)
Base: 694464d; mergeable_state: clean

Comment thread deploy/vm/setup-vm.sh Outdated

start_redis() {
log "starting Redis container"
podman run -d --name sh-redis --replace -p 6379:6379 docker.io/redis:7-alpine

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this publishes an unauthenticated Redis on every interface, and on this VM that is remote control of where turns execute.

-p 6379:6379 binds 0.0.0.0 in podman. The container is redis:7-alpine with no --requirepass, no ACL, and no TLS. So every port on this VM that is not firewalled off gives anyone on the network full read/write access to the store that holds the session log, the ownership index, the lease store and sh:sandbox:records.

Why this is worse here than the usual "exposed Redis" finding. deploy/vm/env/supervisor.env.example:11 ships SH_SANDBOX_DISCOVERY=records, and select-sandbox.ts:119 honours it by never listing pods at all:

const pods = source === 'records' ? [] : await list(selector, namespace, context, deps.run);

On this deployment the only inventory of executors is a set of Redis records. Write to Redis and you choose the sandbox every turn is dispatched to — which means you receive the user's prompts and repository contents and return whatever you like as the agent's output. That is not a data-at-rest exposure, it is the execution path. The 429 admission control, the fail-closed relay token and the RestrictAddressFamilies on both units are all bypassed, because none of them is in the way.

Two things in this repo already say this is wrong. deploy/claude/commands/promote.md:88-91 and demo-promoted-workflow.sh:144-146 both exist because a 0.0.0.0:6379 container in this repo was mistaken for a cluster Redis once already, and they go out of their way to tell an operator never to use 6379. And setup-vm.test.sh:294 asserts that the admin listener binds 127.0.0.1 because it is unauthenticated — the same suite watches podman run ... sh-redis at :366 for ordering and never looks at its bind address. The invariant is already written down for the listener that exposes six counters; it is not applied to the one that exposes session state.

Fix — one line, and nothing needs Redis off-loopback:

podman run -d --name sh-redis --replace -p 127.0.0.1:6379:6379 docker.io/redis:7-alpine

Both templates already point at redis://127.0.0.1:6379 (supervisor.env.example:16, relay.env.example:13), and the sandbox containers reach the relay, not Redis — start_sandboxes gives them RELAY_ADDR/SANDBOX_TOKEN/SANDBOX_ID and nothing else. So no consumer loses access. Worth an assertion in setup-vm.test.sh beside the 127.0.0.1 check at :294, so this cannot come back.

Two smaller things on the same line, both already in your known-limitations list, but they compound: there is no volume, so --replace discards the store on every re-run; and there is no --restart=always, so after a VM reboot both units come up via multi-user.target and Redis does not. Since start_services deliberately enables the units for the next boot, reboot survival looks like an intended property that this line breaks. A quadlet or podman generate systemd would fix persistence, the restart policy and the missing After= in one move.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — -p 127.0.0.1:6379:6379.

You're right that this is the execution path rather than a data-at-rest finding, and the
SH_SANDBOX_DISCOVERY=records argument is what makes it urgent: with pods short-circuited to [],
whoever writes sh:sandbox:records picks the executor for every turn.

Added the assertion you suggested, in both directions — 127.0.0.1:6379:6379 present, bare 6379:6379
absent. One wrinkle worth mentioning: the bare-form guard fired on its own documentation at first,
because the fix quotes the unsafe form in a comment to explain itself. It strips comment lines now.

On the two smaller points: I've left the missing volume and --restart=always alone. You're right that
reboot survival looks intended given start_services enables both units, and a quadlet would fix
persistence, the restart policy and the missing After= together — but that changes how the deployment
is structured rather than correcting one line, and it deserves its own review rather than riding along
with a security fix. It stays on the known-limitations list.

// connection closing while turns are still in flight on other sockets reports the truth
// instead of erasing them. Non-turn requests are still not counted as turns (§3.5) --
// what this adds is a reconciliation signal, not a second count.
socket.once('close', () => send({ type: 'load', inFlight: counter.inFlight }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this closes the wedge for a connection that ends, not for one that is held. S x W open connections still wedge the pool into permanent 429s.

The comment above is an accurate account of the defect and real-worker.integration.test.ts:166 is the right regression pin. But reconciliation now hangs entirely on 'close', and there are exactly two places a load is ever sent — this line and TurnCounter's onChange at :89. So for a connection that is handed off and never issues a turn and never closes, the supervisor's +1 from pool.ts:216 has nothing that can take it back down. pool.ts only ever lowers an estimate in reconcile() (:401, driven by load) or on worker exit (:375).

The wedge is therefore still reachable, and it is self-sustaining for the same reason as before:

  1. Open connections; pickLeastLoaded spreads them, so S x W of them bring every slot's estimate to S.
  2. Issue no turn, and keep each connection alive — a GET /health every few seconds is enough. The worker's http.Server has no timeouts configured (worker.ts:88 passes only a handler), so keepAliveTimeout (5 s) never fires on a socket that keeps making requests and headersTimeout (60 s) resets each time.
  3. isSaturated (admission.ts:19) is now true for every worker, so main.ts:88 refuses every new connection before hand-off.
  4. No turn can arrive, so no load can arrive, so nothing reconciles. The held sockets never close.

At the S=1 the regression test itself uses, that is one connection. The test passes because speak() closes each socket and waits for inFlight to return to 0 — which is precisely the event the fix requires. handoff.integration.test.ts:115's 20 connections are sequential and closing too.

It is also invisible in /metrics while it happens. spurious_refusals is the counter for "we refused when we had capacity", and reconcile() can only increment it on a load reporting lower than refusalEstimate (pool.ts:413). The wedge is the state in which no load arrives at all, so the one counter designed to detect it cannot fire. over_admission stays flat too. From the outside this is a pool that is simply busy.

You do not need an attacker for this: a load balancer or a monitoring probe using persistent connections accumulates the same way, and S is meant to be small.

Fix — report on accept as well as on close, two lines:

accept(socket: Socket, head?: Buffer): void {
  if (socket === undefined || socket === null) return;
  // Reconcile the optimistic +1 from pool.ts:216 within one round trip, whether or not this
  // connection ever runs a turn. Absolute count, so it never erases turns on other sockets.
  send({ type: 'load', inFlight: counter.inFlight });
  socket.once('close', () => send({ type: 'load', inFlight: counter.inFlight }));

The trade is worth stating plainly, because it inverts a bias the design chose on purpose: reporting on accept means a real turn's connection momentarily reports its pre-turn count, so the estimate dips for one IPC round trip and the pool can over-admit. But over-admission is bounded by that round trip, is already accepted by §3.9, and is already counted (over_admission). The wedge is unbounded, uninstrumented, and clears only when a worker crashes. Keeping the 'close' report as well costs nothing and keeps the keep-alive-with-turns case exact.

If you would rather not report on accept, the alternative is to make the optimistic credit expire — decay it after a deadline unless a load confirms it — but that is a timer per hand-off for the same outcome. For the regression test, the case that would have caught this is one held-open GET /health at S=1 followed by a second connection expecting 200, not 429.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — reports on accept as well as on close.

Your analysis is right, including the part I'd missed: reconciliation hung entirely on 'close', so a
held socket had nothing that could ever lower the estimate. The detail that convinced me it was worth
inverting the bias is that the wedge is uninstrumented — spurious_refusals can only rise on a load
reporting lower than the refusal estimate, and the wedge is precisely the state where no load arrives,
so the counter built to detect it cannot fire.

I took the accept-side report rather than the expiring-credit alternative, for the reason you gave: a
timer per hand-off buys the same outcome. The trade is written into the comment, so the next reader sees
that the momentary pre-turn dip is deliberate and bounded, against a wedge that was neither.

Added the regression test in the shape you described — a held-open GET /health at S=1, then a second
connection expecting 200 — and checked it is non-vacuous: removing the accept-side send fails exactly
that test and nothing else. Four unit tests in worker.test.ts gained the accept report in their
expected sequences, each with a note on why the leading 0 is there. One of them, the
absolute-count-on-close test, turned out to demonstrate its own property better afterwards: the probe
socket's accept report reads 1, not 0, because a turn is live on the other socket.

Comment thread deploy/vm/setup-vm.sh Outdated
--add-host host.containers.internal:host-gateway \
-e "SANDBOX_ID=sh-sandbox-$i" \
-e "RELAY_ADDR=$addr" \
-e "SANDBOX_TOKEN=$token" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the shared relay secret goes into podman's argv, where any local user can read it.

-e "SANDBOX_TOKEN=$token" puts the value in the podman run process's command line, and /proc/<pid>/cmdline is world-readable on Linux unless hidepid is set. So for the lifetime of each of the SH_SANDBOX_COUNT invocations, an unprivileged local user running ps in a loop reads the token that require_relay_token just went to some trouble to establish must be a real secret.

The window is short and this needs a local account, which is why it is a suggestion and not a blocker. But the token is the whole of the relay's authentication (makeDefaultValidateToken, fail-closed), so holding it means being able to attach as a sandbox — and per the Redis comment above, being an executor on this deployment is the interesting capability.

Fix — pass by name, not by value:

SANDBOX_TOKEN="$token" podman run -d --name "sh-sandbox-$i" --replace \
  --add-host host.containers.internal:host-gateway \
  -e "SANDBOX_ID=sh-sandbox-$i" \
  -e "RELAY_ADDR=$addr" \
  -e SANDBOX_TOKEN \
  "$SANDBOX_IMAGE"

-e NAME with no = tells podman to take the value from its own environment, so it never appears in argv. Note this changes what the test observes: setup-vm.test.sh:221 and :374 both grep -q -- '-e SANDBOX_TOKEN=<value>' against the mock log, so they currently pin the leaky shape. They would become an assertion that the token is not in argv while the container still receives it — which is the better test anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — -e SANDBOX_TOKEN, with the value taken from podman's own environment.

You called the test consequence correctly, and it turned into the more interesting part. The old
assertions pinned the leaky shape, so they had to change — but argv alone cannot distinguish "passed
safely" from "not passed at all", so the podman mock now records its own SANDBOX_TOKEN too. There are
three assertions where there was one: by-name present, value absent from argv, value actually delivered.
Any two of those would pass a broken implementation.

Comment thread packages/supervisor/src/main.ts Outdated
socket.on('error', () => {
/* peer gone; there is nothing to write and nothing useful to log per connection */
});
void route(socket);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — void route(socket) marks the rejection unhandled, and an unhandled rejection kills the supervisor with every worker it owns.

This is the same failure class as the missing 'error' listener three lines above, arriving by a different door. route is async, so anything it throws becomes a rejected promise; void explicitly declines to handle it, and Node's default unhandledRejection mode is to throw, which ends the process. KillMode=control-group in the unit then takes the whole worker set with it, and Restart=always restarts a supervisor that has lost every in-flight turn.

Today's body looks safe — handOff catches its own send failures (pool.ts:218), readHead only ever resolves, and socket.end() on a dead socket does not throw. So this is a robustness point, not a live bug. But it is load-bearing: config.policy.pick is an injected function (a future policy could throw), and the whole reason the connection callback is a one-line dispatcher is that a supervisor must survive anything one connection can do.

void route(socket).catch((err) => {
  // A supervisor must not die of one connection: an unhandled rejection here ends the process
  // and KillMode=control-group takes every worker with it.
  log({ event: 'route_failed', err: String(err) });
  socket.destroy();
});

socket.destroy() rather than leaking it, on the same reasoning as handOff's "a forgotten socket is a leaked fd" at pool.ts:229.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — void route(socket).catch(...) that logs route_failed and destroys the socket, on
your reasoning that a forgotten socket is a leaked fd.

Also applied to the void supervisor.close() you flagged in the same breath. That one has a sharper
consequence than I first thought: an unhandled rejection there would end the process through the default
handler rather than through the exit path, which skips the drain the close() exists for — killing
in-flight turns systemd was willing to wait for. It exits non-zero on that branch now, so a failed drain
is distinguishable in the journal from a clean stop.

Comment thread packages/supervisor/src/head.ts Outdated
const onData = (chunk: Buffer): void => {
chunks.push(chunk);
total += chunk.length;
if (headerBlockEnd(Buffer.concat(chunks)) !== -1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — this re-concatenates and re-scans the whole accumulation on every chunk, so a peer that dribbles bytes turns a 16 KB read into ~134 MB of work.

Buffer.concat(chunks) runs once per 'data' event over a growing array, and headerBlockEnd then indexOfs the result from the start each time. With MAX_HEAD_BYTES at Node's maxHeaderSize (16384) and a peer writing one byte per packet, that is 1+2+...+16384 bytes copied and roughly the same again scanned — about 134 MB touched per connection, and a chunks array of 16384 buffers, for 16 KB of input. The peer chooses the chunking, so it chooses the multiplier.

Two reasons it is worth fixing rather than tolerating:

  • It is reachable pre-admission and unbounded in concurrency. main.ts:88 decides saturation before the await readHead(socket) at :97, and the estimate only rises at hand-off, so any number of connections can sit in this loop at once. Nothing caps how many.
  • It lands in the number E8 is measuring. This is CPU burned on the routing hop, in the arm whose entire purpose is to compare sticky routing against leastInFlight. A cost that scales with the peer's packet size is the kind of thing that makes a rung unreproducible.

Only the stickyBySession arm reaches it (needsHead), and the shipped template is leastInFlight, which is why this is a suggestion.

Fix — search across the boundary instead of re-concatenating. The terminator can straddle two chunks, so the search has to start 3 bytes back from the previous end, but that is the only subtlety:

let scanned = 0; // bytes already searched, minus the 3-byte overlap
const onData = (chunk: Buffer): void => {
  chunks.push(chunk);
  total += chunk.length;
  const buf = chunks.length === 1 ? chunks[0]! : Buffer.concat(chunks);
  const at = buf.indexOf(TERMINATOR, Math.max(0, scanned - (TERMINATOR.length - 1)));
  scanned = buf.length;
  if (at !== -1) { ... }

That removes the quadratic scan. Removing the quadratic copy too means keeping a running Buffer.concat only at finish() and searching the tail of the last chunk plus a 3-byte carry — slightly more code, and worth it only if you want the sticky arm's routing cost to be flat in packet count. Either way finish() still returns Buffer.concat(chunks), so invariant 1 is untouched.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — each chunk is now scanned once with a carry of at most TERMINATOR.length - 1 bytes,
so the work is linear in the bytes received.

I nearly shipped a half-fix worth naming: my first attempt scanned incrementally but still called
Buffer.concat(chunks) on every event, which removes the re-scan and keeps the re-copy. The version that
landed builds a small probe from the carry plus the new chunk, leaving chunks untouched — so finish()
still concatenates exactly once, and the over-cap bytes are still kept, which the cap path depends on.

Three tests cover the only thing that can regress, the straddling terminator: one byte per packet across
the boundary, the two CRLFs in separate packets, and the cap path fed a byte at a time. Removing the carry
fails exactly the first two.

Your two reasons are both why I fixed it rather than deferring on "only the sticky arm reaches it" — the
pre-admission reachability with nothing capping concurrency, and that it lands in the CPU E8 is measuring
on the very arm the comparison is about.

const lease = deps.lease ?? new RedisLeaseStore(env.REDIS_URL);

const pods = await list(selector, namespace, context, deps.run);
const source = resolveDiscoverySource(env, opts.remoteSandbox === true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — this PR adds two config knobs and validates them at different times: SH_ROUTING_POLICY at boot, SH_SANDBOX_DISCOVERY once per turn.

resolveDiscoverySource throws on an unrecognised value, and it is called from exactly one place — here, inside selectPoolSandbox, which run-leaf.ts reaches on the turn path. Nothing calls it at startup; I grepped every reference. So SH_SANDBOX_DISCOVERY=record (or grpc, which the test at select-sandbox.test.ts:242 uses as its example of a rejected value) boots cleanly, passes /health, and fails every turn.

Two doors away, policyFromName throws the same shape of error for SH_ROUTING_POLICY and readConfig calls it before createServer, so a typo there is a crashloop with the reason in the journal. Same PR, same kind of enum, opposite failure mode.

This is not hypothetical for the target deployment: deploy/vm/env/supervisor.env.example:11 sets SH_SANDBOX_DISCOVERY=records explicitly, so it is a value an operator edits, not one they inherit. And it is the pattern #249 closed a must-fix on — a keyset parsed per request rather than at boot, giving "failure arrives per request on a healthy-looking deployment". assertKeysetUsable was the fix there; the same one-line shape works here:

// In the supervisor's startup path, beside readConfig(process.env):
resolveDiscoverySource(process.env, process.env.SH_REMOTE_SANDBOX === '1');

Keeping the per-turn call as well is right — it is what catches the env changing under a running process. The point is only that a typo should be a boot error, and the records-without-SH_REMOTE_SANDBOX=1 check at :44 deserves the same treatment: that combination is a static property of the unit file, and discovering it on the first turn wastes a whole bring-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab, but not where you suggested, and the reason is worth stating.

resolveDiscoverySource lives in @sh/harness, and packages/supervisor ships dependencies: {}
deliberately — the parent is the one process that must never die of a dependency. So the check went into
the worker's boot path instead, which already depends on the harness and is also where
assertKeysetUsable lives, i.e. the precedent you cited. A throw there still surfaces as a crashloop
with the reason in the journal, via the supervisor's restart backoff.

The per-turn call stays, on your reasoning that it's what catches the env changing under a running
process. And the records-without-SH_REMOTE_SANDBOX=1 combination is covered by the same call, which I
agree it deserves: that's a static property of the unit file.

If you'd rather it sat literally beside readConfig, the cost is a dependency edge from the supervisor to
the harness, and I'd want that decided on its own merits rather than as a side effect of this fix.

import { describe, it, expect } from 'vitest';
import { RedisSessionBackend } from '@sh/session-backend';

const REDIS_URL = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — this integration test has no guard, so pnpm test in packages/supervisor fails rather than skips on any machine without a live Redis.

vitest.config.ts includes test/**/*.test.ts, so this runs unconditionally, and RedisSessionBackend here will refuse to connect. CI hides it because CI has Redis — which is exactly why it is worth a guard: the contributor who hits it is someone cloning the repo for the first time, and what they see is a red suite with a connection error rather than "1 skipped".

The sibling file already establishes the convention. handoff.integration.test.ts:81 uses it.skipIf(process.platform !== 'linux') for its /proc/self/fd dependency, with a comment explaining that the case would fail rather than skip without it. Same reasoning applies to an external service:

const hasRedis = process.env.REDIS_URL !== undefined || process.env.SH_TEST_REDIS === '1';
describe.skipIf(!hasRedis)('session survives the worker that started it', () => {

Gating on REDIS_URL being set rather than probing keeps it deterministic, and it makes the requirement explicit instead of implicit in a ?? default. The property this test pins — a session outliving the worker that wrote it — is worth keeping; it just should not be the reason a fresh checkout looks broken.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13353ab — describe.skipIf(!hasRedis), gated on REDIS_URL being set with SH_TEST_REDIS=1 as
the escape hatch, following handoff.integration.test.ts's convention.

Gating on the variable rather than probing, exactly as you suggested: it keeps the requirement explicit
instead of implicit in the ?? default. The suite now reports 2 skipped rather than failing on a machine
without Redis.

…and six more

Ten review comments: two must-fix, seven suggestions, one nit. All addressed.

## must-fix: Redis published on every interface

`-p 6379:6379` binds 0.0.0.0 in podman, and the image runs with no --requirepass, no
ACL and no TLS. On THIS deployment that is the execution path, not data at rest:
env/supervisor.env.example ships SH_SANDBOX_DISCOVERY=records, and select-sandbox.ts
honours it by never listing pods, so the only inventory of executors is a set of Redis
records. Whoever can write them chooses the sandbox every turn dispatches to --
receiving the user's prompts and repository contents and returning whatever they like
as the agent's output. Admission control, the fail-closed relay token and
RestrictAddressFamilies are all bypassed because none of them sits in that path.

Now `-p 127.0.0.1:6379:6379`. Nothing loses access: both env templates already point at
redis://127.0.0.1:6379, and the sandbox containers reach the RELAY, not Redis. The
invariant was already written down for the admin listener; it simply had not been
applied to the listener that exposes session state. setup-vm.test.sh now asserts both
directions, with comment lines stripped first because the script deliberately quotes
the unsafe form in a comment (the guard fired on its own documentation at first).

## must-fix: the wedge was only closed for connections that END

The close-only `load` report left the HELD case open. S x W sockets handed off, issuing
no turn, and kept alive drive every slot's estimate to S; main.ts then refuses before
hand-off, so no turn arrives, so no `load` arrives, so nothing reconciles -- 429s until
a worker crashes. At S=1 that is ONE held connection, and it needs no attacker: a load
balancer or monitoring probe on a persistent connection does it by accident. It is also
invisible while it happens, because `spurious_refusals` can only rise on a `load`
reporting lower than the refusal estimate, and the wedge is the state where no `load`
arrives.

Now reported on accept as well as on close. The trade, stated in the comment because it
inverts a deliberate bias: a real turn's connection momentarily reports its pre-turn
count, so the estimate dips for one IPC round trip and the pool can over-admit -- but
that is bounded by the round trip, accepted by §3.9, and already counted, where the
wedge was unbounded and uninstrumented.

The new integration test holds a keep-alive connection open with no turn on it and
asserts the estimate still returns to 0 and a second connection still gets 200. Verified
non-vacuous: removing the accept-side report fails exactly that test and nothing else.
Four unit tests' expectations gained the accept report, each with a note on why.

## suggestions and nit

- `void route(socket)` now has a `.catch` that logs and destroys the socket. `route` is
  async, so a throw is a rejected promise; unhandled, Node's default ends the process,
  KillMode=control-group takes every worker, and Restart=always brings back a supervisor
  that lost every in-flight turn. `config.policy.pick` is injected, so this is reachable.
  Same treatment for `void supervisor.close()` on shutdown, where an unhandled rejection
  would skip the drain the close() is for; it exits non-zero so a failed drain is
  distinguishable in the journal.
- SH_STATS_INTERVAL_MS was the one numeric env parse outside config.ts's readInt. NaN ->
  setInterval(fn, NaN) -> a hot timer in the process whose event-loop lag E8 measures.
  Now `statsIntervalMs`, rejecting non-integers and non-positives, with tests including
  one proving the old expression really did yield NaN.
- SH_SANDBOX_DISCOVERY is now validated at BOOT. Its only caller was on the turn path, so
  a typo booted cleanly, passed /health, and failed every turn -- the shape rossoctl#249 closed as
  a must-fix, with SH_ROUTING_POLICY validated at boot two doors away. Placed in the
  worker rather than the supervisor because resolveDiscoverySource lives in @sh/harness and
  packages/supervisor ships `dependencies: {}` deliberately; the worker already depends on
  the harness and is where assertKeysetUsable, the cited precedent, lives. A throw there
  still surfaces as a crashloop with the reason in the journal.
- The relay token no longer enters podman's argv. `-e SANDBOX_TOKEN` (no `=`) takes the
  value from podman's own environment, so `ps` cannot read what /proc/<pid>/cmdline used
  to expose. The tests now assert all three halves -- by-name present, value absent from
  argv, value actually delivered -- which needed the podman mock to record its own
  environment, since argv alone cannot tell "passed safely" from "not passed at all".
- resume.integration.test.ts now skips without REDIS_URL instead of failing. vitest picks
  it up unconditionally, so a fresh clone saw a red suite with a connection error rather
  than "1 skipped"; CI hid it by having Redis. Gated on the variable rather than a probe,
  following handoff.integration.test.ts's convention.
- New admin-allowlist.test.ts pins ENV_ALLOWLIST secret-free by NAME SHAPE rather than a
  list of known-bad names, so it catches the variable nobody thought of, plus a
  behavioural check that /metrics filters rather than passing the environment through, plus
  a check proving the pattern matches the names it is meant to catch.

- head.ts no longer re-concatenates and re-scans the whole accumulation on every 'data'
  event. The peer chose the multiplier: at one byte per packet up to MAX_HEAD_BYTES that is
  1+2+...+16384 bytes copied and about as many scanned, ~134 MB touched plus a 16384-entry
  array for 16 KB of input. It is reachable BEFORE admission (main.ts decides saturation
  before awaiting readHead) with nothing capping concurrency, and it lands in the CPU E8
  measures on the routing hop -- in the arm whose whole purpose is comparing sticky routing
  against leastInFlight. Each chunk is now scanned once with a <=3-byte carry, so the work is
  linear; `chunks` is untouched, so finish() still concatenates once and the over-cap bytes
  are still kept. Three tests cover the only thing that can regress -- a terminator
  straddling a boundary -- and removing the carry fails exactly those two.

Verified: harness 403 passed, supervisor 110 passed / 2 skipped, knative-server 331
passed, test-deploy 23 ok, typecheck and lint clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
pdettori added a commit to pdettori/moca that referenced this pull request Sep 14, 2026
Takes deploy/vm/setup-vm.sh and its test from feat/p6-vm-process-manager (PR rossoctl#250's
review fixes), so a box provisioned for measurement gets them:

  - Redis publishes on 127.0.0.1 rather than every interface. On this deployment that
    is the execution path, not data at rest: SH_SANDBOX_DISCOVERY=records means the
    only inventory of executors is a set of Redis records, so whoever can write them
    picks the sandbox every turn dispatches to.
  - The relay token is passed to podman BY NAME, so it never enters argv where
    /proc/<pid>/cmdline exposes it to any local user.

A measurement box is a real box on a real network for hours; it should not be running
the pre-review configuration. Taken as files rather than by merge to avoid dragging in
the worker.ts/admin.ts conflicts between the two branches, which are unrelated to this.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review of the P6 supervisor slice. 10 findings, in four themes.

1. The worker boot path replaces startServer() and drops two things it did. worker.ts uses bare createServer(handler) and never listens, so it loses assertKeysetUsable() (the boot-time keyset check #249 added, which this file's own comment cites as its precedent) and loses the SIGTERM handler. The second one matters most: with KillMode=control-group, systemd SIGTERMs every worker at the same instant as the supervisor, so the carefully ordered 20s drain in main.ts::close() drains a pool that is already dead and §3.9's "in-flight turns run to completion" never happens on the real deployment.

2. Three VM gaps that bring up green and fail later. No Environment=HOME= on sh-supervisor.service (every k8s manifest that runs this code sets HOME=/tmp deliberately — service.yaml:33, leaf-scaledjob.yaml:39, control-plane.yaml:190 — and the unit adds ProtectHome=true on top of a --no-create-home user); Redis and the sandbox containers have no restart policy while the supervisor is WantedBy=multi-user.target, so a reboot brings back the supervisor and nothing it talks to; and the README's token step cannot be followed in the order it is written.

3. A one-line wedge in accept(). The handle-less guard returns above the load reconciliation, so the supervisor's optimistic +1 is never corrected — the exact unbounded, uninstrumented 429 wedge that the 30-line comment immediately below it exists to close.

4. Two findings that corrupt E8's own instrumentation. route() ignores readHead's 'closed' outcome and hands off dead sockets, charging W handoff_retries + 1 handoff_failures per abandoned peer — at exactly the saturation knee where peers abandon. And when every worker goes unhealthy between pick and handOff, the connection is destroyed rather than refused, so a driver sees a reset instead of the 429 + Retry-After the identical condition produces one branch earlier, and noteRefusal() is skipped so it cannot be reconciled as spurious.

Credit where due: readHead's incremental carry-scan and cap-vs-terminator ordering are correct, handOff's refusal to branch on send()'s return value is right for the reason recorded, stickyBySession's delete-before-set LRU is correct, reconcile's Number.isFinite value guard is the right shape, close()'s eagerly captured once(server, 'close') avoids the race it names, isTurnRequest does mirror server.ts:641 exactly, and setup-vm.sh's || true / &&-list constructs are errexit-safe.


One finding could not be anchored inline (the line is outside this PR's diff hunks for that file):

harness/src/select-sandbox.ts:141 — low. In the new records mode pods is forced to [], so when sh:sandbox:records is empty the thrown message is still no Running pods for pool selector '<selector>' — the exact misdirection that resolveDiscoverySource's own guard at :43-49 was added to prevent ("Blame the flag, not the pool… which sends them debugging a healthy pool"). records is the shipped VM default (env/supervisor.env.example:11), and "no sandbox container has attached to the relay yet" is the single most likely first-run failure there, so this is the message operators will actually hit. The message should branch on source.

// crashloop with the reason in the journal, via the supervisor's restart backoff. The records-
// without-SH_REMOTE_SANDBOX=1 combination is covered by the same call, and deserves to be: it is a
// static property of the unit file, and discovering it on the first turn wastes a whole bring-up.
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — boot-time keyset validation is lost on this path. This block validates SH_SANDBOX_DISCOVERY but never calls assertKeysetUsable(process.env), which startServer() (server.ts:657) calls "before anything binds". The worker deliberately bypasses startServer (bare createServer(handler), never listens), so under the supervisor a malformed SH_SESSION_TOKEN_PUBLIC_KEYS no longer crashes at boot: every worker reports ready, serves GET /health, and 503s every authenticated /turn.

That is exactly the "failure arrives per request on a healthy-looking deployment" shape #249 closed — and the comment at :253 cites assertKeysetUsable as this block's own precedent while omitting the call.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 — assertKeysetUsable(process.env) now runs in the worker's boot block, in the same try/catch as resolveDiscoverySource, so a malformed keyset exits 2 with the reason in the journal.

Confirmed the gap was exactly as described: startServer is the only caller, and the worker never goes through it. The comment at :253 now says why the call has to be explicit here rather than treating the citation as sufficient.

Pinned by a new case in real-worker.integration.test.ts — the boot block only runs under the main-module guard, so no .mjs fixture can reach it. It forks the real worker with SH_SESSION_TOKEN_PUBLIC_KEYS=garbage and asserts worker_exit code: 2 and that no worker ever read healthy.

});
// Supervisor crash ⇒ the IPC channel closes ⇒ we exit, so systemd restarts the whole set
// rather than leaving orphaned workers holding sockets nobody routes to (§6).
process.on('disconnect', () => process.exit(0));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — no SIGTERM handler, so the supervisor's drain never runs on the real deployment. The worker installs only a disconnect handler, while sh-supervisor.service:24 sets KillMode=control-group. systemd's stop job sends SIGTERM to every process in the cgroup, so on systemctl stop/restart all W workers receive SIGTERM simultaneously with the supervisor and die immediately on Node's default disposition.

main.ts::close()'s carefully ordered sequence — drainAll() → stop accepting → awaitIdle(SHUTDOWN_GRACE_MS) — then drains a pool whose workers are already gone, and §3.9's "in-flight turns run to completion" never happens. startServer() did have a SIGTERM handler; this path dropped it.

Fix is either KillMode=mixed (SIGTERM to the main process only, which is what the supervisor's own drain assumes) or a worker-side SIGTERM handler that defers to drain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 with KillMode=mixed, the first of the two options you named.

Took that one rather than a worker-side SIGTERM handler because mixed is what the drain already assumes: close() runs drainAll → stop accepting → awaitIdle, and workers exit on their own via disconnect once it finishes, so a worker-side handler would be a second path to the same state. mixed still SIGKILLs the whole tree at TimeoutStopSec, which is what control-group was there for.

setup-vm.test.sh now asserts KillMode=mixed is present and control-group is absent, so this cannot silently revert.

One knock-on: main.ts's unhandled-rejection comment cited KillMode=control-group as the mechanism that takes the workers down with the supervisor. That is now the disconnect handler instead, and the comment says so.

Left sh-relay.service on control-group — it is a single process with no fork tree and no ordered drain, so mixed and control-group are the same thing there.

Comment thread packages/knative-server/src/worker.ts Outdated
// `undefined` as a connection throws `TypeError: Cannot convert undefined or null to
// object` out of the message handler, uncaught -- killing a worker that may be
// multiplexing S turns. A supervisor bug must cost one connection, not the process.
if (socket === undefined || socket === null) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium-low — this guard returns above the reconciliation it needs to run. The handle-less check returns before the send({ type: 'load', … }) a few lines below. handOff has already credited slot.inFlight += 1 for this connection, so every handle-less conn raises the supervisor's estimate permanently.

At SH_TURNS_PER_WORKER=1 a single one saturates the slot: every later connection is refused before hand-off, so no turn can arrive, so no load can arrive — the precise unbounded wedge the 30-line comment immediately below exists to close, and equally invisible in spurious_refusals for the same reason it gives.

Moving the send above the guard fixes it at no cost: reporting the absolute current count is safe whether or not the handle arrived.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 — the send moved above the guard, as suggested. Your reasoning holds exactly: handOff credits slot.inFlight += 1 before the child ever sees the message, and the absolute count is safe to report whether or not the handle arrived.

Regression test in worker.test.ts: accept(undefined) must produce exactly [{type: "load", inFlight: 0}]. Before the move that array is empty, so the test fails on the ordering rather than on the crash the neighbouring test already covers.

The comment now records that the ordering is deliberate, since "guard first" is the reading someone would otherwise restore.

let head: Buffer | undefined;
let sessionId: string | undefined;
if (config.policy.needsHead) {
const read = await readHead(socket);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — readHead's 'closed' outcome is ignored, so dead sockets are handed off. read.outcome is consulted only to count 'cap'. When a peer hangs up or RSTs during the sticky-arm pre-read, readHead resolves 'closed' and this code proceeds to handOff a dead socket: child.send(msg, socket) throws, the catch at pool.ts:218 swallows it, and the loop walks every remaining worker.

So one abandoned connection charges W handoff_retries plus one handoff_failures. Those are E8's own instrumentation counters, and client-side abandonment is common at exactly the saturation knee E8 measures — so the numbers stop meaning what PoolCounters documents them to mean.

if (read.outcome === 'closed') return; before pick is the fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 with if (read.outcome === 'closed') { socket.destroy(); return; } before pick, as suggested.

The diagnosis is right and the severity is understated — this is not "low". I measured the pre-fix path (reverted the fix, one abandoned connection, SH_WORKERS=2, sticky):

COUNTERS {"restarts":1,"handoffRetries":0,"handoffFailures":0,...}
LOGS     [{"event":"worker_exit","id":0,"code":1,"ranForMs":116,"restartInMs":250}]

child.send(msg, deadSocket) does not throw, so handOff's catch never runs, no retry loop happens, and both counters you predicted would move stay at zero. The child receives a conn whose handle it cannot use and exits — so one connection the client abandoned killed a worker and every turn it was multiplexing. Same class as Critical #1 in the PR description, reached from the other end.

So the counter-integrity argument holds a fortiori, and there is a worse consequence behind it. Pinned in handoff.integration.test.ts on restarts === 0, with the two zero counters asserted as well precisely because they are the ones that do not move. The test drives a live sticky request first so the zeros cannot pass vacuously, and reverting the fix fails it.

Chasing this also surfaced a separate defect on the shutdown path, which is why the same commit touches drainAll. Details in the PR comment.

}
// Never just drop it: a forgotten socket is a leaked fd, and on a saturation ladder that
// is the leak that ends the run.
this.tally.handoffFailures += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — a lost race here answers with a reset where the same condition answers 429 one branch earlier. When every worker goes unhealthy between policy.pick() and handOff (a restart window — which main.ts:124 treats as a 429 case), the loop exits and socket.destroy() gives the client an ECONNRESET/empty reply rather than the 429 + Retry-After that route() produces for the identical state.

It also skips pool.noteRefusal(), so the refusal is never stamped and cannot later be reconciled as spurious. A driver cannot distinguish this from a supervisor crash, and on an E8 rung a bare reset is a different data point from a clean back-pressure signal.

The "never just drop it" reasoning in the comment is right about the fd; the gap is the missing response.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 — noteRefusal() + refuse(socket) in place of socket.destroy().

Both halves confirmed: it is the same state main.ts:124 answers 429 for, and the missing noteRefusal() meant this branch's refusals could never be counted as spurious. refuse() ends the socket and bounds the fd with REFUSAL_LINGER_MS, so the "never just drop it" property the comment is about is still closed — by a different mechanism, which the comment now says.

The existing test was renamed and extended: it asserts the 429 and Retry-After are written, destroy() is not called, and then that a later load reporting below the refusal estimate increments spurious_refusals — which is the only externally observable proof noteRefusal() ran. fakeSocket gained resume/end/once for it.

Comment thread packages/supervisor/src/config.ts Outdated
// `assertKeysetUsable` -- the precedent for "validate at boot, not per request" -- already lives.
return {
port,
workers: readInt(env, 'SH_WORKERS', cpus().length, { min: 1 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — bounds are applied to parsed values but not to fallbacks. readInt returns fallback unchecked at :30. SH_WORKERS passes cpus().length with { min: 1 }, and os.cpus() is documented as possibly returning an empty array.

With SH_WORKERS unset on such a host the supervisor boots successfully, logs supervisor_listening … workers: 0, forks nothing, and isSaturated([]) is true by design — so it answers 429 to every request forever, with no error anywhere.

This also falsifies the :24-28 comment's claim that "no reader has to work out whether the value in that slot is a legal one": for a computed fallback nothing checks it. Validating fallback against bounds too would close it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10, both halves.

readInt now validates fallback against bounds as well, so the :24-28 claim is true of computed defaults and not only of values an operator typed. And SH_WORKERS' fallback goes through a new exported defaultWorkers(cpus().length) that clamps to 1, so the value is legal by construction.

Clamped rather than left to throw: a host where os.cpus() returns [] has a broken CPU count, not a broken config, and one worker is a working deployment — whereas a supervisor that refuses to boot over it trades a silent wedge for a loud outage the operator cannot act on. That makes the new bounds check unreachable for SH_WORKERS, which is the same relationship the undefined throw already has with readConfig's blank check for SH_TURNS_PER_WORKER; both are nets for the next default, and the comment now says so explicitly.

Tested via defaultWorkers(0) === 1 — a pure function so the empty-cpus case needs no node:os mock — and the existing default assertion now reads defaultWorkers(cpus().length).

# each package's own node_modules, never root-hoisted -- a CWD of the repo root crashes with
# ERR_MODULE_NOT_FOUND 'tsx'. Do not "simplify" this back to the repo root.
WorkingDirectory=/opt/serverless-harness/packages/supervisor
EnvironmentFile=/etc/serverless-harness/supervisor.env

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — no Environment=HOME=, unlike every manifest that runs this same code. deploy/knative/service.yaml:33, leaf-scaledjob.yaml:39 and control-plane.yaml:190 all set HOME=/tmp deliberately, with a writable mount behind it. (relay-deployment.yaml does not — consistent with this being a harness-side need, i.e. this unit's, not the relay's.)

Here the unit runs as User=harness (README recommends useradd --system --no-create-home) with ProtectHome=true and ProtectSystem=strict, so $HOME is a nonexistent or masked path and the only writable locations are PrivateTmp's /tmp and StateDirectory. Any turn that writes agent/session state under $HOME fails at runtime — after a completely green bring-up.

setup-vm.test.sh:86 asserts the hardening directives are present, but nothing asserts HOME, so the test suite cannot see this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10 — Environment=HOME=/tmp, matching the three manifests you cite, with PrivateTmp=true already below it supplying the writable path.

Followed your read on the relay: relay-deployment.yaml sets no HOME, so this is a harness-side need and sh-relay.service is left alone.

setup-vm.test.sh now asserts two things, since the second is what the manifests actually pair: that Environment=HOME= is present, and that whatever it points at is writable under this unit's own sandboxing — /tmp (PrivateTmp) or /var/lib/serverless-harness (StateDirectory). Anything else is the same runtime failure with an extra step, and the hardening loop could not have caught it because it only asserts what is present.

Comment thread deploy/vm/setup-vm.sh Outdated
# The invariant was already written down for the admin listener (setup-vm.test.sh asserts it binds
# 127.0.0.1 *because* it is unauthenticated). It simply had not been applied to the listener that
# exposes session state, the ownership index, the lease store and sh:sandbox:records.
podman run -d --name sh-redis --replace -p 127.0.0.1:6379:6379 docker.io/redis:7-alpine

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — nothing brings these containers back after a reboot. This podman run -d (and the ones in start_sandboxes) carry no --restart policy and get no generated systemd unit, while sh-supervisor.service is WantedBy=multi-user.target.

So after a VM reboot the supervisor and relay come back and Redis and every sandbox container do not: sh:sandbox:records is empty and every turn fails until an operator re-runs setup-vm.sh. The unit's comment ("The client retries on connect, so ordering against Redis is not load-bearing") is true of ordering but does not cover a container that never starts at all.

Redis also has no volume, so session state is lost on reboot regardless — worth stating explicitly if it is intended for this round.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10, and it needed both halves rather than just --restart.

--restart=always is now on Redis and every sandbox container, but podman-run(1) is explicit that it does not restart containers after a host reboot — so setup-vm.sh also enables podman-restart.service, podman's documented mechanism for exactly that. Enabled before start_redis/start_sandboxes so a container started in between is already covered. Not fatal if the unit is missing: that is one podman package's name, and such a host has a working bring-up plus a documented reboot gap, so it warns with the consequence named instead of aborting under set -e.

Your point about the unit's comment stands — it was about ordering and said nothing about a container that never starts.

On Redis's missing volume: intended for this round, and now said out loud rather than implied. start_redis records it, and README.md has a new "Reboots" section stating that --restart=always and podman-restart.service bring the container back and not the data in it.

setup-vm.test.sh asserts every podman run carries --restart=always (with a non-zero guard so it cannot pass vacuously), that podman-restart.service is enabled, that a missing one warns about the reboot rather than failing, and that both the script and the README state the volume gap.

Comment thread deploy/vm/README.md Outdated
default), so `setup-vm.sh` checks the _installed_ `relay.env` for a non-empty
`SH_RELAY_TOKEN` before starting any sandbox container, and refuses to continue with a clear
message if it is missing, rather than starting containers that can never attach. Set it before
running `setup-vm.sh`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — this instruction cannot be followed in the order it is written. "Set it before running setup-vm.sh" followed by … | sudo tee -a /etc/serverless-harness/relay.env: on a fresh VM neither that directory nor that file exists until setup-vm.sh's install_env creates them, so the tee -a fails.

The actual sequence is: run the script once (it installs both env files, then require_relay_token aborts before install_units/start_redis/start_sandboxes/start_services), edit the token, re-run. The numbered 1–4 list at lines 38-50 also reads as though one invocation completes all four steps, which it never does on a first run.

The parenthetical at line 105 gestures at this, but the imperative above it still points the operator at a path that does not exist yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f6c4f10. Both problems you identified, and the fix is to document the real sequence rather than reword the imperative.

"Bring it up" now opens by stating that a first run takes two invocations by design, with the actual three commands: run the script (it stops at the token check), edit relay.env, re-run. It also says the first invocation's non-zero exit is the expected path and not something to debug.

The numbered list is now 1–5 with the token check as its own step 2, so the ordering is visible instead of implied — everything below it runs only on the second invocation.

The token section drops the tee -a imperative for sudoedit after the first run, and mentions appending only as an alternative "once the file exists", stating that the directory and file do not exist before install_env creates them.

@cwiklik

cwiklik commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Review summary — 10 findings

Full inline comments are in review 5202806698, anchored to 13353ab. Scope reviewed: refs/pull/250/head vs merge-base 694464d4 — 38 commits, 43 files, +5168/−12.

Theme 1 — the worker path dropped two things startServer() did

The supervisor rewrite has the worker deliberately bypass startServer() (bare createServer(handler), never listens). Two pieces of safety behavior that lived in startServer didn't come along:

# Location Issue
1 packages/knative-server/src/worker.ts:260 Boot validates SH_SANDBOX_DISCOVERY but never calls assertKeysetUsable(process.env). A malformed SH_SESSION_TOKEN_PUBLIC_KEYS no longer crashes at boot — every worker reports ready and serves GET /health while 503-ing every authenticated /turn. That is the same per-request-failure-on-a-healthy-looking-deployment shape #249 closed, and the comment at :253 cites assertKeysetUsable as its own precedent while omitting the call.
2 packages/knative-server/src/worker.ts:291 No SIGTERM handler (only disconnect), while sh-supervisor.service:24 sets KillMode=control-group. systemd SIGTERMs every process in the cgroup, so on systemctl stop/restart all workers die immediately on Node's default disposition — main.ts's ordered close() then drains an already-empty pool and §3.9's "in-flight turns run to completion" never happens on the real deployment. Wants KillMode=mixed or a worker-side handler that defers to drain.

Theme 2 — three VM gaps that pass bring-up and fail later

# Location Issue
3 deploy/vm/systemd/sh-supervisor.service:19 No Environment=HOME=..., while deploy/knative/service.yaml:33, leaf-scaledjob.yaml:39 and control-plane.yaml:190 all set HOME=/tmp with a writable mount. The unit runs as User=harness (README recommends --no-create-home) with ProtectHome=true and ProtectSystem=strict, so $HOME is nonexistent or masked. setup-vm.test.sh:86 asserts the hardening directives but nothing asserts HOME.
4 deploy/vm/setup-vm.sh:153 sh-redis and the sandbox containers get no --restart policy and no generated unit, but the supervisor is WantedBy=multi-user.target. After a reboot the supervisor and relay return, Redis and the sandboxes don't, sh:sandbox:records is empty, and every turn fails until someone re-runs setup-vm.sh. The unit's "ordering against Redis is not load-bearing" comment is true of ordering but not of a container that never starts. Redis also has no volume, so session state is lost on reboot regardless.
10 deploy/vm/README.md:99 "Set it before running setup-vm.sh" followed by tee -a /etc/serverless-harness/relay.env — but that directory doesn't exist until install_env creates it, so the documented first step fails on a fresh VM. Real sequence: run once (aborts at require_relay_token after installing env files), set the token, re-run. The 1–4 list at README:38-50 also reads as though one invocation completes all four steps.

Theme 3 — the cheapest fix, worst failure mode

# Location Issue
5 packages/knative-server/src/worker.ts:133 accept()'s handle-less guard returns before the send({type:'load'}) reconciliation two lines below, but handOff already credited slot.inFlight += 1. At SH_TURNS_PER_WORKER=1 one handle-less conn saturates the slot permanently: connections are refused before hand-off, so no turn arrives, so no load arrives. That's the exact wedge the 30-line comment below it exists to close, and it's equally invisible in spurious_refusals. Moving the send above the guard fixes it at no cost.

Theme 4 — two findings that corrupt E8's own instrumentation

Both matter because E8 measures the saturation knee, which is precisely where client abandonment and restart windows are common.

# Location Issue
6 packages/supervisor/src/main.ts:110 route() uses read.outcome only to count 'cap', never 'closed'. A peer that hangs up mid-pre-read is handed off as a live socket: child.send throws, pool.ts:218 swallows it, and the loop walks every worker — charging W handoff_retries + one handoff_failures for one abandoned connection. Fix: return early on 'closed' before pick.
7 packages/supervisor/src/pool.ts:231 When all workers go unhealthy between policy.pick() and handOff, handOff calls socket.destroy() — while the same condition caught one branch earlier at main.ts:124 returns 429 + Retry-After. The client gets an ECONNRESET indistinguishable from a supervisor crash, and noteRefusal() is skipped so it can't be reconciled as spurious.

Also

# Location Issue
8 packages/supervisor/src/config.ts:65 readInt bounds only env-parsed values; fallback returns unchecked at :30. SH_WORKERS passes cpus().length with {min: 1}, and os.cpus() may return []. On such a host the supervisor boots, logs workers: 0, forks nothing, and isSaturated([]) is true by design — 429 to every request forever, no error logged.
9 harness/src/select-sandbox.ts:141 (could not be anchored inline — outside the diff hunks) In records mode pods is forced to [], so an empty sh:sandbox:records still throws no Running pods for pool selector '<selector>' — the misdirection resolveDiscoverySource's own guard at :43-49 was added to prevent. records is the shipped VM default (env/supervisor.env.example:11) and "no sandbox has attached to the relay yet" is the likeliest first-run failure there, so this is the message operators will actually hit. Should branch on source.

Checked and correct

readHead's incremental carry-scan and cap-vs-terminator ordering (no truncated-header mispin is reachable, since sessionIdFromHead is only consulted when complete); refuse()'s resume() + unref'd linger + clearTimeout against pauseOnConnect: true; handOff's refusal to branch on send()'s return value; stickyBySession's delete-before-set LRU; reconcile's Number.isFinite guard; close()'s eagerly captured once(server, 'close'); isTurnRequest's exact parity with server.ts:641; and setup-vm.sh's errexit-safe || true / &&-list constructs.

…, and one more

All nine comments on rossoctl#250 verified against the code and fixed. Two turned out
worse than reported; one new defect surfaced while pinning them.

Boot-time validation (medium): worker.ts bypasses startServer, so rossoctl#249's
assertKeysetUsable never ran on this deployment — a malformed
SH_SESSION_TOKEN_PUBLIC_KEYS booted clean and 503'd every authenticated /turn.
Called it alongside the existing SH_SANDBOX_DISCOVERY check.

Shutdown drain (medium): KillMode=control-group SIGTERMed every worker
alongside the supervisor, and worker.ts installs no SIGTERM handler, so
close()'s ordered drainAll -> stop accepting -> awaitIdle drained a pool that
was already dead. KillMode=mixed, which is what that sequence assumes.

Handle-less conn (medium-low): accept() returned above the load report, so
handOff's +1 credit for a handle-less conn was permanent — the same unbounded
wedge the 30-line comment below it closes. Report moved above the guard.

Abandoned pre-read (reported low, measured worse): readHead's 'closed' outcome
was ignored. The predicted cost was W handoff_retries plus a failure; measured,
send() does not throw at all, so both counters stay at zero and the WORKER dies
instead (worker_exit code 1, restarts 1) — every turn it multiplexed with it.
Return early.

Hand-off exhaustion (low): destroy() gave an ECONNRESET where main.ts answers
429 for the identical state, and skipped noteRefusal() so the refusal could
never reconcile as spurious. noteRefusal + refuse.

Computed fallbacks (low): readInt returned fallback unchecked; os.cpus() may be
empty, so SH_WORKERS could be 0 — forks nothing, isSaturated([]) is true, 429s
forever with no error. Bounds now cover fallbacks, and defaultWorkers clamps.

HOME (medium): every Knative manifest running this code sets HOME=/tmp with a
writable mount; the unit set none under ProtectHome=true and a --no-create-home
user. Environment=HOME=/tmp, paired with the existing PrivateTmp.

Reboots (medium): the podman containers had no --restart and no generated unit
while both services are WantedBy=multi-user.target. --restart=always plus
podman-restart.service (podman-run(1): --restart alone does not survive a
reboot), non-fatal if that unit is absent. Redis's missing volume is now stated
rather than discovered.

README (low): the token instruction tee'd into a file setup-vm.sh had not
created yet, and the 1-4 list read as one invocation. Documents the real
two-pass first run.

Additionally, and not from the review: ChildProcess.send() to a closed channel
does not throw — it emits 'error' a tick later, so every try/catch around a
send in pool.ts guarded the one shape this failure never takes. On the ordinary
shutdown path with any worker already dead, close() -> drainAll() killed the
supervisor by uncaughtException before awaitIdle, losing the in-flight turns on
every other worker. drainAll now checks connected, and each child gets an
'error' listener. Found because the new abandoned-pre-read test made a dead
worker exist at teardown for the first time.

Every fix has a test; the two measured behaviours are recorded in the comments
and test rationale rather than the predicted ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori

Copy link
Copy Markdown
Member Author

All nine review comments addressed in f6c4f10 — each verified against the code first, each with a test. Replies are in the individual threads.

Two were worse than reported, and one new defect fell out of pinning them.

send() to a closed IPC channel does not throw, so every try/catch around one is inert

Not from the review — found because the new abandoned-pre-read test made a dead worker exist at teardown for the first time, and the suite then reported an uncaught ERR_IPC_CHANNEL_CLOSED out of drainAll. Confirmed against Node directly:

send returned, no sync throw
UNCAUGHT: ERR_IPC_CHANNEL_CLOSED

ChildProcess.send() with no callback does process.nextTick(() => this.emit("error", err)). On a child with no error listener that is an unhandled error event, i.e. an uncaughtException. So the try { handle.send(...) } catch in drainAll was guarding against the one shape this failure never takes.

It is on the ordinary shutdown path, not a corner. Whenever any worker is already dead — a crashloop, an OOM kill, and now a boot-validation exit — close() calls drainAll(), which sent to that dead channel, and the supervisor died mid-drain, before awaitIdle, taking the in-flight turns on every other worker with it. Precisely the outcome the ordered close() exists to prevent, and it interacts with the KillMode fix: that one makes the drain reachable, this one makes it survivable.

drainAll now checks connected first (a dead worker is already drained), and every child gets an error listener so the absence of one cannot be fatal. Two tests: no drain is sent down a gone channel while later workers still get theirs, and an error event is logged rather than thrown.

Verification

make fmt, make lint, make typecheck, pnpm -r test (1555 passed, 22 skipped, 0 failures, no unhandled errors) and make test-deploy all clean. The two behaviours I measured rather than assumed — the abandoned pre-read killing a worker, and the async channel error — are recorded in the code comments and test rationale, so the next reader gets the measurement and not the prediction.

🤖 Generated with Claude Code

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 13353ab...f6c4f10 (+509 lines over 14 files, force-pushed; commit count unchanged at 38). Nine of my ten findings are fixed, each with a test that fails without the fix. One is not addressed here and is now carried on #253. Two new items below, neither blocking.

Author: pdettori (MEMBER — maintainer)
Areas reviewed: TypeScript (supervisor, knative-server), systemd units, shell, docs, tests
Agent/IDE config (.claude/.vscode): none — supply-chain gate clear
Commits: 38, all signed-off
CI status: passing (12/12, incl. deploy-scripts, which does run the new shell test)

Per-finding verdicts

# Item Verdict
1 Worker boot skips keyset validation Fixed — assertKeysetUsable(process.env) inside the boot try, before resolveDiscoverySource, so it exits 2 rather than serving 500s
2 KillMode=control-group defeats the drain Fixed — mixed; premise verified (main.ts:220 does install a SIGTERM handler, with a re-entrancy guard)
3 ProtectHome with no writable HOME Fixed — Environment=HOME=/tmp, asserted writable
4 Containers do not return after reboot Fixed — --restart=always on all four podman runs plus podman-restart.service, non-fatally
5 Handle-less conn loses the load report Fixed — report hoisted above the guard
6 Closed socket handed off anyway Fixed — see below
7 Hand-off failure resets the client Fixed — noteRefusal() + refuse(); test asserts 429 + Retry-After, no destroy, and spuriousRefusals 0→1
8 readInt exempts its own fallback Fixed — bounds now apply to the fallback; I checked all four call sites, none newly throws at boot, and the undefined check still fires first so SH_TURNS_PER_WORKER is unaffected
9 records-mode emptiness blames the pool selector Not addressed — select-sandbox.ts is not in this delta. Raised on #253, where SandboxPoolEmptyError now owns that message; no need to fix it here
10 README's first run cannot succeed as written Fixed — two invocations stated explicitly, steps renumbered, sudoedit, new "Reboots" section

On finding 6, your diagnosis is better than mine

I claimed child.send throws and that pool.ts's catch swallows it. You measured that it does not throw, so the counters that would have shown it stay at zero and the worker dies instead — worker_exit code: 1, restarts: 1, taking every turn it was multiplexing. That is a worse failure than the counter inflation I described, and I had the severity wrong.

The fix is also airtight rather than merely narrower, for a reason worth recording: once the await readHead(socket) continuation resumes, the path through policy.pick to handOff is entirely synchronous, so nothing can destroy the socket in between. The guard has no residual window. (Any future await inserted there would reopen it.)

The unrequested work is the right call

drainAll's connected check and spawn's handle.on('error') are correct, and "Required, not defensive" is the accurate label — an unlistened 'error' on an EventEmitter is an uncaughtException, not something a try/catch or a .catch() can reach. I verified the surrounding invariants hold: both restart paths gate on shuttingDown (pool.ts:418, :435), so no slot is respawned mid-shutdown, and ready_ignored_draining covers the late-ready case.

I also checked that the new test is not vacuous, since its comment rests on it: FakeWorker extends EventEmitter, so expect(() => h.forked[0].emit('error', ...)).not.toThrow() really does fail with the listener removed. Same for the >= 4 non-vacuity guard in the shell test — SH_SANDBOX_COUNT=3 is exported at setup-vm.test.sh:54, so Redis + 3 is the right count and the guard is live.

Worth naming across PRs: this is the same defect class as #253's redis-backend.ts, where seven createClient sites still register no 'error' listener. The mechanism you write up here is the argument for fixing it there.

Verdict

APPROVE. The two items below are a comment-accuracy issue and a stale cross-reference; neither should hold the branch.

One procedural note: your own CHANGES_REQUESTED (5198443923) is still on this PR and my approval does not clear it — mergeable_state stays blocked until you re-review.

One limitation worth stating

git fetch fails in my environment (self-signed certificate in the proxy chain), so my clone is pinned at a982ffd. Everything above was read from the PR's HEAD (f6c4f10) through the API, not from that clone.

# turns run to completion" never happened on the real deployment. `mixed` still accounts for the
# whole tree at the deadline, which is what control-group was here for.
KillMode=mixed
# §3.7: SIGTERM starts the drain. The deadline must exceed the longest turn E6 measured.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — this comment and SHUTDOWN_GRACE_MS now disagree about what this number is for, and the KillMode fix is what made it matter.

"The deadline must exceed the longest turn E6 measured" describes a stop timeout that protects a long turn. But close() stops waiting at SHUTDOWN_GRACE_MS = 20_000, logs grace_expired, and proceeds — so a turn still running at 20s is killed by the supervisor, and the remaining 100s of this window never protects anything. main.ts:33-35 says as much and assigns the opposite role: the 120s is "the outer bound … so systemd's SIGKILL stays a genuine backstop for a supervisor that has hung rather than a race against this timer."

That reading is the correct one, and this line is the misleading one. It matters now in a way it did not before this delta: under KillMode=control-group the drain never really ran, so the relationship between the two deadlines was untestable. mixed makes it live, and 20s is now the real §3.9 boundary.

If E6's longest turn is anywhere near this 120s, the interesting consequence is not the comment — it is that §3.9's "in-flight turns run to completion" holds only for turns under 20s, on the ordinary systemctl stop path. SHUTDOWN_GRACE_MS's own doc comment already flags itself as a chosen value pending a spec sentence, so I would not change the constant here; I would fix this line to say what the timeout is (a hang backstop) and let the recorded spec gap carry the rest.

Comment thread deploy/vm/setup-vm.sh
# NO VOLUME, deliberately for this round: session state, the ownership index and the lease store
# are lost on every reboot and on every `podman rm` of this container. That is acceptable only
# because this deployment exists to run E8 rungs, where each run starts from an empty Redis
# anyway. It is stated here (and in README.md's "What round one does not claim") rather than left

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — this cross-reference points at the one section that does not say this.

"What round one does not claim" (README:213) is exclusively about the systemd hardening directives and the absent NetworkPolicy analogue; it does not mention the volume. The statement this line promises is in the "Reboots" section you added in this delta (README:169-174, "Redis state does not survive a reboot either way.") — which states it well, including the --restart=always brings back the container, not the data point made just below here.

So an operator who follows the pointer lands in a section about syscall filters. Retargeting it to "Reboots" is the whole fix.

@pdettori
pdettori merged commit 8585177 into rossoctl:main Sep 15, 2026
12 checks passed
@pdettori
pdettori deleted the feat/p6-vm-process-manager branch September 15, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants