From 473dd993d0bc9027d0c683d22f54e43012f6da58 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 14:28:07 +0300 Subject: [PATCH 1/2] fix(publish): build an app with native assets only for platforms it has an asset for io.pilot.smol 1.2.0 published a darwin/amd64 adapter bundle although install.json has no smolvm for darwin/amd64 (upstream ships none). The bundle was left unlisted, but it exits 1 on every spawn: "install assets: stage: no asset for darwin/amd64". BuildBundle now builds DefaultPlatforms filtered to the asset platforms (BuildPlatforms); apps without assets still build for all four. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/publish/build.go | 25 +++++++++++++++++++++++- internal/publish/build_platforms_test.go | 20 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/publish/build.go b/internal/publish/build.go index e9b2227..624217a 100644 --- a/internal/publish/build.go +++ b/internal/publish/build.go @@ -26,6 +26,29 @@ import ( // primary (the bare bundle_url older clients fetch). var DefaultPlatforms = []string{"linux/amd64", "linux/arm64", "darwin/arm64", "darwin/amd64"} +// BuildPlatforms is the subset of DefaultPlatforms cfg can run on. An app that +// stages native assets (install.json) runs only where it has an asset: on any +// other platform its adapter builds, but exits on every spawn with "no asset +// for /". io.pilot.smol 1.2.0 shipped such a darwin/amd64 bundle +// (upstream smolvm has no darwin x86_64 build), which crash-looped. Apps with +// no assets run everywhere the pure-Go adapter compiles for. +func BuildPlatforms(cfg *scaffold.Config) []string { + if len(cfg.Assets) == 0 { + return DefaultPlatforms + } + have := map[string]bool{} + for _, a := range cfg.Assets { + have[a.OS+"/"+a.Arch] = true + } + var out []string + for _, p := range DefaultPlatforms { + if have[p] { + out = append(out, p) + } + } + return out +} + // PlatformBundle is one OS/arch's signed tarball. type PlatformBundle struct { Platform string // "darwin/arm64" @@ -106,7 +129,7 @@ func BuildBundle(cfg *scaffold.Config, priv ed25519.PrivateKey) (*Bundle, error) primaryBinBytes int primaryTarBytes int ) - for _, plat := range DefaultPlatforms { + for _, plat := range BuildPlatforms(cfg) { pb, binLen, err := buildPlatform(tmp, cfg, mfRaw, priv, plat) if err != nil { return nil, fmt.Errorf("build %s: %w", plat, err) diff --git a/internal/publish/build_platforms_test.go b/internal/publish/build_platforms_test.go index b157e51..46c4298 100644 --- a/internal/publish/build_platforms_test.go +++ b/internal/publish/build_platforms_test.go @@ -5,7 +5,10 @@ import ( "bytes" "compress/gzip" "io" + "strings" "testing" + + "github.com/pilot-protocol/app-template/internal/scaffold" ) // magicForPlatform returns the executable magic an os should produce. @@ -97,3 +100,20 @@ func TestBuildBundle_AllPlatforms(t *testing.T) { t.Errorf("missing platforms: %v", want) } } + +// TestBuildPlatforms_AssetsLimitTargets: an app that stages native assets is +// built only for the platforms it has an asset for (io.pilot.smol 1.2.0 +// shipped a darwin/amd64 bundle with no smolvm for it, which exited on every +// spawn). An app without assets builds for every default platform. +func TestBuildPlatforms_AssetsLimitTargets(t *testing.T) { + cfg := sampleSubmission().ToConfig() + if got := BuildPlatforms(cfg); len(got) != len(DefaultPlatforms) { + t.Fatalf("no assets: platforms = %v, want %v", got, DefaultPlatforms) + } + cfg.Assets = []scaffold.Asset{{OS: "darwin", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "linux", Arch: "arm64"}} + got := BuildPlatforms(cfg) + want := []string{"linux/amd64", "linux/arm64", "darwin/arm64"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("platforms = %v, want %v", got, want) + } +} From ff2f9d04a40b23fc9a57ad88795010baaa849652 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 14:28:08 +0300 Subject: [PATCH 2/2] fix(scaffold): VMs a smol call starts never outlive the app; io.pilot.smol 1.2.1 Measured on io.pilot.smol 1.2.0 (darwin/arm64, real smolvm 1.2.0 VMs): - an ephemeral `machine run` VM is orphaned (ppid 1, own process group) when its CLI gets SIGTERM or SIGKILL: app stop, group stop, call deadline. smolvm tears it down only on SIGINT. - VMs from `machine start` and `machine run -d` keep running after the app's SIGTERM, the supervisor's SIGKILL and the supervisor's death. New passthrough route options: - cli.env_rules: environment for the child of a matching call. smol sets SMOLVM_BOOT_BINARY=${command_dir}/smolvm-bin for a foreground run, which arms smolvm's own parent-death watchdog in the VM (manager.rs). - cli.teardown: a matching call opens a record "run "; the adapter runs open records on a clean stop, and the child guard (which stays up while records are open) runs them if the adapter is killed. An explicit stop/delete closes the record; a failed start closes what it opened. smol.exec declares both; the description says VMs stop with the app. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/CLI-ADAPTER.md | 49 ++ internal/publish/submission.go | 5 + internal/scaffold/config.go | 162 ++++++ internal/scaffold/scaffold.go | 1 + .../scaffold/templates/childguard.go.tmpl | 85 ++- .../scaffold/templates/client_cli.go.tmpl | 24 +- internal/scaffold/templates/main.go.tmpl | 8 +- internal/scaffold/templates/teardown.go.tmpl | 288 ++++++++++ internal/scaffold/zz_cli_teardown_e2e_test.go | 495 ++++++++++++++++++ submissions/io.pilot.smol/submission.json | 81 ++- 10 files changed, 1191 insertions(+), 7 deletions(-) create mode 100644 internal/scaffold/templates/teardown.go.tmpl create mode 100644 internal/scaffold/zz_cli_teardown_e2e_test.go diff --git a/docs/CLI-ADAPTER.md b/docs/CLI-ADAPTER.md index 67eef85..f9358ac 100644 --- a/docs/CLI-ADAPTER.md +++ b/docs/CLI-ADAPTER.md @@ -93,6 +93,55 @@ cli: force_args: [--daemonize, "no"] ``` +## What a call leaves running outside the app — `cli.teardown`, `cli.env_rules` + +Some CLIs hand what they start to a process outside the adapter's process +group and return: `smolvm machine start` and `machine run -d` leave a VM +running in its own group, reparented to init. Nothing in the group-based +lifecycle (clean stop, child guard, Pdeathsig, the supervisor's group stop) +reaches it. Declare the command that undoes such a call: + +```yaml +- name: smol.exec + cli: + passthrough: true + teardown: + - argv_prefix: [machine, start] # which calls start something + name_flags: [--name, -n] # --name x / --name=x names it + default_name: default # no flag: this name + run: [machine, stop, --name, "${name}"] # argv after the base command + clear_prefixes: [[machine, stop], [machine, delete]] + - argv_prefix: [machine, run] + when_flags: [-d, --detach] # only a detached run + name_flags: [--name, -n] + default_name: default + run: [machine, stop, --name, "${name}"] + clear_prefixes: [[machine, stop], [machine, delete]] +``` + +A matching call opens a record (before the CLI runs, so a start cut short +by a stop is still covered). A clean stop of the app runs every open record's +command (30 s each, concurrently); if the app is SIGKILLed, the child guard, +which stays up while any record is open, runs them before it takes the group +down. A successful call under `clear_prefixes` for the same name closes the +record, and a start that exits non-zero closes the record it opened. Flags +are only looked for before a `--`. + +`env_rules` add environment to the child of one kind of call. smol uses it +to arm smolvm's own parent-death watchdog for an ephemeral VM, since +`machine run` tears its VM down only on SIGINT, and SIGTERM or SIGKILL of the +CLI orphans it: + +```yaml + env_rules: + - argv_prefix: [machine, run] + unless_flags: [-d, --detach] # a detached VM must outlive its CLI + env: {SMOLVM_BOOT_BINARY: "${command_dir}/smolvm-bin"} +``` + +`${command_dir}` is the directory of the resolved base command (the staged +binary's directory for an app with assets). + ## Hardening built into the runner The generated `exec.go` is defensive by default: diff --git a/internal/publish/submission.go b/internal/publish/submission.go index fa636cf..aa58031 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -236,6 +236,9 @@ type SubCLIRoute struct { // Service marks a method that starts a long-running server the adapter // owns and stops with it (see scaffold.CLIService). Service *SubCLIService `json:"service,omitempty"` + // EnvRules / Teardown mirror scaffold.CLIRoute (passthrough only). + EnvRules []scaffold.CLIEnvRule `json:"env_rules,omitempty"` + Teardown []scaffold.CLITeardown `json:"teardown,omitempty"` } // SubCLIService mirrors scaffold.CLIService. @@ -732,6 +735,8 @@ func (s Submission) ToConfig() *scaffold.Config { ParamsAsFlags: m.CLI.ParamsAsFlags, Passthrough: m.CLI.Passthrough, Tools: m.CLI.Tools, + EnvRules: m.CLI.EnvRules, + Teardown: m.CLI.Teardown, } if sv := m.CLI.Service; sv != nil { method.CLI.Service = &scaffold.CLIService{ diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 93e8555..2783ccc 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -914,6 +914,93 @@ type CLIRoute struct { // Service marks the method as starting a long-running server that the // adapter owns (see CLIService), instead of a run-to-completion command. Service *CLIService `yaml:"service,omitempty"` + // EnvRules (passthrough only) add environment variables to the CLI child + // of a call whose argv matches (see CLIEnvRule). + EnvRules []CLIEnvRule `yaml:"env_rules,omitempty"` + // Teardown (passthrough only) records what a call started that lives on + // after the CLI exits (a detached VM, a background daemon the CLI itself + // manages) and the command that stops it. The adapter runs that command + // when it stops, and its child guard runs it if the adapter is killed. + Teardown []CLITeardown `yaml:"teardown,omitempty"` +} + +// CLIEnvRule adds Env to the child of a passthrough call whose argv starts +// with ArgvPrefix and carries none of UnlessFlags (before a "--"). Values may +// use ${command_dir}: the directory of the resolved base command (the staged +// binary's directory for an app with assets). +// +// Written for io.pilot.smol: `smolvm machine run` (an ephemeral VM) tears its +// VM down only on SIGINT. SIGTERM or SIGKILL of the CLI (a call timeout, the +// adapter's stop, the child guard) orphaned the VM, which runs in its own +// process group. With SMOLVM_BOOT_BINARY set, smolvm arms a parent-death +// watchdog in the VM process, so the VM dies with its CLI whatever signal hits +// it. A detached run (-d) must not get it, or its VM would die as soon as the +// CLI returns. +type CLIEnvRule struct { + ArgvPrefix []string `yaml:"argv_prefix" json:"argv_prefix"` + UnlessFlags []string `yaml:"unless_flags,omitempty" json:"unless_flags,omitempty"` + Env map[string]string `yaml:"env" json:"env"` +} + +// CLITeardown declares that a passthrough call whose argv starts with +// ArgvPrefix (and, with WhenFlags, carries one of them) leaves something +// running that the app owns. The resource is named by the value of the first +// of NameFlags the call passes (`--name x` or `--name=x`), else DefaultName. +// Run is the argv (after the base command) that stops it, with ${name}. A +// successful call whose argv starts with one of ClearPrefixes and resolves to +// the same name (an explicit stop or delete) drops the record. +// +// Written for io.pilot.smol: `machine start` and `machine run -d` leave a VM +// running after the CLI exits, in its own process group and outside the +// adapter's reach; it survived the app's SIGTERM, the supervisor's SIGKILL and +// uninstall. Each is now stopped with `machine stop --name `. +type CLITeardown struct { + ArgvPrefix []string `yaml:"argv_prefix" json:"argv_prefix"` + WhenFlags []string `yaml:"when_flags,omitempty" json:"when_flags,omitempty"` + NameFlags []string `yaml:"name_flags,omitempty" json:"name_flags,omitempty"` + DefaultName string `yaml:"default_name,omitempty" json:"default_name,omitempty"` + Run []string `yaml:"run" json:"run"` + ClearPrefixes [][]string `yaml:"clear_prefixes,omitempty" json:"clear_prefixes,omitempty"` +} + +// LifecycleGo renders the route's env rules and teardown rules as Spec +// fields (a leading ", " each), for the generated registerHandlers. +func (r *CLIRoute) LifecycleGo() string { + if r == nil { + return "" + } + var b strings.Builder + if len(r.EnvRules) > 0 { + b.WriteString(", EnvRules: []backend.EnvRule{") + for i, er := range r.EnvRules { + if i > 0 { + b.WriteString(", ") + } + keys := make([]string, 0, len(er.Env)) + for k := range er.Env { + keys = append(keys, k) + } + sort.Strings(keys) + kv := make([]string, 0, 2*len(keys)) + for _, k := range keys { + kv = append(kv, k, er.Env[k]) + } + fmt.Fprintf(&b, "{Prefix: %#v, Unless: %#v, Env: %#v}", er.ArgvPrefix, er.UnlessFlags, kv) + } + b.WriteString("}") + } + if len(r.Teardown) > 0 { + b.WriteString(", Teardown: []backend.TeardownRule{") + for i, td := range r.Teardown { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "{Prefix: %#v, When: %#v, NameFlags: %#v, DefaultName: %q, Run: %#v, Clear: %#v}", + td.ArgvPrefix, td.WhenFlags, td.NameFlags, td.DefaultName, td.Run, td.ClearPrefixes) + } + b.WriteString("}") + } + return b.String() } // CLIService declares a method that starts a long-running server. The adapter @@ -1595,6 +1682,7 @@ func (c *Config) validateCLIMethod(i int, m Method) []error { if sv := m.CLI.Service; sv != nil { errs = append(errs, validateCLIService(i, m, sv)...) } + errs = append(errs, validateCLILifecycle(i, m)...) return errs } @@ -1863,3 +1951,77 @@ func isLoopbackHost(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } + +var envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// validateCLILifecycle checks cli.env_rules and cli.teardown (see CLIEnvRule, +// CLITeardown). +func validateCLILifecycle(i int, m Method) []error { + var errs []error + c := m.CLI + if (len(c.EnvRules) > 0 || len(c.Teardown) > 0) && !c.Passthrough { + errs = append(errs, fmt.Errorf("methods[%d] (%s): cli.env_rules/cli.teardown only apply to a passthrough route", i, m.Name)) + } + flagsOK := func(at string, flags []string) { + for _, f := range flags { + if !strings.HasPrefix(f, "-") || f == "-" || f == "--" || strings.ContainsAny(f, "= ") { + errs = append(errs, fmt.Errorf("%s: %q must be a flag such as --detach or -d", at, f)) + } + } + } + prefixOK := func(at string, p []string) { + if len(p) == 0 { + errs = append(errs, fmt.Errorf("%s: argv_prefix must not be empty", at)) + } + for _, a := range p { + if a == "" || strings.HasPrefix(a, "-") { + errs = append(errs, fmt.Errorf("%s: argv_prefix entry %q must be a subcommand word", at, a)) + } + } + } + for j, er := range c.EnvRules { + at := fmt.Sprintf("methods[%d] (%s): cli.env_rules[%d]", i, m.Name, j) + prefixOK(at, er.ArgvPrefix) + flagsOK(at+".unless_flags", er.UnlessFlags) + if len(er.Env) == 0 { + errs = append(errs, fmt.Errorf("%s: env must not be empty", at)) + } + for k, v := range er.Env { + if !envNameRE.MatchString(k) { + errs = append(errs, fmt.Errorf("%s: env name %q is not a valid variable name", at, k)) + } + for _, tok := range placeholderREScaffold.FindAllString(v, -1) { + if tok != "${command_dir}" { + errs = append(errs, fmt.Errorf("%s: env %s uses %s; only ${command_dir} is available", at, k, tok)) + } + } + } + } + for j, td := range c.Teardown { + at := fmt.Sprintf("methods[%d] (%s): cli.teardown[%d]", i, m.Name, j) + prefixOK(at, td.ArgvPrefix) + flagsOK(at+".when_flags", td.WhenFlags) + flagsOK(at+".name_flags", td.NameFlags) + if len(td.Run) == 0 { + errs = append(errs, fmt.Errorf("%s: run (the argv that stops what the call started) must not be empty", at)) + } + usesName := false + for _, a := range td.Run { + for _, tok := range placeholderREScaffold.FindAllString(a, -1) { + if tok != "${name}" { + errs = append(errs, fmt.Errorf("%s: run uses %s; only ${name} is available", at, tok)) + } + usesName = true + } + } + if usesName && len(td.NameFlags) == 0 && td.DefaultName == "" { + errs = append(errs, fmt.Errorf("%s: run uses ${name} but there is no name_flags or default_name to fill it", at)) + } + for _, cp := range td.ClearPrefixes { + prefixOK(at+".clear_prefixes", cp) + } + } + return errs +} + +var placeholderREScaffold = regexp.MustCompile(`\$\{[^}]*\}`) diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index a4d218e..a0ad555 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -31,6 +31,7 @@ func childProcFiles() []file { {filepath.Join("internal", "backend", "childproc_other.go"), "childproc_other.go.tmpl"}, {filepath.Join("internal", "backend", "childguard.go"), "childguard.go.tmpl"}, {filepath.Join("internal", "backend", "service.go"), "service.go.tmpl"}, + {filepath.Join("internal", "backend", "teardown.go"), "teardown.go.tmpl"}, {filepath.Join("internal", "backend", "svcproc_linux.go"), "svcproc_linux.go.tmpl"}, {filepath.Join("internal", "backend", "svcproc_other.go"), "svcproc_other.go.tmpl"}, } diff --git a/internal/scaffold/templates/childguard.go.tmpl b/internal/scaffold/templates/childguard.go.tmpl index 064402d..abcb3b0 100644 --- a/internal/scaffold/templates/childguard.go.tmpl +++ b/internal/scaffold/templates/childguard.go.tmpl @@ -26,15 +26,22 @@ // call starts a new one. The guard runs only when the adapter leads its own // process group, as the supervisor spawns it, so it never signals a group the // adapter does not own. +// +// The guard also holds the adapter's open teardown records (teardown.go): the +// stop command for each thing a call left running outside the process group, +// such as a detached VM. While any record is open the guard stays up, and when +// the adapter dies it runs those commands before it takes the group down. package backend import ( "bufio" + "encoding/json" "log" "os" "os/exec" "os/signal" "strconv" + "strings" "sync" "syscall" "time" @@ -56,6 +63,54 @@ type childGuard struct { inflight int idle *time.Timer off bool // this adapter does not lead its process group + // records are the open teardown records (key → JSON), replayed to every + // new guard; holding is set while they keep one acquire() outstanding. + records map[string][]byte + holding bool +} + +// putRecord opens (or replaces) a teardown record and hands it to the guard. +// The first open record holds the guard until the last one is dropped. +func (g *childGuard) putRecord(key string, rec []byte) { + g.mu.Lock() + if g.records == nil { + g.records = map[string][]byte{} + } + g.records[key] = rec + if g.w != nil { + g.writeRecordLocked(key, rec) + } + hold := !g.holding + g.holding = true + g.mu.Unlock() + if hold { + g.acquire() // starts a guard if none runs; a new guard gets every record + } +} + +// dropRecord closes a teardown record (its resource was stopped). +func (g *childGuard) dropRecord(key string) { + g.mu.Lock() + if _, ok := g.records[key]; !ok { + g.mu.Unlock() + return + } + delete(g.records, key) + if g.w != nil { + _, _ = g.w.WriteString("untd\t" + key + "\n") + } + unhold := g.holding && len(g.records) == 0 + if unhold { + g.holding = false + } + g.mu.Unlock() + if unhold { + g.release() + } +} + +func (g *childGuard) writeRecordLocked(key string, rec []byte) { + _, _ = g.w.WriteString("td\t" + key + "\t" + string(rec) + "\n") } var guard childGuard @@ -136,6 +191,9 @@ func (g *childGuard) startLocked() error { _ = r.Close() done := make(chan struct{}) g.w, g.done = w, done + for key, rec := range g.records { + g.writeRecordLocked(key, rec) + } go func() { _ = cmd.Wait() close(done) @@ -186,10 +244,23 @@ func ChildGuardMain() { return } released := false + records := map[string]teardownRecord{} sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 64<<10), 4<<20) for sc.Scan() { - if sc.Text() == "release" { + line := sc.Text() + switch { + case line == "release": released = true + case strings.HasPrefix(line, "td\t"): + if f := strings.SplitN(line, "\t", 3); len(f) == 3 { + var rec teardownRecord + if json.Unmarshal([]byte(f[2]), &rec) == nil { + records[f[1]] = rec + } + } + case strings.HasPrefix(line, "untd\t"): + delete(records, strings.TrimPrefix(line, "untd\t")) } } if released { @@ -202,5 +273,17 @@ func ChildGuardMain() { return // the adapter is alive and closed the pipe itself } } + // Stop what the adapter's calls left running outside the group first: + // those commands are this guard's children, in the group, and would die + // with it. + var wg sync.WaitGroup + for _, rec := range records { + wg.Add(1) + go func(rec teardownRecord) { + defer wg.Done() + runTeardown(rec, TeardownTimeout) + }(rec) + } + wg.Wait() _ = syscall.Kill(-syscall.Getpgrp(), syscall.SIGKILL) } diff --git a/internal/scaffold/templates/client_cli.go.tmpl b/internal/scaffold/templates/client_cli.go.tmpl index e85d902..6beb050 100644 --- a/internal/scaffold/templates/client_cli.go.tmpl +++ b/internal/scaffold/templates/client_cli.go.tmpl @@ -77,6 +77,11 @@ type Spec struct { // Service marks a method that starts a long-running server the adapter // owns (service.go); nil for a run-to-completion command. Service *ServiceSpec + // EnvRules (passthrough) add environment to a matching call's child, and + // Teardown records what a matching call leaves running outside the + // adapter's process group, with the command that stops it (teardown.go). + EnvRules []EnvRule + Teardown []TeardownRule } // Call carries one invocation's dynamic inputs, decoded from the IPC payload. @@ -127,8 +132,14 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage return r.startService(ctx, sv, args, call) } + env := r.childEnv() + var opened []tdOpened + if spec.Passthrough { + env = append(env, r.ruleEnv(spec.EnvRules, call.Args)...) + opened = r.openTeardowns(spec.Teardown, call.Args, r.childEnv()) + } cmd := exec.CommandContext(ctx, r.base[0], args...) - cmd.Env = r.childEnv() + cmd.Env = env // On timeout/cancel, ask the CLI to stop (SIGTERM) before exec's WaitDelay // escalates to SIGKILL. A bare SIGKILL gives the CLI no chance to stop what // it started itself: aegis's model download (curl) and its L2 llama-server @@ -155,6 +166,17 @@ func (r *Runner) Run(ctx context.Context, spec Spec, call Call) (json.RawMessage guard.release() exitCode := 0 + if spec.Passthrough && (len(spec.Teardown) > 0) { + // A start that did not complete (a non-zero exit) closes the record + // it opened; one cut short by the deadline or a stop keeps it, since + // it may have started its resource before it was killed. + var ee *exec.ExitError + if runErr == nil { + settleTeardowns(spec.Teardown, call.Args, opened, true) + } else if ctx.Err() == nil && errors.As(runErr, &ee) && ee.Exited() { + settleTeardowns(spec.Teardown, call.Args, opened, false) + } + } if runErr != nil { var ee *exec.ExitError if ctxErr := ctx.Err(); ctxErr != nil { diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index 890020c..29fa8ee 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -170,6 +170,10 @@ func main() { // them now (SIGTERM, then SIGKILL after a grace period). Before the guard // is closed, since a running server holds it. backend.StopServices() + // What cli.teardown calls left running on their own (a detached VM) is + // stopped with its declared command, before the guard that would + // otherwise do it is released. + backend.RunTeardowns(backend.TeardownTimeout) backend.CloseChildGuard(2 * time.Second) {{- end}} if serveErr != nil { @@ -733,7 +737,7 @@ func jsonScalar(v json.RawMessage) string { func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, c *backend.CloudClient, version, backendURL string) { {{- range .Methods}} {{- if .CLI}} - d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} (local) + d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}}{{.CLI.LifecycleGo}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} (local) {{- else}} d.Register("{{.Name}}", cloudHandler(c, "{{.HTTP.Verb}}", "{{.HTTP.Path}}", {{if eq .HTTP.Path $.ProvisionPath}}true{{else}}false{{end}}, dur("{{.TimeoutFor}}"))) // {{.Duration}} (cloud) {{- end}} @@ -827,7 +831,7 @@ func decodeCall(spec backend.Spec, raw json.RawMessage) (backend.Call, error) { {{- else}} func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, version string) { {{- range .Methods}} - d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} + d.Register("{{.Name}}", forwardExec(r, backend.Spec{ {{if .CLI.Passthrough}}Passthrough: true{{if .CLI.Tools}}, Tools: {{printf "%#v" .CLI.Tools}}{{end}}{{else}}Args: {{printf "%#v" .CLI.Args}}, ParamsAsFlags: {{.CLI.ParamsAsFlags}}{{end}}{{with .CLI.Service}}, Service: &backend.ServiceSpec{ReadyTCP: {{printf "%q" .ReadyTCP}}, ReadyAfter: dur({{printf "%q" .ReadyAfterOrDefault}}), ReadyTimeout: dur({{printf "%q" .ReadyTimeoutOrDefault}}), LogFile: {{printf "%q" .LogFile}}, ForceArgs: {{printf "%#v" .ForceArgs}}, Tools: {{printf "%#v" .Tools}}, ReadyPortFlag: {{printf "%q" .ReadyPortFlag}}}{{end}}{{.CLI.LifecycleGo}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}} {{- end}} d.Register("{{.Namespace}}.help", helpHandler(version)) } diff --git a/internal/scaffold/templates/teardown.go.tmpl b/internal/scaffold/templates/teardown.go.tmpl new file mode 100644 index 0000000..4e28ee7 --- /dev/null +++ b/internal/scaffold/templates/teardown.go.tmpl @@ -0,0 +1,288 @@ +// Per-call environment rules and teardown records for the {{.ID}} cli runner. +// GENERATED by pilot-app; edit pilot.app.yaml and re-generate. +// +// Some CLIs start things that keep running after the CLI itself exits, outside +// the adapter's process group: a detached VM (`smolvm machine start`, +// `machine run -d`), a background daemon the tool manages itself. Neither the +// adapter's clean stop, nor the supervisor's group stop, nor Pdeathsig, nor the +// child guard's group kill reaches them. Found with io.pilot.smol 1.2.0: VMs +// from `machine start` and `machine run -d` survived the app's SIGTERM, the +// supervisor's SIGKILL of the app and uninstall, each holding its VM's memory. +// +// A cli.teardown rule names such a call and the command that undoes it. When a +// matching call runs, the adapter records "run with this env" +// under the resource's name, and hands the record to the child guard +// (childguard.go), which it keeps running while any record is open. Then: +// - a clean stop (SIGTERM, or the parent-death watch in main) runs every +// open record itself (RunTeardowns); +// - if the adapter is SIGKILLed, the guard runs them before it takes the +// process group down; +// - a successful explicit stop (a cli.teardown clear_prefixes call for the +// same name) closes the record, so nothing is stopped twice; +// - a start call that fails closes the record it opened. +// +// A cli.env_rules rule adds environment to one call's child when its argv +// matches: io.pilot.smol sets SMOLVM_BOOT_BINARY for a foreground +// `machine run`, which arms smolvm's own parent-death watchdog in the VM +// process, so an ephemeral VM dies with its CLI however the CLI is stopped. +package backend + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// TeardownTimeout bounds one teardown command, from the adapter or the guard. +const TeardownTimeout = 30 * time.Second + +// EnvRule is a cli.env_rules entry, baked in at generation time. Env is a flat +// key, value, key, value list; values may use ${command_dir}. +type EnvRule struct { + Prefix []string + Unless []string + Env []string +} + +// TeardownRule is a cli.teardown entry, baked in at generation time. +type TeardownRule struct { + Prefix []string + When []string + NameFlags []string + DefaultName string + Run []string // argv after the base command; ${name} + Clear [][]string +} + +// teardownRecord is what the guard needs to stop one resource on its own. +type teardownRecord struct { + Argv []string `json:"argv"` // full argv, base command included + Env []string `json:"env"` +} + +var ( + tdMu sync.Mutex + tdOpen = map[string]teardownRecord{} // key: rule index + name + tdClosed bool // RunTeardowns has started: no new records +) + +// hasPrefix reports whether args starts with prefix, word for word. +func hasPrefix(args, prefix []string) bool { + if len(prefix) == 0 || len(args) < len(prefix) { + return false + } + for i, p := range prefix { + if args[i] != p { + return false + } + } + return true +} + +// options returns args up to a "--" (what follows is the guest command, whose +// own flags must not count). +func options(args []string) []string { + for i, a := range args { + if a == "--" { + return args[:i] + } + } + return args +} + +// hasAnyFlag reports whether args (before "--") carries one of flags, as +// `-d`, `--detach`, `--detach=...`, or a short flag inside a cluster (`-id`). +func hasAnyFlag(args, flags []string) bool { + for _, a := range options(args) { + for _, f := range flags { + if a == f || strings.HasPrefix(a, f+"=") { + return true + } + if len(f) == 2 && f[0] == '-' && f[1] != '-' && len(a) > 2 && a[0] == '-' && a[1] != '-' && isLetters(a[1:]) && strings.ContainsRune(a[1:], rune(f[1])) { + return true + } + } + } + return false +} + +func isLetters(s string) bool { + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') { + return false + } + } + return s != "" +} + +// flagValue returns the value of the first of flags in args (before "--"), +// given as `--name x` or `--name=x`, and whether one was found. +func flagValueOf(args, flags []string) (string, bool) { + opts := options(args) + for i, a := range opts { + for _, f := range flags { + if a == f && i+1 < len(opts) { + return opts[i+1], true + } + if strings.HasPrefix(a, f+"=") { + return strings.TrimPrefix(a, f+"="), true + } + } + } + return "", false +} + +// ruleEnv returns the extra environment the matching env rules give this call. +func (r *Runner) ruleEnv(rules []EnvRule, args []string) []string { + var env []string + for _, er := range rules { + if !hasPrefix(args, er.Prefix) || hasAnyFlag(args, er.Unless) { + continue + } + for i := 0; i+1 < len(er.Env); i += 2 { + env = append(env, er.Env[i]+"="+strings.ReplaceAll(er.Env[i+1], "${command_dir}", r.commandDir())) + } + } + return env +} + +// commandDir is the directory of the resolved base command. +func (r *Runner) commandDir() string { + p := r.base[0] + if !filepath.IsAbs(p) { + if lp, err := exec.LookPath(p); err == nil { + p = lp + } + } + if abs, err := filepath.Abs(p); err == nil { + p = abs + } + return filepath.Dir(p) +} + +// resourceName resolves the name a teardown rule's call acts on. +func (td TeardownRule) resourceName(args []string) string { + if v, ok := flagValueOf(args, td.NameFlags); ok && v != "" { + return v + } + return td.DefaultName +} + +// tdOpened is one record a call opened, so a failed call can close it again. +type tdOpened struct{ key string } + +// openTeardowns records every teardown rule this call matches, before the CLI +// runs (so an adapter killed while the start is in flight still stops what it +// started). It returns the records this call newly opened. +func (r *Runner) openTeardowns(rules []TeardownRule, args, env []string) []tdOpened { + var opened []tdOpened + for i, td := range rules { + if !hasPrefix(args, td.Prefix) || (len(td.When) > 0 && !hasAnyFlag(args, td.When)) { + continue + } + name := td.resourceName(args) + argv := []string{r.base[0]} + argv = append(argv, r.base[1:]...) + for _, a := range td.Run { + argv = append(argv, strings.ReplaceAll(a, "${name}", name)) + } + key := teardownKey(i, name) + rec := teardownRecord{Argv: argv, Env: env} + tdMu.Lock() + if tdClosed { + tdMu.Unlock() + continue + } + _, had := tdOpen[key] + tdOpen[key] = rec + tdMu.Unlock() + line, _ := json.Marshal(rec) + guard.putRecord(key, line) + if !had { + opened = append(opened, tdOpened{key: key}) + } + } + return opened +} + +// settleTeardowns runs after the call: a start that failed closes the records +// it opened, and a successful explicit stop closes the record it stopped. +func settleTeardowns(rules []TeardownRule, args []string, opened []tdOpened, ok bool) { + if !ok { + for _, o := range opened { + closeTeardown(o.key) + } + return + } + for i, td := range rules { + for _, cp := range td.Clear { + if hasPrefix(args, cp) { + closeTeardown(teardownKey(i, td.resourceName(args))) + } + } + } +} + +func teardownKey(rule int, name string) string { + b, _ := json.Marshal([]any{rule, name}) + return string(b) +} + +func closeTeardown(key string) { + tdMu.Lock() + _, had := tdOpen[key] + delete(tdOpen, key) + tdMu.Unlock() + if had { + guard.dropRecord(key) + } +} + +// RunTeardowns is main's clean-shutdown step: it runs every open record's +// stop command (concurrently, each bounded by timeout) and closes it. +func RunTeardowns(timeout time.Duration) { + tdMu.Lock() + tdClosed = true + recs := tdOpen + tdOpen = map[string]teardownRecord{} + tdMu.Unlock() + if len(recs) == 0 { + return + } + var wg sync.WaitGroup + for _, rec := range recs { + wg.Add(1) + go func(rec teardownRecord) { + defer wg.Done() + runTeardown(rec, timeout) + }(rec) + } + wg.Wait() + for key := range recs { + guard.dropRecord(key) + } +} + +// runTeardown runs one stop command; its output is discarded. +func runTeardown(rec teardownRecord, timeout time.Duration) { + if len(rec.Argv) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, rec.Argv[0], rec.Argv[1:]...) + cmd.Env = rec.Env + cmd.WaitDelay = 2 * time.Second + if err := cmd.Run(); err != nil { + logTeardown(rec, err) + } +} + +func logTeardown(rec teardownRecord, err error) { + _, _ = os.Stderr.WriteString("teardown " + strings.Join(rec.Argv[1:], " ") + ": " + err.Error() + "\n") +} diff --git a/internal/scaffold/zz_cli_teardown_e2e_test.go b/internal/scaffold/zz_cli_teardown_e2e_test.go new file mode 100644 index 0000000..3ea0696 --- /dev/null +++ b/internal/scaffold/zz_cli_teardown_e2e_test.go @@ -0,0 +1,495 @@ +//go:build !windows + +package scaffold + +import ( + "encoding/json" + "flag" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// fakeSmolEnv switches the test binary into a stand-in for the smolvm CLI: +// TestHelperFakeSmol. fakeSmolStateEnv names its state directory. +const ( + fakeSmolEnv = "SCAFFOLD_TEST_FAKE_SMOL" + fakeSmolStateEnv = "SCAFFOLD_TEST_FAKE_SMOL_STATE" +) + +// TestHelperFakeSmol is not a test: it is the fake `smolvm` of +// TestCLITeardownE2E and skips unless fakeSmolEnv is set. It models the parts +// of smolvm 1.2.0 that matter for the VM's lifetime: +// +// - `machine run` / `machine start` spawn the VM (`_boot-vm `) in its +// own process group, as smolvm does. With SMOLVM_BOOT_BINARY set, the VM +// watches its parent and exits when the CLI is gone (upstream's watchdog, +// manager.rs); without it, it runs until `machine stop`. +// - `machine run` without -d waits for the VM like the real foreground run, +// and tears it down only on SIGINT (upstream's SigintGuard): SIGTERM or +// SIGKILL of the CLI orphans the VM. +// - `machine start` and `machine run -d` return at once and leave the VM up. +// - `machine stop` kills the named VM and appends its name to /stops. +// - a VM named "fail" fails to start (exit 1) and starts nothing. +func TestHelperFakeSmol(t *testing.T) { + if os.Getenv(fakeSmolEnv) == "" { + t.Skip("helper process for TestCLITeardownE2E") + } + state := os.Getenv(fakeSmolStateEnv) + args := flag.Args() + name := func() string { + for i, a := range args { + if a == "--" { + break + } + if (a == "--name" || a == "-n") && i+1 < len(args) { + return args[i+1] + } + if strings.HasPrefix(a, "--name=") { + return strings.TrimPrefix(a, "--name=") + } + } + return "default" + } + has := func(flags ...string) bool { + for _, a := range args { + if a == "--" { + return false + } + for _, f := range flags { + if a == f { + return true + } + } + } + return false + } + pidFile := func(n string) string { return filepath.Join(state, "vm-"+n+".pid") } + if len(args) >= 2 && args[0] == "_boot-vm" { + n := args[1] + parent := os.Getppid() + _ = os.WriteFile(pidFile(n), []byte(strconv.Itoa(os.Getpid())), 0o644) + signal.Ignore(syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) + if os.Getenv("SMOLVM_BOOT_WATCH_PARENT") == "1" { + for os.Getppid() == parent { + time.Sleep(20 * time.Millisecond) + } + os.Exit(0) + } + for { + time.Sleep(time.Hour) // not select{}: the runtime would call that a deadlock + } + } + if len(args) < 2 || args[0] != "machine" { + fmt.Println(`{"version":"fake"}`) + os.Exit(0) + } + n := name() + switch args[1] { + case "run", "start": + detached := args[1] == "start" || has("-d", "--detach") + _ = os.WriteFile(filepath.Join(state, "env-"+n), []byte(os.Getenv("SMOLVM_BOOT_BINARY")), 0o644) + if n == "fail" { + fmt.Fprintln(os.Stderr, "Error: machine 'fail' could not start") + os.Exit(1) + } + vm := exec.Command(os.Args[0], "-test.run=^TestHelperFakeSmol$", "--", "_boot-vm", n) + watch := "0" + if os.Getenv("SMOLVM_BOOT_BINARY") != "" { + watch = "1" + } + vm.Env = append(os.Environ(), "SMOLVM_BOOT_WATCH_PARENT="+watch) + vm.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := vm.Start(); err != nil { + os.Exit(2) + } + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + if _, err := os.Stat(pidFile(n)); err == nil { + break + } + } + if detached { + fmt.Println("started " + n) + os.Exit(0) + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT) + <-sig + _ = vm.Process.Kill() + os.Exit(130) + case "stop", "delete": + b, err := os.ReadFile(pidFile(n)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: machine '%s' is not running\n", n) + os.Exit(1) + } + if pid, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + _ = os.Remove(pidFile(n)) + f, _ := os.OpenFile(filepath.Join(state, "stops"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + fmt.Fprintln(f, n) + _ = f.Close() + os.Exit(0) + } + os.Exit(0) +} + +// TestCLITeardownE2E: nothing a cli call starts may outlive the adapter, even +// when the CLI hands it off to a process outside the adapter's process group. +// Found with io.pilot.smol 1.2.0 on darwin/arm64 with real smolvm VMs: +// +// - an ephemeral `machine run` VM was orphaned (ppid 1, own group) when the +// CLI got SIGTERM (app stop, process-group stop) or SIGKILL (the 60 s call +// deadline): 2/2, 2/2 and 1/1 runs. smolvm tears it down only on SIGINT. +// Fixed with cli.env_rules: SMOLVM_BOOT_BINARY arms smolvm's own +// parent-death watchdog in the VM for a foreground run. +// - VMs from `machine start` and `machine run -d` kept running after the +// app's SIGTERM and after the supervisor's SIGKILL of the app. Fixed with +// cli.teardown: the adapter stops them with `machine stop --name ` on +// a clean stop, and its child guard does it when the adapter is killed. +func TestCLITeardownE2E(t *testing.T) { + 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() + toolDir := filepath.Join(root, "smolvm-9.9.9") + if err := os.MkdirAll(toolDir, 0o755); err != nil { + t.Fatal(err) + } + tool := filepath.Join(toolDir, "smolvm") + state := filepath.Join(root, "state") + if err := os.MkdirAll(state, 0o755); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\nexec env " + fakeSmolEnv + "=1 " + fakeSmolStateEnv + "='" + state + "' '" + os.Args[0] + "' -test.run='^TestHelperFakeSmol$' -- \"$@\"\n" + if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cfg := parseSpec(t, ` +id: io.pilot.fakesmol +app_version: 0.1.0 +description: "Fronts a fake smolvm." +namespace: fakesmol +backend: + type: cli + command: ["`+tool+`"] +methods: + - name: fakesmol.exec + summary: "Passthrough." + timeout: 4s + cli: + passthrough: true + env_rules: + - argv_prefix: [machine, run] + unless_flags: [-d, --detach] + env: {SMOLVM_BOOT_BINARY: "${command_dir}/smolvm-bin"} + teardown: + - argv_prefix: [machine, start] + name_flags: [--name, -n] + default_name: default + run: [machine, stop, --name, "${name}"] + clear_prefixes: [[machine, stop], [machine, delete]] + - argv_prefix: [machine, run] + when_flags: [-d, --detach] + name_flags: [--name, -n] + default_name: default + run: [machine, stop, --name, "${name}"] + clear_prefixes: [[machine, stop], [machine, delete]] +`) + 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) + } + manifest := filepath.Join(proj, "manifest.json") + + waitFor := func(t *testing.T, cond func() bool, d time.Duration, what string) { + t.Helper() + for deadline := time.Now().Add(d); ; time.Sleep(20 * time.Millisecond) { + if cond() { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out after %s waiting for %s", d, what) + } + } + } + spawn := func(t *testing.T) (*exec.Cmd, string) { + t.Helper() + dir, err := os.MkdirTemp("", "td") // short: sun_path is ~104 bytes on darwin + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + sock := filepath.Join(dir, "app.sock") + a := exec.Command(bin, "--socket", sock, "--manifest", manifest) + a.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + a.Stderr = os.Stderr + if err := a.Start(); err != nil { + t.Fatalf("start adapter: %v", err) + } + pid := a.Process.Pid + t.Cleanup(func() { _ = syscall.Kill(-pid, syscall.SIGKILL); _, _ = a.Process.Wait() }) + waitFor(t, func() bool { _, err := os.Stat(sock); return err == nil }, 10*time.Second, "adapter socket") + return a, sock + } + call := func(sock string, args ...string) (map[string]any, error) { + conn, err := net.DialTimeout("unix", sock, 3*time.Second) + if err != nil { + return nil, err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(20 * time.Second)) + var out json.RawMessage + if err := ipc.Call(conn, "fakesmol.exec", map[string]any{"args": args}, &out); err != nil { + return nil, err + } + var m map[string]any + _ = json.Unmarshal(out, &m) + return m, nil + } + vmPid := func(t *testing.T, name string) int { + t.Helper() + var pid int + waitFor(t, func() bool { + b, err := os.ReadFile(filepath.Join(state, "vm-"+name+".pid")) + if err != nil { + return false + } + pid, err = strconv.Atoi(strings.TrimSpace(string(b))) + return err == nil && pid > 0 + }, 10*time.Second, "VM "+name+" to start") + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + return pid + } + stops := func() map[string]int { + b, _ := os.ReadFile(filepath.Join(state, "stops")) + m := map[string]int{} + for _, l := range strings.Fields(string(b)) { + m[l]++ + } + return m + } + reset := func() { + _ = os.Remove(filepath.Join(state, "stops")) + } + envOf := func(name string) string { + b, _ := os.ReadFile(filepath.Join(state, "env-"+name)) + return string(b) + } + stopBy := map[string]func(a *exec.Cmd){ + "sigterm": func(a *exec.Cmd) { _ = a.Process.Signal(syscall.SIGTERM); _, _ = a.Process.Wait() }, + // The app-store supervisor's stop path through v1.0.3. + "sigkill": func(a *exec.Cmd) { _ = a.Process.Kill(); _, _ = a.Process.Wait() }, + // A supervisor that signals the whole group. + "group-sigterm": func(a *exec.Cmd) { _ = syscall.Kill(-a.Process.Pid, syscall.SIGTERM); _, _ = a.Process.Wait() }, + } + + // Ephemeral VM: however the adapter stops mid-run, the VM goes with it. + for _, how := range []string{"sigterm", "sigkill", "group-sigterm"} { + t.Run("ephemeral/"+how, func(t *testing.T) { + reset() + a, sock := spawn(t) + name := "eph-" + how + go func() { + _, _ = call(sock, "machine", "run", "--name", name, "--image", "alpine", "--", "sleep", "-d", "30") + }() + vm := vmPid(t, name) + if got, want := envOf(name), filepath.Join(toolDir, "smolvm-bin"); got != want { + t.Errorf("SMOLVM_BOOT_BINARY for a foreground run = %q, want %q", got, want) + } + stopBy[how](a) + waitFor(t, func() bool { return !procAlive(vm) }, 10*time.Second, "ephemeral VM to die after adapter "+how) + if left := groupLeft(a.Process.Pid, 3*time.Second); len(left) > 0 { + t.Errorf("adapter group not empty after %s: %v", how, left) + } + }) + } + + // The call deadline (timeout: 4s) stops the CLI with SIGTERM, then SIGKILL: + // the VM must not survive it. + t.Run("ephemeral/deadline", func(t *testing.T) { + reset() + _, sock := spawn(t) + done := make(chan error, 1) + go func() { + _, err := call(sock, "machine", "run", "--name", "eph-deadline", "--", "sleep", "30") + done <- err + }() + vm := vmPid(t, "eph-deadline") + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "deadline") { + t.Errorf("run past the deadline: err = %v, want a deadline error", err) + } + case <-time.After(15 * time.Second): + t.Fatal("call did not end at its deadline") + } + waitFor(t, func() bool { return !procAlive(vm) }, 10*time.Second, "VM to die after the call deadline") + }) + + // Persistent VMs: stopped with `machine stop --name ` on SIGTERM, by + // the child guard on SIGKILL, and after a supervisor group SIGTERM. + for _, how := range []string{"sigterm", "sigkill", "group-sigterm"} { + t.Run("persistent/"+how, func(t *testing.T) { + reset() + a, sock := spawn(t) + starts := [][]string{ + {"machine", "start", "--name", "p1-" + how}, + {"machine", "run", "-d", "--name=p2-" + how, "--image", "alpine"}, + {"machine", "start"}, // the default VM + } + var vms []int + for _, s := range starts { + res, err := call(sock, s...) + if err != nil || res["exit"] != float64(0) { + t.Fatalf("%v = %v, %v", s, res, err) + } + } + for _, n := range []string{"p1-" + how, "p2-" + how, "default"} { + vms = append(vms, vmPid(t, n)) + if env := envOf(n); env != "" { + t.Errorf("VM %s: SMOLVM_BOOT_BINARY=%q on a persistent start; its VM would die with the CLI", n, env) + } + } + // Long past the CLI's exit and the guard's idle retirement: the + // VMs are still up (persistent), held only by the teardown records. + time.Sleep(1500 * time.Millisecond) + for _, p := range vms { + if !procAlive(p) { + t.Fatalf("persistent VM %d died before the adapter stopped", p) + } + } + stopBy[how](a) + for _, p := range vms { + waitFor(t, func() bool { return !procAlive(p) }, 10*time.Second, fmt.Sprintf("VM %d to be stopped after adapter %s", p, how)) + } + names := []string{"p1-" + how, "p2-" + how, "default"} + // The fake stop logs its name after it has killed the VM. + waitFor(t, func() bool { + got := stops() + return got[names[0]] > 0 && got[names[1]] > 0 && got[names[2]] > 0 + }, 5*time.Second, "every machine stop to finish") + got := stops() + for _, n := range names { + if got[n] != 1 { + t.Errorf("machine stop --name %s ran %d times, want 1 (stops: %v)", n, got[n], got) + } + } + if left := groupLeft(a.Process.Pid, 3*time.Second); len(left) > 0 { + t.Errorf("adapter group not empty after %s: %v", how, left) + } + }) + } + + // An explicit stop (any spelling of the name) closes the record, and a + // start that fails opens none: nothing is stopped twice or needlessly. + t.Run("records", func(t *testing.T) { + reset() + a, sock := spawn(t) + if _, err := call(sock, "machine", "start", "-n", "c1"); err != nil { + t.Fatal(err) + } + vm := vmPid(t, "c1") + if res, err := call(sock, "machine", "stop", "--name=c1"); err != nil || res["exit"] != float64(0) { + t.Fatalf("machine stop = %v, %v", res, err) + } + waitFor(t, func() bool { return !procAlive(vm) }, 5*time.Second, "c1 to stop") + if res, err := call(sock, "machine", "start", "--name", "fail"); err != nil || res["exit"] != float64(1) { + t.Fatalf("failing start = %v, %v", res, err) + } + // A read-only command opens nothing. + if _, err := call(sock, "machine", "ls"); err != nil { + t.Fatal(err) + } + _ = a.Process.Signal(syscall.SIGTERM) + _, _ = a.Process.Wait() + got := stops() + if got["c1"] != 1 || got["fail"] != 0 || len(got) != 1 { + t.Errorf("stops = %v, want only c1 once (the explicit stop)", got) + } + }) +} + +// TestValidateCLILifecycle: env_rules/teardown are checked at generation time. +func TestValidateCLILifecycle(t *testing.T) { + base := func(cli string) string { + return ` +id: io.pilot.x +app_version: 0.1.0 +description: "x" +namespace: x +backend: {type: cli, command: ["x"]} +methods: + - name: x.exec + summary: "x" + cli: +` + cli + } + cases := []struct { + name, cli, want string + }{ + {"ok", ` passthrough: true + env_rules: [{argv_prefix: [a], unless_flags: [-d], env: {K: "${command_dir}/b"}}] + teardown: [{argv_prefix: [a], name_flags: [--name], run: [stop, "${name}"], clear_prefixes: [[stop]]}] +`, ""}, + {"not passthrough", ` args: [a] + teardown: [{argv_prefix: [a], default_name: d, run: [stop]}] +`, "only apply to a passthrough route"}, + {"bad env placeholder", ` passthrough: true + env_rules: [{argv_prefix: [a], env: {K: "${home}"}}] +`, "only ${command_dir}"}, + {"bad env name", ` passthrough: true + env_rules: [{argv_prefix: [a], env: {"1K": "v"}}] +`, "not a valid variable name"}, + {"empty prefix", ` passthrough: true + teardown: [{argv_prefix: [], default_name: d, run: [stop]}] +`, "argv_prefix must not be empty"}, + {"name without source", ` passthrough: true + teardown: [{argv_prefix: [a], run: [stop, "${name}"]}] +`, "no name_flags or default_name"}, + {"bad flag", ` passthrough: true + teardown: [{argv_prefix: [a], when_flags: [detach], default_name: d, run: [stop]}] +`, "must be a flag"}, + {"no run", ` passthrough: true + teardown: [{argv_prefix: [a], default_name: d}] +`, "run (the argv that stops"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + errs := validateSpec(t, base(tc.cli)) + if tc.want == "" { + if errs != "" { + t.Fatalf("want valid, got %s", errs) + } + return + } + if !strings.Contains(errs, tc.want) { + t.Fatalf("errs = %q, want it to mention %q", errs, tc.want) + } + }) + } +} diff --git a/submissions/io.pilot.smol/submission.json b/submissions/io.pilot.smol/submission.json index 679e1b7..7e2b571 100644 --- a/submissions/io.pilot.smol/submission.json +++ b/submissions/io.pilot.smol/submission.json @@ -192,7 +192,7 @@ ] }, "id": "io.pilot.smol", - "version": "1.2.0", + "version": "1.2.1", "description": "Smol Machines — fast, hardware-isolated Linux microVMs for agents, now local AND cloud. Create and run sub-second microVMs locally with the smolvm CLI (real hypervisor isolation, networking off by default), then push a VM to the smol cloud with a single method. Pilot provisions a per-user cloud key automatically on install; each user's cloud machines are isolated and metered against their own free credit. No cloud account, no API key to manage.", "email": "apps@pilotprotocol.network", "backend": { @@ -220,11 +220,86 @@ "methods": [ { "name": "smol.exec", - "description": "Run ANY smolvm subcommand in a fast, hardware-isolated Linux microVM LOCALLY. Payload is {\"args\":[...]} (verbatim smolvm argv) with optional {\"stdin\":\"...\"}. This one method exposes the whole smolvm CLI — for the complete agent reference call smol.exec {\"args\":[\"--help\"]}, and for any subcommand call smol.exec {\"args\":[\"\",\"--help\"]}.\n\nCOMMAND SURFACE:\n• machine run — create an EPHEMERAL VM, run one command, tear down (nothing persists). e.g. [\"machine\",\"run\",\"--net\",\"--image\",\"alpine\",\"--\",\"sh\",\"-c\",\"echo hi\"].\n• machine create | start | stop | delete — lifecycle of a PERSISTENT named VM (--name, default \"default\").\n• machine exec — run a command in a persistent VM; FILESYSTEM CHANGES PERSIST across sessions (package installs stick). e.g. [\"machine\",\"exec\",\"--name\",\"myvm\",\"--\",\"apk\",\"add\",\"python3\"].\n• machine status | ls | images | monitor — read-only introspection (do NOT stop a running VM).\n• machine cp — copy files host↔VM (HOST:GUEST). machine update — change mounts/ports/env/cpu/memory on a STOPPED VM. machine prune — reclaim layers (prune --all needs the VM stopped).\n• pack create -o — build a portable, self-contained .smolmachine executable; pack run — run one. machine create --from .smolmachine for fast start.\n• serve start --listen — HTTP API server (POST/GET /api/v1/machines…); serve openapi — the spec.\n• config — manage registries + defaults.\n\nKEY FLAGS: --net (networking is OFF by default), --image , -v HOST:GUEST[:ro] (mount; -v host:/workspace replaces the default workspace), -p HOST:GUEST (port), --gpu, --ssh-agent (forward host SSH agent; keys never enter the VM), --secret-env GUEST=HOST / --secret-file GUEST=/abs / -s Smolfile (inject secrets by reference), --from , --cpus, --memory.\n\nDEFAULTS: network off; cpus 4; memory 8192 MiB; storage 20 GiB; name \"default\". Elastic memory/CPU via virtio balloon.\n\nNOT SUPPORTED OVER IPC: interactive sessions (-it / machine shell) and long-running serve (no attached TTY).", + "description": "Run ANY smolvm subcommand in a fast, hardware-isolated Linux microVM LOCALLY. Payload is {\"args\":[...]} (verbatim smolvm argv) with optional {\"stdin\":\"...\"}. This one method exposes the whole smolvm CLI — for the complete agent reference call smol.exec {\"args\":[\"--help\"]}, and for any subcommand call smol.exec {\"args\":[\"\",\"--help\"]}.\n\nCOMMAND SURFACE:\n• machine run — create an EPHEMERAL VM, run one command, tear down (nothing persists). e.g. [\"machine\",\"run\",\"--net\",\"--image\",\"alpine\",\"--\",\"sh\",\"-c\",\"echo hi\"].\n• machine create | start | stop | delete — lifecycle of a PERSISTENT named VM (--name, default \"default\").\n• machine exec — run a command in a persistent VM; FILESYSTEM CHANGES PERSIST across sessions (package installs stick). e.g. [\"machine\",\"exec\",\"--name\",\"myvm\",\"--\",\"apk\",\"add\",\"python3\"].\n• machine status | ls | images | monitor — read-only introspection (do NOT stop a running VM).\n• machine cp — copy files host↔VM (HOST:GUEST). machine update — change mounts/ports/env/cpu/memory on a STOPPED VM. machine prune — reclaim layers (prune --all needs the VM stopped).\n• pack create -o — build a portable, self-contained .smolmachine executable; pack run — run one. machine create --from .smolmachine for fast start.\n• serve start --listen — HTTP API server (POST/GET /api/v1/machines…); serve openapi — the spec.\n• config — manage registries + defaults.\n\nKEY FLAGS: --net (networking is OFF by default), --image , -v HOST:GUEST[:ro] (mount; -v host:/workspace replaces the default workspace), -p HOST:GUEST (port), --gpu, --ssh-agent (forward host SSH agent; keys never enter the VM), --secret-env GUEST=HOST / --secret-file GUEST=/abs / -s Smolfile (inject secrets by reference), --from , --cpus, --memory.\n\nDEFAULTS: network off; cpus 4; memory 8192 MiB; storage 20 GiB; name \"default\". Elastic memory/CPU via virtio balloon.\n\nLIFETIME: VMs are owned by this app. A persistent VM started here (machine start, machine run -d) is stopped when the app stops (its disk persists; machine start brings it back), and an ephemeral machine run VM never outlives its call.\n\nNOT SUPPORTED OVER IPC: interactive sessions (-it / machine shell) and long-running serve (no attached TTY).", "latency": "med", "params": [], "cli": { - "passthrough": true + "passthrough": true, + "env_rules": [ + { + "argv_prefix": [ + "machine", + "run" + ], + "unless_flags": [ + "-d", + "--detach" + ], + "env": { + "SMOLVM_BOOT_BINARY": "${command_dir}/smolvm-bin" + } + } + ], + "teardown": [ + { + "argv_prefix": [ + "machine", + "start" + ], + "name_flags": [ + "--name", + "-n" + ], + "default_name": "default", + "run": [ + "machine", + "stop", + "--name", + "${name}" + ], + "clear_prefixes": [ + [ + "machine", + "stop" + ], + [ + "machine", + "delete" + ] + ] + }, + { + "argv_prefix": [ + "machine", + "run" + ], + "when_flags": [ + "-d", + "--detach" + ], + "name_flags": [ + "--name", + "-n" + ], + "default_name": "default", + "run": [ + "machine", + "stop", + "--name", + "${name}" + ], + "clear_prefixes": [ + [ + "machine", + "stop" + ], + [ + "machine", + "delete" + ] + ] + } + ] } }, {