- runAppBinary registers SIGINT and SIGTERM after its build and recursion checks and runChild forwards each signal to the app binary, then waits for it to exit, so stopping summer serve frees the port (WR-08) - newAppCmd keeps the inherited stdin, env and output, and a context cancel sends SIGTERM with a 15-second appShutdownGrace before SIGKILL - the child stays in summer's process group so the admin password prompts can read the terminal - helper-process tests cover SIGINT, SIGTERM and context cancel - console introduction notes the signal forwarding
180 lines
6.2 KiB
Go
180 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/internal/build"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
)
|
|
|
|
// appRunner runs the app binary with argv; tests replace it.
|
|
var appRunner = runAppBinary
|
|
|
|
// delegateCommands returns every command summer runs on the app binary. One
|
|
// table is the single source: name, base description and a hint naming the
|
|
// common flags or arguments the app binary accepts.
|
|
func delegateCommands() []bonfire.Command {
|
|
rows := []struct{ name, description, hint string }{
|
|
{"migrate", "Run plugin migrations in the app binary", ""},
|
|
{"migrate:rollback", "Roll back the last migration of a plugin in the app binary", "--plugin <id>"},
|
|
{"migrate:status", "Show per-plugin migration history in the app binary", ""},
|
|
{"serve", "Run the app HTTP server", "--addr <host:port> (default :8080)"},
|
|
{"queue:work", "Run background job workers in the app binary", "--queue <name> (repeatable)"},
|
|
{"queue:clear", "Clear pending queued jobs in the app binary", `[queue] argument (default "default")`},
|
|
{"schedule:run", "Run the scheduler in the app binary (or once for system cron)", "--once"},
|
|
{"route:list", "List the app's routes from the app binary", ""},
|
|
{"key:generate", "Print a fresh application key from the app binary", ""},
|
|
{"admin:create", "Create a backend administrator in the app binary", "--email <email> (required), --login, --role <code>, --superuser; the password is prompted or read from stdin"},
|
|
{"admin:reset-password", "Reset a backend administrator's password in the app binary", "<identifier> (login or email); the password is prompted or read from stdin"},
|
|
}
|
|
cmds := make([]bonfire.Command, 0, len(rows))
|
|
for _, r := range rows {
|
|
cmds = append(cmds, delegateCommand(r.name, r.description, r.hint))
|
|
}
|
|
return cmds
|
|
}
|
|
|
|
// delegateCommand returns a passthrough command that runs name on the app
|
|
// binary with every argument summer was given.
|
|
func delegateCommand(name, description, hint string) bonfire.Command {
|
|
long := description + ".\n\nsummer runs bin/<binary> " + name +
|
|
" in the application directory, building the binary first when it is missing. Every flag and argument after " +
|
|
name + ", including --help, is passed to the app binary unchanged."
|
|
if hint != "" {
|
|
long += "\n\nCommon flags: " + hint + "."
|
|
}
|
|
long += "\n\nRun summer " + name + " --help inside an application to list all of them."
|
|
return bonfire.Command{
|
|
Name: name,
|
|
Description: description + " (flags pass to the app binary)",
|
|
Long: long,
|
|
Passthrough: true,
|
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
|
args := in.Args()
|
|
// One leading "--" is dropped so the older
|
|
// `summer serve -- --addr :8123` form keeps working.
|
|
if len(args) > 0 && args[0] == "--" {
|
|
args = args[1:]
|
|
}
|
|
argv := append([]string{name}, args...)
|
|
return appRunner(ctx, argv, out)
|
|
},
|
|
}
|
|
}
|
|
|
|
func runAppBinary(ctx context.Context, argv []string, out bonfire.Output) error {
|
|
if len(argv) == 0 {
|
|
return fmt.Errorf("summer: missing app command")
|
|
}
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appDir, err := build.FindAppDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bin := filepath.Join(appDir, "bin", m.Binary)
|
|
if _, err := os.Stat(bin); err != nil {
|
|
if err := build.App(ctx, appDir, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
self, err := os.Executable()
|
|
if err == nil {
|
|
if same, _ := sameFile(self, bin); same {
|
|
return fmt.Errorf("summer: refusing to recurse into %s", bin)
|
|
}
|
|
}
|
|
cmd := newAppCmd(ctx, bin, appDir, argv, out)
|
|
sigs := make(chan os.Signal, 2)
|
|
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
|
defer signal.Stop(sigs)
|
|
if err := runChild(cmd, sigs); err != nil {
|
|
return fmt.Errorf("summer: %s: %w", strings.Join(argv, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appShutdownGrace is how long a cancelled app binary may take to exit after
|
|
// SIGTERM before it is killed. It is longer than the 10-second shutdown
|
|
// timeout of serve and the workers, so they finish a graceful shutdown.
|
|
const appShutdownGrace = 15 * time.Second
|
|
|
|
// newAppCmd builds the command that runs the app binary bin with argv in dir.
|
|
// The child inherits summer's stdin, so the hidden password prompts of
|
|
// admin:create and admin:reset-password read from the terminal. Cancelling
|
|
// ctx sends SIGTERM, and the child is killed only after appShutdownGrace.
|
|
func newAppCmd(ctx context.Context, bin, dir string, argv []string, out io.Writer) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, bin, argv...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = out
|
|
cmd.Stderr = out
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Env = os.Environ()
|
|
cmd.Cancel = func() error {
|
|
err := cmd.Process.Signal(syscall.SIGTERM)
|
|
if err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
// SIGTERM is not supported everywhere (Windows); kill instead.
|
|
return cmd.Process.Kill()
|
|
}
|
|
return err
|
|
}
|
|
cmd.WaitDelay = appShutdownGrace
|
|
return cmd
|
|
}
|
|
|
|
// runChild starts cmd, forwards every signal received on sigs to it and
|
|
// waits for it to exit, returning Wait's error. It never escalates to a kill:
|
|
// the app binary decides how to shut down.
|
|
//
|
|
// Ctrl+C in a terminal delivers SIGINT to both summer and the child, because
|
|
// they share the foreground process group, so the child may see SIGINT twice.
|
|
// The app's NotifyContext-based shutdown tolerates that. The child is
|
|
// deliberately not put in its own process group (Setpgid): a background
|
|
// process group that reads the terminal gets SIGTTIN, which would break the
|
|
// password prompts of admin:create and admin:reset-password.
|
|
func runChild(cmd *exec.Cmd, sigs <-chan os.Signal) error {
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
for {
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case sig := <-sigs:
|
|
// Best effort: the child may already have exited
|
|
// (os.ErrProcessDone) or the platform may not support sig.
|
|
_ = cmd.Process.Signal(sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameFile(a, b string) (bool, error) {
|
|
ai, err := os.Stat(a)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
bi, err := os.Stat(b)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return os.SameFile(ai, bi), nil
|
|
}
|