Skip to content
Merged
29 changes: 26 additions & 3 deletions docs/CLI-ADAPTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,32 @@ The generated `exec.go` is defensive by default:
OOM the adapter; truncation is flagged in the reply.
- **Structured failures** — a non-zero exit is returned as
`{"stdout","stderr","exit","truncated"}` rather than an opaque error, so the
caller sees everything the CLI produced. Only spawn failures (binary missing)
and timeouts surface as IPC errors; the per-method `timeout`/`duration` bounds
the run and the child is killed on cancel.
caller sees everything the CLI produced. Spawn failures (binary missing),
timeouts and a child ended by a signal surface as IPC errors, never as
`{"exit":-1}`; the per-method `timeout`/`duration` bounds the run and the
child is killed on cancel (SIGTERM, then SIGKILL after 5 s).
- **Children never outlive the adapter** — a child still running when the
adapter goes away is stopped, however it goes: a SIGTERM waits for in-flight
calls (and so their children); a dead supervisor makes the adapter shut down
(it watches its parent; macOS has no parent-death signal); a SIGKILLed
adapter's children get the Linux parent-death signal and, on every OS, are
taken down by the child guard (`internal/backend/childguard.go`, a copy of
the adapter that exists only while a child is in flight). Children stay in
the adapter's process group, so a supervisor group stop reaches them too. A
server a CLI deliberately daemonizes into its own session is outside all of
this.
- **Staging leaves nothing behind** — native-binary downloads go to
`$APP/.staged/tmp`, not the shared `TMPDIR`; a stop during the first-spawn
download removes the partial file, and a SIGKILLed start's leftover is swept
by the next start.
- **Ready before the binary** — an app that ships its binary (`assets`) listens
at once and stages the binary in the background: the supervisor waits only a
few seconds for the socket (and never marks a later one ready), and
`<ns>.help` needs no binary. A call that needs it waits within its own
deadline. A failed download is reported per call and retried by a later call
(backing off from 2 s to 5 min) instead of exiting, so a first start without
network recovers without a respawn. `tar` and install steps get the same
parent-death signal as CLI calls.

## Why HTTP works today and CLI doesn't

Expand Down
13 changes: 8 additions & 5 deletions docs/R2-ARTIFACT-REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ set install order + args fold into the bundle tarball fetc
(`proc.exec`, `fs.write $APP`, `net.dial <r2-host>`). The whole tarball is
sha-pinned in the catalogue, so `install.json` (and the expected asset shas)
can't be altered undetected.
4. **Install + call** (host). The generated cli adapter calls `StageAssets($APP)`
on first spawn (`internal/backend/stage.go`): read `install.json` → select the
asset(s) for `runtime.GOOS/GOARCH` → in ascending `order`, fetch from R2,
verify sha256, stage under `$APP` (single file, or `tar.gz` extracted via the
host `tar`), run any install `args` — then exec the staged `exec_path` per call.
4. **Install + call** (host). The generated cli adapter stages its assets on
first spawn (`internal/backend/stage.go`, `NewStager` → `StageAssetsContext`),
in the background while it already serves: read `install.json` → select the
asset(s) for `runtime.GOOS/GOARCH` → in ascending `order`, fetch from R2 into
`$APP/.staged/tmp`, verify sha256, stage under `$APP` (single file, or `tar.gz`
extracted via the host `tar`), run any install `args` — then exec the staged
`exec_path` per call. A call that arrives before staging is done waits for it
within its own deadline; a failed download is retried by a later call.

## R2 layout

Expand Down
256 changes: 256 additions & 0 deletions internal/scaffold/cli_lifecycle_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
//go:build !windows

package scaffold

import (
"bufio"
"encoding/json"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"testing"
"time"

"github.com/pilot-protocol/app-store/pkg/ipc"
)

// buildCLIAdapter generates the adapter for spec and builds it, the way
// the other e2e tests do (go.sum seeded from this module, so the build needs no
// network). Returns the binary and the generated project dir.
func buildCLIAdapter(t *testing.T, spec string) (bin, proj string) {
t.Helper()
if testing.Short() {
t.Skip("builds and runs a real adapter binary; skipped under -short")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go toolchain not available")
}
root := t.TempDir()
cfg := parseSpec(t, spec)
proj = filepath.Join(root, "proj")
if _, err := Generate(cfg, proj); err != nil {
t.Fatalf("generate: %v", err)
}
if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil {
_ = os.WriteFile(filepath.Join(proj, "go.sum"), sum, 0o644)
}
bin = filepath.Join(root, "adapter")
build := exec.Command("go", "build", "-o", bin, "./cmd/"+cfg.BinaryName)
build.Dir = proj
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build adapter: %v\n%s", err, out)
}
return bin, proj
}

// shortTempDir is a per-test dir with a short path: a t.TempDir() for a
// subtest can exceed sun_path (104 bytes on darwin) once app.sock is appended.
func shortTempDir(t *testing.T, prefix string) string {
t.Helper()
dir, err := os.MkdirTemp("", prefix)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}

// waitForSocket waits until the adapter accepts connections on sock (the file
// appears at bind, a moment before listen).
func waitForSocket(t *testing.T, sock string, within time.Duration) {
t.Helper()
for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
if c, err := net.DialTimeout("unix", sock, time.Second); err == nil {
_ = c.Close()
return
}
}
t.Fatalf("adapter socket %s did not accept connections within %s", sock, within)
}

// ipcCall dials the adapter once per call, like the supervisor does.
func ipcCall(sock, method, args string, deadline time.Duration) (json.RawMessage, error) {
conn, err := net.DialTimeout("unix", sock, 3*time.Second)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(deadline))
var out json.RawMessage
err = ipc.Call(conn, method, json.RawMessage(args), &out)
return out, err
}

// processGone reports whether pid has exited within the given time (a zombie
// counts as alive: its parent has not reaped it yet). It SIGKILLs a survivor so
// a failing test leaves nothing behind.
func processGone(pid int, within time.Duration) bool {
for deadline := time.Now().Add(within); ; time.Sleep(20 * time.Millisecond) {
if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
return true
}
if time.Now().After(deadline) {
_ = syscall.Kill(pid, syscall.SIGKILL)
return false
}
}
}

// TestCLIAdapterChildLifecycleE2E: a CLI child still running when its call
// times out, when the adapter is stopped, or when the process that spawned the
// adapter dies must not outlive the call or the adapter, and a timed-out call
// must fail rather than report a successful {"exit":-1}. io.pilot.miren
// 0.1.0: `miren login` started over miren.exec was left running, reparented to
// init, after the supervisor SIGKILLed the adapter and after the daemon died
// (Linux: the adapter's Pdeathsig is not inherited by its children; macOS: the
// adapter itself kept serving as an orphan), and a call cut off by its 60s
// deadline replied {"exit":-1} as a success.
func TestCLIAdapterChildLifecycleE2E(t *testing.T) {
root := t.TempDir()
// `hang <pidfile>` records its pid, then blocks without writing anything
// (so SIGPIPE cannot end it early once the adapter is gone).
tool := filepath.Join(root, "faketool")
script := "#!/bin/sh\n" +
"case \"$1\" in\n" +
" hang) echo $$ > \"$2\"; exec sleep 300;;\n" +
" *) exit 2;;\n" +
"esac\n"
if err := os.WriteFile(tool, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
bin, proj := buildCLIAdapter(t, `
id: io.pilot.faketool
app_version: 0.1.0
description: "Fronts faketool."
namespace: faketool
backend:
type: cli
command: ["`+tool+`"]
methods:
- name: faketool.hang
summary: "Blocks until killed."
timeout: 4s # room for the child to start on a loaded host before the deadline
cli: {args: ["hang", "${pidfile}"]}
- name: faketool.run
summary: "Passthrough."
cli: {passthrough: true}
`)
manifest := filepath.Join(proj, "manifest.json")

start := func(t *testing.T) (*exec.Cmd, string) {
t.Helper()
sock := filepath.Join(shortTempDir(t, "cla"), "app.sock")
a := exec.Command(bin, "--socket", sock, "--manifest", manifest)
a.Stderr = os.Stderr
a.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // like the supervisor
if err := a.Start(); err != nil {
t.Fatalf("start adapter: %v", err)
}
t.Cleanup(func() { _ = a.Process.Kill(); _, _ = a.Process.Wait() })
waitForSocket(t, sock, 10*time.Second)
return a, sock
}
childPID := func(t *testing.T, pidfile string) int {
t.Helper()
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
if b, err := os.ReadFile(pidfile); err == nil && len(b) > 0 {
if pid, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
return pid
}
}
}
t.Fatal("child never started")
return 0
}
hangInFlight := func(t *testing.T, sock string) int {
t.Helper()
pidfile := filepath.Join(t.TempDir(), "pid")
go func() { _, _ = ipcCall(sock, "faketool.run", `{"args":["hang","`+pidfile+`"]}`, 30*time.Second) }()
return childPID(t, pidfile)
}

t.Run("timeout is an error and kills the child", func(t *testing.T) {
_, sock := start(t)
pidfile := filepath.Join(t.TempDir(), "pid")
out, err := ipcCall(sock, "faketool.hang", `{"pidfile":"`+pidfile+`"}`, 30*time.Second)
if err == nil || !strings.Contains(err.Error(), "deadline exceeded") {
t.Fatalf("timed-out call: out=%s err=%v, want an IPC error naming the deadline (not a {\"exit\":-1} result)", out, err)
}
if pid := childPID(t, pidfile); !processGone(pid, 3*time.Second) {
t.Errorf("child pid %d still running after its call timed out", pid)
}
})

t.Run("SIGTERM stops the in-flight child before the adapter exits", func(t *testing.T) {
a, sock := start(t)
pid := hangInFlight(t, sock)
_ = a.Process.Signal(syscall.SIGTERM)
if _, err := a.Process.Wait(); err != nil {
t.Fatal(err)
}
if !processGone(pid, 100*time.Millisecond) {
t.Errorf("in-flight child pid %d outlived the adapter's SIGTERM exit", pid)
}
if _, err := os.Stat(sock); !os.IsNotExist(err) {
t.Errorf("socket left after a clean exit: %v", err)
}
})

t.Run("SIGKILL of the adapter takes the in-flight child down", func(t *testing.T) {
// Linux: the child's parent-death signal. Every OS: the child guard
// (childguard.go), which needs the adapter to lead its process group.
a, sock := start(t)
pid := hangInFlight(t, sock)
_ = a.Process.Kill()
_, _ = a.Process.Wait()
if !processGone(pid, 3*time.Second) {
t.Errorf("in-flight child pid %d outlived the SIGKILLed adapter (%s)", pid, runtime.GOOS)
}
})

t.Run("parent death stops the adapter and its in-flight child", func(t *testing.T) {
// The adapter's parent is a shell standing in for the supervisor. It is
// SIGKILLed; the adapter (no Pdeathsig here, as on macOS) must notice,
// cancel the running call, reap its child and exit.
sock := filepath.Join(shortTempDir(t, "clp"), "app.sock")
parent := exec.Command("/bin/sh", "-c", `"$0" "$@" & echo $!; wait`, bin, "--socket", sock, "--manifest", manifest)
parent.Stderr = os.Stderr
stdout, err := parent.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := parent.Start(); err != nil {
t.Fatal(err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read adapter pid: %v", err)
}
adapterPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("adapter pid %q: %v", line, err)
}
t.Cleanup(func() { _ = syscall.Kill(adapterPID, syscall.SIGKILL) })
waitForSocket(t, sock, 10*time.Second)
pid := hangInFlight(t, sock)

_ = parent.Process.Kill()
_ = parent.Wait()
if !processGone(adapterPID, 5*time.Second) {
t.Fatalf("adapter pid %d kept running after its parent died", adapterPID)
}
if !processGone(pid, 500*time.Millisecond) {
t.Errorf("in-flight child pid %d outlived the orphaned adapter", pid)
}
if _, err := os.Stat(sock); !os.IsNotExist(err) {
t.Errorf("socket left after the adapter shut down: %v", err)
}
})
}
13 changes: 13 additions & 0 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ type file struct {
tmpl string
}

// childProcFiles are the per-OS process attributes the exec runner applies to
// every CLI child (Linux: parent-death signal), plus the guard that takes an
// in-flight child down with a SIGKILLed adapter; emitted with client_cli.go.tmpl.
func childProcFiles() []file {
return []file{
{filepath.Join("internal", "backend", "childproc_linux.go"), "childproc_linux.go.tmpl"},
{filepath.Join("internal", "backend", "childproc_other.go"), "childproc_other.go.tmpl"},
{filepath.Join("internal", "backend", "childguard.go"), "childguard.go.tmpl"},
}
}

// Generate renders a full adapter project for cfg into outDir. cfg must already
// be Resolve()d and Validate()d. Returns the list of written paths.
func Generate(cfg *Config, outDir string) ([]string, error) {
Expand Down Expand Up @@ -69,6 +80,7 @@ func Generate(cfg *Config, outDir string) ([]string, error) {
}
case "cli":
files = append(files, file{filepath.Join("internal", "backend", "exec.go"), "client_cli.go.tmpl"})
files = append(files, childProcFiles()...)
// Native-binary delivery: emit the staging runtime only when the app
// actually ships assets (an already-installed cli needs no stager).
if cfg.HasAssets() {
Expand All @@ -83,6 +95,7 @@ func Generate(cfg *Config, outDir string) ([]string, error) {
file{filepath.Join("internal", "backend", "cloud.go"), "cloud.go.tmpl"},
file{filepath.Join("internal", "backend", "signer.go"), "signer.go.tmpl"},
)
files = append(files, childProcFiles()...)
if cfg.HasAssets() {
files = append(files, file{filepath.Join("internal", "backend", "stage.go"), "stage.go.tmpl"})
}
Expand Down
Loading
Loading