Files
kandev/apps/backend/internal/backendapp/startup.go
T
2026-09-12 16:29:38 +01:00

222 lines
6.2 KiB
Go

package backendapp
import (
"context"
"net/http"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/startup"
"go.uber.org/zap"
)
type bootstrapContextKey struct{}
type bootstrapRuntime struct {
handler *handlerSwitch
server *http.Server
listeners *serverListeners
ready atomic.Bool
readinessMu sync.Mutex
readinessPublishing bool
startupClosing bool
}
type startupSignalContextKey struct{}
type processContextKey struct{}
type workerCancelContextKey struct{}
type startupSignalController struct {
first chan os.Signal
stop chan struct{}
done chan struct{}
signals chan os.Signal
once sync.Once
}
func newStartupSignalController(ctx context.Context, cancel context.CancelFunc, log *logger.Logger) *startupSignalController {
controller := &startupSignalController{
first: make(chan os.Signal, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
signals: make(chan os.Signal, 2),
}
signal.Notify(controller.signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
if log != nil {
log.Debug("shutdown signal handler armed",
zap.Int("pid", os.Getpid()),
zap.Int("ppid", os.Getppid()))
}
go func() {
defer close(controller.done)
select {
case sig := <-controller.signals:
controller.first <- sig
cancel()
select {
case second := <-controller.signals:
if log != nil {
log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String()))
_ = log.Close()
}
os.Exit(1)
case <-controller.stop:
}
case <-ctx.Done():
case <-controller.stop:
}
}()
return controller
}
func withStartupSignalController(ctx context.Context, controller *startupSignalController) context.Context {
return context.WithValue(ctx, startupSignalContextKey{}, controller)
}
func withProcessContext(ctx, process context.Context) context.Context {
return context.WithValue(ctx, processContextKey{}, process)
}
func processContextFromContext(ctx context.Context) context.Context {
process, _ := ctx.Value(processContextKey{}).(context.Context)
return process
}
// processRuntimeContext returns the process lifetime carried by startup
// workers. Restore cancels workers while the process remains alive to finish
// the restore and deliver its terminal job update.
func processRuntimeContext(ctx context.Context) context.Context {
if process := processContextFromContext(ctx); process != nil {
return process
}
return ctx
}
// beginReadinessPublication serializes the readiness handoff with the startup
// cancellation watcher. Once startup cancellation claims listener closure,
// initialization cannot publish a router into a closing server.
func (runtime *bootstrapRuntime) beginReadinessPublication(ctx context.Context) bool {
runtime.readinessMu.Lock()
defer runtime.readinessMu.Unlock()
if runtime.startupClosing || ctx.Err() != nil {
return false
}
runtime.readinessPublishing = true
return true
}
func (runtime *bootstrapRuntime) claimStartupClose() bool {
runtime.readinessMu.Lock()
defer runtime.readinessMu.Unlock()
if runtime.ready.Load() || runtime.readinessPublishing {
return false
}
runtime.startupClosing = true
return true
}
func withWorkerCancel(ctx context.Context, cancel context.CancelFunc) context.Context {
return context.WithValue(ctx, workerCancelContextKey{}, cancel)
}
func workerCancelFromContext(ctx context.Context) context.CancelFunc {
cancel, _ := ctx.Value(workerCancelContextKey{}).(context.CancelFunc)
return cancel
}
func startupSignalControllerFromContext(ctx context.Context) *startupSignalController {
controller, _ := ctx.Value(startupSignalContextKey{}).(*startupSignalController)
return controller
}
func (controller *startupSignalController) wait(ctx context.Context) os.Signal {
select {
case sig := <-controller.first:
return sig
default:
}
select {
case sig := <-controller.first:
return sig
case <-ctx.Done():
select {
case sig := <-controller.first:
return sig
default:
return nil
}
}
}
func (controller *startupSignalController) stopAndWait() {
if controller == nil {
return
}
controller.once.Do(func() {
signal.Stop(controller.signals)
close(controller.stop)
<-controller.done
})
}
// runWithBootstrap owns the listener for both initialization and application use.
// Constructors run synchronously so resource cleanup cannot race initialization.
// The process context remains live while worker contexts are quiesced, which
// lets restore finish and deliver its job result before explicit shutdown.
func runWithBootstrap(ctx context.Context, cfg *config.Config, log *logger.Logger, initialize func(context.Context) bool) bool {
startupCtx, cancel := context.WithCancel(ctx)
defer cancel()
controller := newStartupSignalController(startupCtx, cancel, log)
defer controller.stopAndWait()
if err := startupCtx.Err(); err != nil {
return false
}
progress := startup.New(log)
startupCtx = startup.WithReporter(startupCtx, progress)
startupCtx = withStartupSignalController(startupCtx, controller)
workerCtx, cancelWorkers := context.WithCancel(startupCtx)
defer cancelWorkers()
workerCtx = withProcessContext(workerCtx, startupCtx)
workerCtx = withWorkerCancel(workerCtx, cancelWorkers)
ready.Store(false)
handler, server, listeners, err := bindBootstrapListeners(cfg, log, Version, progress)
if err != nil {
return false
}
defer closeBoundListeners(server, listeners, log)
runtime := &bootstrapRuntime{handler: handler, server: server, listeners: listeners}
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
progress.Report()
for {
select {
case <-startupCtx.Done():
if runtime.claimStartupClose() {
closeBoundListeners(server, listeners, log)
}
return
case <-done:
return
case <-ticker.C:
progress.Report()
}
}
}()
defer func() { close(done); <-stopped }()
ok := initialize(context.WithValue(workerCtx, bootstrapContextKey{}, runtime))
return ok && (startupCtx.Err() == nil || runtime.ready.Load())
}