diff --git a/go/README.md b/go/README.md index 14843ed..77e8570 100644 --- a/go/README.md +++ b/go/README.md @@ -79,7 +79,8 @@ That is the entire plugin. `pluginsdk.Main`: 3. configures logging and runs the optional `Setup` hook, 4. adopts the `--api_fd` socket, 5. builds the gin engine with default middleware and calls `Register`, -6. serves until `SIGINT`/`SIGTERM`, then drains and shuts down. +6. serves until `SIGINT`/`SIGTERM`, then drains, runs the optional `Shutdown` + hook, and returns. Run it locally with `--api-addr` instead of `--api_fd`: @@ -286,6 +287,9 @@ type Plugin[C any] struct { // Setup is an optional pre-serve hook (see SetupFunc). Setup SetupFunc[C] + // Shutdown is an optional post-drain hook (see ShutdownFunc). + Shutdown ShutdownFunc[C] + // Middleware is appended to the default stack (unless disabled below). Middleware []gin.HandlerFunc @@ -295,17 +299,27 @@ type Plugin[C any] struct { } ``` -### `type RegisterFunc[C any]` and `type SetupFunc[C any]` +### `type RegisterFunc[C any]`, `type SetupFunc[C any]` and `type ShutdownFunc[C any]` ```go type RegisterFunc[C any] func(ctx context.Context, cfg *C, engine *gin.Engine) error type SetupFunc[C any] func(ctx context.Context, cfg *C) error +type ShutdownFunc[C any] func(ctx context.Context, cfg *C) error ``` `Register` attaches routes; returning a non-nil error aborts startup. `Setup` is an optional hook that runs once after configuration is resolved but before the server starts accepting requests — use it for one-time work such as fetching -source code. +source code. `Shutdown` is an optional hook that runs once after the server has +stopped accepting requests and drained the ones in flight, but before `Main` +returns — use it to release what `Register` started and the platform cannot see +for you: a session with another service, a lease, a node registration. It runs +on what is left of the `ShutdownTimeout` budget after the drain, and `ctx` is +bound accordingly; a returned error is logged, not fatal. + +Do not tear down from a goroutine watching the base context instead: it races +the return from `Main`, and a server that was idle when the signal arrived +drains instantly, so that race is usually lost. ### Options (for `Serve`) diff --git a/go/framework.go b/go/framework.go index 3b62e29..9fb635d 100644 --- a/go/framework.go +++ b/go/framework.go @@ -69,6 +69,14 @@ type RegisterFunc[C any] func( // code). Returning an error aborts startup. type SetupFunc[C any] func(ctx context.Context, cfg *C) error +// ShutdownFunc is an optional hook run once, after the server has stopped +// accepting requests and drained the ones in flight, but before Main returns +// and the process ends. Use it to release what Register started and the +// platform cannot see: a session with another service, a lease, a node +// registration. It shares the ShutdownTimeout budget with the drain, and ctx +// is bound accordingly. A returned error is logged, not fatal. +type ShutdownFunc[C any] func(ctx context.Context, cfg *C) error + // Plugin declares a plugin for the one-call Main entrypoint. type Plugin[C any] struct { // Name is the plugin name, used in logs and diagnostics. It does not affect @@ -84,6 +92,9 @@ type Plugin[C any] struct { // Setup is an optional pre-serve hook (see SetupFunc). Setup SetupFunc[C] + // Shutdown is an optional post-drain hook (see ShutdownFunc). + Shutdown ShutdownFunc[C] + // Middleware is appended to the default stack (unless disabled below). Middleware []gin.HandlerFunc @@ -151,6 +162,12 @@ func run[C any](ctx context.Context, p *Plugin[C]) error { opts = append(opts, WithoutDefaultMiddleware()) } + if p.Shutdown != nil { + opts = append(opts, WithShutdown(func(ctx context.Context) error { + return p.Shutdown(ctx, &root.Config) + })) + } + s := newSettings(name, p.Version, opts) ctx = configureLogging(ctx, s) @@ -280,6 +297,19 @@ func serve[C any]( return fmt.Errorf("shutting down server: %w", err) } + // The hook runs after the drain, on what is left of the same budget. + // Without it a plugin has no safe place for its own teardown: a goroutine + // watching ctx races the return from here, and a server that was idle + // when the signal arrived drains instantly, so that race is usually lost. + if s.shutdown != nil { + if err := s.shutdown(shutdownCtx); err != nil { + log.G(base). + Warn(). + Err(err). + Msg("running the plugin shutdown hook") + } + } + return nil } diff --git a/go/options.go b/go/options.go index 8974125..73afd60 100644 --- a/go/options.go +++ b/go/options.go @@ -4,6 +4,7 @@ package pluginsdk import ( + "context" "net" "github.com/gin-gonic/gin" @@ -54,6 +55,8 @@ type settings struct { responder func(*gin.Context, int, any) rawConfig []byte + + shutdown func(context.Context) error } // Option customises Serve. Options are applied in order, after any Options @@ -104,6 +107,13 @@ func WithResponder(fn func(*gin.Context, int, any)) Option { return func(s *settings) { s.responder = fn } } +// WithShutdown runs fn once the server has stopped and drained, before Serve +// returns. It is the programmatic form of Plugin.Shutdown; see ShutdownFunc +// for the contract. +func WithShutdown(fn func(context.Context) error) Option { + return func(s *settings) { s.shutdown = fn } +} + // WithRawConfig supplies the raw platform config bytes (as delivered on STDIN) // so they are retrievable through RawConfig. Main sets this for you; custom // command grammars pass it explicitly.