Repository navigation
Conversation
… uses them
io.pilot.redis 8.6.2 leaked on darwin/arm64, darwin/amd64, linux/arm64 and
linux/amd64 (harness runs on the published bundles):
- redis.start ran `redis-server --daemonize yes`. The server setsid()s out of
the adapter's process group and is reparented to pid 1, so it survived the
adapter's SIGTERM ("LEFT pid=93603 ppid=1 pgid=93603 redis-server"), the
supervisor's stop (SIGKILL of the adapter's pid) and the supervisor's own
death, even on Linux where Pdeathsig killed the adapter. Pdeathsig, the
supervisor's group stop, reapStale and the child guard cannot reach it.
- redis.exec ["redis-server",...,"--daemonize","yes"] leaked the same way, and
a relative args[0] ran any host binary through the `redis` dispatcher:
["../"x40+"bin/sh","-c","id -u"] printed "ESCAPED uid=501" (macOS) and
"uid=1000" (Linux).
- redis.start bound *:port (`CONFIG GET bind` = "* -::*") although the docs
say 127.0.0.1, and a second start on a busy port returned exit 0 because
the daemonized server forks before it binds.
Template:
- cli.service: a method that starts a long-running server the adapter owns.
The server runs as the adapter's foreground child in its process group, and
force_args are appended last, so `--daemonize no` wins over the caller and
any config file. The call returns once the server is ready: ready_tcp
accepts, or, on a passthrough route, it has stayed up for ready_after. A
ready_tcp address that already accepts fails the call before anything
starts. A server that is not ready within ready_timeout is stopped, and a
process that exits first (--version, a config error) returns its output
like a command. How the server is stopped:
- on a clean stop, StopServices sends SIGTERM, then SIGKILL after 8s;
- while a server runs it holds the child guard (childguard.go), so a
SIGKILLed adapter takes it down on every OS;
- on Linux the server also gets SIGTERM from the kernel (Pdeathsig) when
the adapter dies.
- cli.tools: an allowlist for a passthrough route's args[0] (bare names only).
- Validation: a passthrough service requires cli.tools and service.tools, and
ready_timeout must be below the method timeout.
- Documented in docs/CLI-ADAPTER.md.
redis submission (8.6.2 -> 8.6.3; the bundled Redis stays 8.6.2):
- redis.start is a service: ready_tcp 127.0.0.1:${port}, --bind 127.0.0.1 and
force_args --daemonize no.
- redis.exec allows only the six bundled tools, and runs redis-server and
redis-sentinel as services.
Tests:
- TestCLIServiceLifetime covers SIGTERM and SIGKILL for enumerated and
passthrough services, supervisor death through the mini supervisor, three
services stopped by one SIGTERM, one-shot replies, allowlist refusals, a
busy port and a server that never gets ready. Each stop must leave an empty
process group, and a clean stop must SIGTERM the server.
- Mutation checks on darwin/arm64:
- With the service not holding the guard, both SIGKILL cases fail ("timed
out after 10s waiting for server to die after adapter sigkill").
- Without StopServices, the three SIGTERM cases fail ("server was not
stopped with SIGTERM").
- TestCLIServiceConfig covers the validation rules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A/B report —
|
| Command | Vanilla ms | Pilot ms | Δ | Exit match |
|---|---|---|---|---|
| redis-cli --version | 4 | 7 | +3 | ✓ |
| redis-cli --help | 4 | 6 | +2 | ✓ |
| redis-server --help | 6 | 9 | +3 | ✓ |
| redis-cli --version (exec) | 3 | 6 | +3 | ✓ |
…, not after a fixed wait
A passthrough service counted as ready once it had stayed up for ready_after
(1s for redis.exec). The harness showed that a freshly staged binary's first
launch takes longer than that to listen. `redis.start`, which probes its
ready_tcp, took this long to become ready in the lifecycle phase:
- darwin/arm64: 1.5-1.9s (a relaunch of the same files: 53ms)
- darwin/amd64 under Rosetta: 3.2-4.5s (relaunch: 112ms)
So `redis.exec ["redis-server","--port",P,...]` reported "started" and the
next ping got "Could not connect to Redis at 127.0.0.1:P: Connection refused"
(darwin/arm64 and darwin/amd64, 2 of 2 runs).
cli.service.ready_port_flag (passthrough only) names the argv flag that
carries the server's port. When a call passes it (`--port N` or `--port=N`,
the last one wins), the server is ready once 127.0.0.1:N accepts, and the call
fails fast if something already accepts there. Without the flag, readiness is
still ready_after. redis.exec sets ready_port_flag to "--port".
Test: TestCLIServiceLifetime/passthrough-ready-port uses a server that
listens only after 1.5s, in both forms, and checks that a busy port is
refused. With the port probe disabled it fails ("returned after 309ms,
before the server listened"; "reported started but 127.0.0.1:53188
refuses").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #114 (
fix/otto-lifecycle), which is stacked on #111. This PR adds one commit. It depends on #114's child guard (childguard.go),childProcAttr/lockForChildand shutdown drain, and on #111'swatchParent.Problem
The harness sweep ran the published io.pilot.redis 8.6.2 bundles on darwin/arm64, darwin/amd64 (Rosetta), linux/arm64 and linux/amd64 (
docker --init). It found these leaks on every platform:redis.startrunsredis-server --daemonize yes. The serversetsid()s out of the adapter's process group and is reparented to pid 1. It survives the adapter's SIGTERM (LEFT pid=93603 ppid=1 pgid=93603 redis-server, darwin-arm64), the supervisor's stop, which SIGKILLs the adapter's pid, and supervisor death. On Linux it survives even when Pdeathsig killed the adapter (PASS: app died 3ms after parent SIGKILL … LEFT pid=1567 ppid=1 redis-server). Pdeathsig, a group kill, reapStale and #114's guard cannot reach it.redis.exec ["redis-server",…,"--daemonize","yes"]leaks the same way.args[0]goes to theredisdispatcher, which only checks[ -x "$dir/$tool" ].["../"×40+"bin/sh","-c","echo ESCAPED uid=$(id -u)"]printsESCAPED uid=501(macOS) anduid=1000(Linux).CONFIG GET bindreturns* -::*and lsof showsTCP *:46391 (LISTEN), although the docs say 127.0.0.1.redis.starton a busy port returns exit 0, because the server forks before it binds.Fix
Template:
cli.service(service.go.tmpl,svcproc_{linux,other}.go.tmpl) marks a method that starts a long-running server the adapter owns.force_argsare appended last, so--daemonize nowins over the caller and any config file.ready_tcpaccepts, or, on a passthrough route, the server has stayed up forready_after.ready_tcpaddress that is already accepting fails the call before anything starts.ready_timeoutis stopped and the call fails.--version, a config error) returns its output like a command.StopServicessends SIGTERM, then SIGKILL after 8 s. It runs after the drain and beforeCloseChildGuard.Pdeathsig) when the adapter dies.cli.toolsis an allowlist for a passthrough route'sargs[0](bare names only).cli.toolsandservice.tools.ready_tcpmust be host:port.ready_timeoutmust be below the method timeout.docs/CLI-ADAPTER.md.submissions/io.pilot.redis→ 8.6.3:redis.startbecomes a service:--bind 127.0.0.1,ready_tcp 127.0.0.1:${port},force_args --daemonize no, and its log tail is returned on failure.redis.execallows only the six bundled tools, and runsredis-serverandredis-sentinelas services.Tests
TestCLIServiceLifetimebuilds a real adapter. It covers:../../../../bin/sh,sh,./serve, and no args);Every stop must leave the adapter's process group empty, and a clean stop must SIGTERM the server rather than leave it to the guard's SIGKILL.
TestCLIServiceConfigcovers the validation rules.timed out after 10s waiting for server to die after adapter sigkill).StopServices, the three SIGTERM cases fail (server was not stopped with SIGTERM).go test ./...passes on darwin/arm64, including fix(scaffold): generated adapters exit with their daemon and drop abandoned calls #111's and fix(scaffold): a cli adapter's in-flight children never outlive it (io.pilot.otto) #114's lifecycle tests.-count=3.Verified on real redis bundles built from this commit
I built all four platforms through
publish.BuildBundlefromsubmissions/io.pilot.redis/submission.jsonat 8.6.3, signed with a TEST-ONLY key; nothing was published.pilot-app verifypasses on each tarball, andverify-submissionpasses. Compared with the published 8.6.2 bundle, the file layout, grants, exposes andinstall.jsonassets are identical; only the version and the signing key differ.The harness ran 7 passes per platform:
help×200,exec×200,server(start then ping×200),setget×200,ptdaemon(passthrough--daemonize yes),inflight(a runningredis-cli SUBSCRIBE), andprobes. Each pass has lifecycle, supervisor-stop (SIGKILL of the pid) and orphan phases:pgroup_left=0 descendants_left=0 run_dir_procs_left=0.redis.startfails with127.0.0.1:<port> is already accepting connections.args[0] must be one of redis-server, redis-cli, ….redis-server --versionoverredis.execreturns its output with exit 0.CONFIG GET bindreturns127.0.0.1.docker --init, uid 1000, golang:1.25 image; amd64 emulated), run at 6400bfc:TestCLIServiceLifetime,TestCLIServiceConfig,TestCLIInflightServerE2EandTestGeneratedAdapterLifecycleE2Epass on both (ok … 21.9sarm64,ok … 35.0samd64). They also pass on darwin/arm64.help×200 andping×200 (200/200),redis.start, a passthroughredis-server --daemonize yes, and an in-flightredis-cli SUBSCRIBE. It ran three stop phases: SIGTERM, a pid-only SIGKILL, and supervisor SIGKILL. On Linux the supervisor-kill phase ran with and without Pdeathsig.42 ppid=1 pgid=42 …/redis-serverand both ports stay open. Without Pdeathsig the app survives:ORPHAN: app alive 6s after supervisor SIGKILL, still serving=true.CONFIG GET bindreturns127.0.0.1(8.6.2 returns* -::*andESCAPED uid=1000), and a second start on the same port fails.bin/redis-appsha256s are byte-identical. For the real-key rebuild, only the manifest signature and tarball sha change.Before merging
publish-rich-from-r2.shbuilds the catalogue entry frombundles/io.pilot.redis/8.6.3/on the prod R2 bucket. Rebuild with the redis publisher key (ed25519:QXvscnyXJ5L/Bmy7oCo5dPC1HrEOGa7WFWBfH5ktejM=) and upload all four tarballs before merging (docs/UPDATING-BUNDLES.md).8.6.3is the adapter version; the server is still Redis 8.6.2. A bump is required: the registry is write-once, and the supervisor only restarts an app whenapp_versionchanges. Upstream Redis already has 8.6.3–8.6.7, so the alternative is to stage Redis 8.6.7 native assets and ship 8.6.7.pub-2328865f…), and so is the manifest'snet.dialgrant, so a first spawn without network fails. This PR does not change that.🤖 Generated with Claude Code