Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/CLI-ADAPTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ down; on Linux it also gets SIGTERM from the kernel when the adapter dies. A
starts, and a process that exits before it is ready (`--version`, a config
error) returns its output like a plain command.

`stop_signal` (`SIGTERM`, the default, or `SIGINT` or `SIGQUIT`) replaces
SIGTERM in that clean stop. Use it when SIGTERM is not the server's fast clean
stop. PostgreSQL's SIGTERM is a "smart" shutdown that waits for every client to
disconnect, so one open connection would run out the 8 s and end in a SIGKILL
and crash recovery. Its SIGINT is a fast shutdown with a checkpoint.

A server must never be started through a wrapper that daemonizes it. For
example, `pg_ctl start` forks the postmaster into its own session. Run the
server binary itself in the foreground. For PostgreSQL that is
`postgres -D <datadir> -p <port> -h 127.0.0.1`, with `stop_signal: SIGINT`.
Leave the wrapper out of a passthrough route's `cli.tools`.

On a passthrough route, `service.tools` names the tools that start a server
(needs `cli.tools`). There are no `${field}` params, so readiness comes from
the argv: with `ready_port_flag`, a call that passes that flag (`--port N` or
Expand Down
2 changes: 2 additions & 0 deletions internal/publish/submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ type SubCLIService struct {
ForceArgs []string `json:"force_args,omitempty"`
Tools []string `json:"tools,omitempty"`
ReadyPortFlag string `json:"ready_port_flag,omitempty"`
StopSignal string `json:"stop_signal,omitempty"`
}

// SubParam is one structured input parameter (vs the old free-text field).
Expand Down Expand Up @@ -742,6 +743,7 @@ func (s Submission) ToConfig() *scaffold.Config {
ForceArgs: sv.ForceArgs,
Tools: sv.Tools,
ReadyPortFlag: sv.ReadyPortFlag,
StopSignal: sv.StopSignal,
}
}
default:
Expand Down
9 changes: 9 additions & 0 deletions internal/scaffold/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,10 @@ type CLIService struct {
// `--port=N`), the server is ready once 127.0.0.1:N accepts (and the call
// fails fast if something already does) instead of after ReadyAfter.
ReadyPortFlag string `yaml:"ready_port_flag,omitempty"`
// StopSignal is sent to stop the server: SIGTERM (default), SIGINT or
// SIGQUIT; SIGKILL follows after a grace period. Use it when SIGTERM is
// not the server's fast clean stop (PostgreSQL: SIGINT).
StopSignal string `yaml:"stop_signal,omitempty"`
}

// ReadyAfterOrDefault / ReadyTimeoutOrDefault feed the generated Spec.
Expand Down Expand Up @@ -1621,6 +1625,11 @@ func validateCLIService(i int, m Method, sv *CLIService) []error {
if mt, err := time.ParseDuration(m.TimeoutFor()); err == nil && rt >= mt {
errs = append(errs, fmt.Errorf("%s.ready_timeout %s must be below the method timeout %s", at, rt, mt))
}
switch sv.StopSignal {
case "", "SIGTERM", "SIGINT", "SIGQUIT":
default:
errs = append(errs, fmt.Errorf("%s.stop_signal %q must be SIGTERM, SIGINT or SIGQUIT", at, sv.StopSignal))
}
if m.CLI.Passthrough {
if len(sv.Tools) == 0 {
errs = append(errs, fmt.Errorf("%s on a passthrough route needs tools (the args[0] names that start a server)", at))
Expand Down
4 changes: 2 additions & 2 deletions internal/scaffold/templates/main.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,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}}, StopSignal: {{printf "%q" .StopSignal}}}{{end}} }, 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}}
Expand Down Expand Up @@ -827,7 +827,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}}, StopSignal: {{printf "%q" .StopSignal}}}{{end}} }, dur("{{.TimeoutFor}}"))) // {{.Duration}}
{{- end}}
d.Register("{{.Namespace}}.help", helpHandler(version))
}
Expand Down
27 changes: 22 additions & 5 deletions internal/scaffold/templates/service.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ type ServiceSpec struct {
// binary's first launch can take seconds (macOS assesses it; Rosetta
// translates it) before it listens.
ReadyPortFlag string
// StopSignal asks the server to stop: "SIGTERM" (default), "SIGINT" or
// "SIGQUIT". Some servers treat SIGTERM as a slow, polite stop that waits
// for clients (PostgreSQL's "smart" shutdown); SIGINT is their fast,
// clean one. SIGKILL still follows after serviceStopGrace.
StopSignal string
}

// stopSignal maps ServiceSpec.StopSignal to a signal (validated at generation).
func stopSignal(name string) syscall.Signal {
switch name {
case "SIGINT":
return syscall.SIGINT
case "SIGQUIT":
return syscall.SIGQUIT
}
return syscall.SIGTERM
}

// appliesTo reports whether this call starts a server. Every call of an
Expand All @@ -92,6 +108,7 @@ type service struct {
name string
cmd *exec.Cmd
out *tailBuffer
sig syscall.Signal // asks it to stop (ServiceSpec.StopSignal)
done chan struct{} // closed once Wait has returned
err error // Wait's result, valid after done
}
Expand Down Expand Up @@ -135,7 +152,7 @@ func (r *Runner) startService(ctx context.Context, sv *ServiceSpec, args []strin
name = filepath.Base(argv[0])
}

s := &service{name: name, out: &tailBuffer{limit: serviceTailBytes}, done: make(chan struct{})}
s := &service{name: name, sig: stopSignal(sv.StopSignal), out: &tailBuffer{limit: serviceTailBytes}, done: make(chan struct{})}
cmd := exec.Command(r.base[0], argv...)
cmd.Env = r.childEnv()
cmd.Stdout, cmd.Stderr = s.out, s.out
Expand Down Expand Up @@ -229,9 +246,9 @@ func (r *Runner) startService(ctx context.Context, sv *ServiceSpec, args []strin
}
}

// stop SIGTERMs the service, then SIGKILLs it after grace, and waits for it.
// stop sends the service its stop signal, then SIGKILL after grace, and waits.
func (s *service) stop(grace time.Duration) {
_ = s.cmd.Process.Signal(syscall.SIGTERM)
_ = s.cmd.Process.Signal(s.sig)
select {
case <-s.done:
return
Expand Down Expand Up @@ -259,7 +276,7 @@ func StopServices() {
return
}
for _, s := range list {
_ = s.cmd.Process.Signal(syscall.SIGTERM)
_ = s.cmd.Process.Signal(s.sig)
}
deadline := time.After(serviceStopGrace)
wait:
Expand All @@ -274,7 +291,7 @@ wait:
select {
case <-s.done:
default:
log.Printf("backend: service %s (pid %d) ignored SIGTERM for %s; killing it", s.name, s.cmd.Process.Pid, serviceStopGrace)
log.Printf("backend: service %s (pid %d) ignored %s for %s; killing it", s.name, s.cmd.Process.Pid, s.sig, serviceStopGrace)
_ = s.cmd.Process.Kill()
select {
case <-s.done:
Expand Down
2 changes: 2 additions & 0 deletions internal/scaffold/zz_cli_service_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ methods:
{"service.tools on enumerated", ` cli: {args: ["x"], service: {tools: ["a"]}}`, "tools only applies to a passthrough route"},
{"ok ready_port_flag", ` cli: {passthrough: true, tools: ["a"], service: {tools: ["a"], ready_port_flag: "--port"}}`, ""},
{"ready_port_flag not a flag", ` cli: {passthrough: true, tools: ["a"], service: {tools: ["a"], ready_port_flag: "port"}}`, "must be a flag such as --port"},
{"ok stop_signal", ` cli: {args: ["x"], service: {stop_signal: "SIGINT"}}`, ""},
{"bad stop_signal", ` cli: {args: ["x"], service: {stop_signal: "SIGKILL"}}`, "stop_signal \"SIGKILL\" must be SIGTERM, SIGINT or SIGQUIT"},
{"ready_port_flag on enumerated", ` cli: {args: ["x"], service: {ready_port_flag: "--port"}}`, "ready_port_flag only applies to a passthrough route"},
}
for _, tc := range cases {
Expand Down
38 changes: 38 additions & 0 deletions internal/scaffold/zz_cli_service_lifetime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func TestCLIServiceLifetime(t *testing.T) {
script := "#!/bin/sh\n" +
"case \"$1\" in\n" +
" serve) echo \"$$ $*\" > \"$2\"; trap 'echo term > \"$2.term\"; kill $! 2>/dev/null; exit 0' TERM; sleep 300 & wait;;\n" +
" servei) echo \"$$ $*\" > \"$2\"; trap 'echo int > \"$2.int\"; kill $! 2>/dev/null; exit 0' INT; trap 'echo term > \"$2.term\"; kill $! 2>/dev/null; exit 0' TERM; sleep 300 & wait;;\n" +
" quick) echo quick-out; exit 3;;\n" +
" listen) shift; exec env " + slowListenerEnv + "=1 '" + os.Args[0] + "' -test.run='^TestHelperSlowListener$' -- \"$@\";;\n" +
" *) echo '{}';;\n" +
Expand All @@ -128,6 +129,11 @@ methods:
cli:
args: ["serve", "${pidfile}"]
service: {ready_after: "300ms", ready_timeout: "10s", force_args: ["--daemonize", "no"]}
- name: svctool.startint
summary: "Start a server whose fast clean stop is SIGINT."
cli:
args: ["servei", "${pidfile}"]
service: {ready_after: "300ms", ready_timeout: "10s", stop_signal: "SIGINT"}
- name: svctool.listen
summary: "Start a server that must answer on a port."
cli:
Expand Down Expand Up @@ -459,4 +465,36 @@ methods:
t.Errorf("process group %d not empty after SIGTERM: %v", apid, left)
}
})

// stop_signal: a service declared with SIGINT (PostgreSQL's fast
// shutdown; its SIGTERM is a "smart" one that waits for every client) gets
// SIGINT, not SIGTERM, when the adapter stops.
t.Run("stop-signal", func(t *testing.T) {
dir := shortDir(t)
sock := filepath.Join(dir, "app.sock")
a := spawn(t, sock)
apid := a.Process.Pid
pf := filepath.Join(dir, "i.pid")
if _, err := call(sock, "svctool.startint", map[string]string{"pidfile": pf}); err != nil {
t.Fatalf("startint: %v", err)
}
pid, _ := readPid(t, pf)
_ = a.Process.Signal(syscall.SIGTERM)
st, _ := a.Process.Wait()
if st == nil || !st.Success() {
t.Errorf("adapter exit after SIGTERM = %v, want 0", st)
}
if procAlive(pid) {
t.Errorf("server %d alive after the adapter's SIGTERM exit", pid)
}
if _, err := os.Stat(pf + ".int"); err != nil {
t.Errorf("server was not stopped with its stop_signal SIGINT (no i.pid.int)")
}
if _, err := os.Stat(pf + ".term"); err == nil {
t.Errorf("server got SIGTERM although its stop_signal is SIGINT")
}
if left := groupMembers(apid); len(left) > 0 {
t.Errorf("process group %d not empty after SIGTERM: %v", apid, left)
}
})
}
Loading
Loading