Skip to content
Merged
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
19 changes: 19 additions & 0 deletions gonsole/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ v0.x, minor releases may contain breaking changes.

Releases of this module are tagged `gonsole/vX.Y.Z`.

## [Unreleased]

### Added

- `Timeouts.CancelGrace` and `Timeouts.StopGrace`, read from the `SHUTDOWN_CANCEL_GRACE` and `SHUTDOWN_STOP_GRACE` settings.
- `ErrGraceRanOut`, the cause a request cancelled after the shutdown grace sees, and `ErrStillServing`.

### Changed

- `Serve` cancels the requests still running when the shutdown grace ends and logs a warning with their count.
- `Serve` closes the connections left when the cancel grace ends, with a warning, and returns `ErrStillServing` if a request still runs. A hijacked connection stays open.
- `Serve` wraps the `Handler` and the `BaseContext` of the server it runs, keeping the base's values but owning its cancellation.
- `Serve` refuses a grace, cancel grace or stop grace that is not above zero, before it listens.
- `Serve` no longer returns the deadline error of the server's own shutdown.

### Fixed

- `Serve` calls stop under the stop grace once the requests ended, never with what the shutdown grace left.

## [0.1.0] - 2026-09-25

### Added
Expand Down
1 change: 1 addition & 0 deletions gonsole/internal/exampleapp/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func (demo) Commands() []gonsole.Command {
func serve(ctx context.Context, call gonsole.Call) error {
timeouts, err := call.Env.Timeouts(gonsole.Timeouts{
ReadHeader: 10 * time.Second, Read: 30 * time.Second, Idle: 120 * time.Second, Grace: 10 * time.Second,
CancelGrace: 5 * time.Second, StopGrace: 5 * time.Second,
})
if err != nil {
return err
Expand Down
190 changes: 177 additions & 13 deletions gonsole/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,42 @@ import (
"log/slog"
"net"
"net/http"
"sync"
"time"
)

// Timeouts are the HTTP timeouts and the shutdown grace one server runs under.
// ErrGraceRanOut is the cause context.Cause reports for a request Serve cancels after the shutdown grace.
var ErrGraceRanOut = errors.New("gonsole: the shutdown grace ran out")

// ErrStillServing reports requests still running when the cancel grace ended.
var ErrStillServing = errors.New("gonsole: requests still running after the cancel grace")

// Timeouts are the HTTP timeouts and the shutdown graces one server runs under.
type Timeouts struct {
// ReadHeader bounds reading one request's headers, the HTTP_READ_HEADER_TIMEOUT setting.
ReadHeader time.Duration
// Read bounds reading one whole request, the HTTP_READ_TIMEOUT setting.
Read time.Duration
// Idle bounds how long a kept alive connection waits for its next request, the HTTP_IDLE_TIMEOUT setting.
Idle time.Duration
// Grace bounds the shutdown of the server and the stop of what it serves, the SHUTDOWN_GRACE setting.
// Grace bounds how long open requests get to finish once the run ends, the SHUTDOWN_GRACE setting.
Grace time.Duration
// CancelGrace bounds how long the requests cancelled after the grace get to end, the SHUTDOWN_CANCEL_GRACE setting.
CancelGrace time.Duration
// StopGrace bounds the stop of what the server serves, the SHUTDOWN_STOP_GRACE setting.
StopGrace time.Duration
}

// Timeouts returns the HTTP timeouts and the shutdown grace, each falling back to fallback.
// Timeouts returns the HTTP timeouts and the shutdown graces, each falling back to fallback.
func (e Env) Timeouts(fallback Timeouts) (Timeouts, error) {
var read Timeouts
var failed [4]error
var failed [6]error
read.ReadHeader, failed[0] = e.Duration("HTTP_READ_HEADER_TIMEOUT", fallback.ReadHeader)
read.Read, failed[1] = e.Duration("HTTP_READ_TIMEOUT", fallback.Read)
read.Idle, failed[2] = e.Duration("HTTP_IDLE_TIMEOUT", fallback.Idle)
read.Grace, failed[3] = e.Duration("SHUTDOWN_GRACE", fallback.Grace)
read.CancelGrace, failed[4] = e.Duration("SHUTDOWN_CANCEL_GRACE", fallback.CancelGrace)
read.StopGrace, failed[5] = e.Duration("SHUTDOWN_STOP_GRACE", fallback.StopGrace)
if err := errors.Join(failed[:]...); err != nil {
return Timeouts{}, err
}
Expand All @@ -46,20 +59,36 @@ func NewServer(addr string, handler http.Handler, t Timeouts) *http.Server {
}
}

// Serve serves srv until ctx ends or serving fails, then shuts it down and calls stop within the grace.
// Serve serves srv until ctx ends or serving fails, then drains it, cancelling what outlasts the grace, and calls stop.
func Serve(
ctx context.Context, srv *http.Server, t Timeouts, stop func(context.Context) error, logger *slog.Logger,
) error {
if err := refuseGraces(t); err != nil {
return err
}
listener, err := net.Listen("tcp", cmp.Or(srv.Addr, ":http"))
if err != nil {
grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.Grace)
defer cancel()
return errors.Join(fmt.Errorf("http server: %w", err), optional(grace, stop))
return errors.Join(fmt.Errorf("http server: %w", err), stopWithin(ctx, t, stop))
}
return serveOn(ctx, srv, listener, t, stop, logger)
}

// serveOn serves srv on listener until ctx ends or serving fails, then shuts it down and calls stop within the grace.
// refuseGraces returns an error naming each grace of t that does not stand above zero.
func refuseGraces(t Timeouts) error {
graces := []struct {
name string
grace time.Duration
}{{"Grace", t.Grace}, {"CancelGrace", t.CancelGrace}, {"StopGrace", t.StopGrace}}
var refused []error
for _, g := range graces {
if g.grace <= 0 {
refused = append(refused, fmt.Errorf("gonsole: Timeouts.%s must stand above zero, got %v", g.name, g.grace))
}
}
return errors.Join(refused...)
}

// serveOn serves srv on listener until ctx ends or serving fails, then drains it and calls stop within the stop grace.
func serveOn(
ctx context.Context, srv *http.Server, listener net.Listener, t Timeouts, stop func(context.Context) error,
logger *slog.Logger,
Expand All @@ -68,6 +97,7 @@ func serveOn(
if srv.ErrorLog == nil {
srv.ErrorLog = slog.NewLogLogger(logger.Handler(), slog.LevelError)
}
requests := track(ctx, srv)
served := make(chan error, 1)
go func() { served <- srv.Serve(listener) }()
logger.Info("listening", "addr", listener.Addr().String())
Expand All @@ -78,13 +108,147 @@ func serveOn(
case <-ctx.Done():
logger.Info("shutting down")
}
grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.Grace)
defer cancel()
shut := srv.Shutdown(grace)
still := drain(ctx, srv, requests, t, logger)
if failed == nil {
<-served
}
return errors.Join(failed, shut, optional(grace, stop))
return errors.Join(failed, still, stopWithin(ctx, t, stop))
}

// track points the requests of srv at a base only Serve cancels, keeping its own base's values, and counts them.
func track(ctx context.Context, srv *http.Server) *inflight {
base, cancel := context.WithCancelCause(context.WithoutCancel(ctx))
requests := &inflight{cancel: cancel}
own := srv.BaseContext
srv.BaseContext = func(listener net.Listener) context.Context {
if own == nil {
return base
}
return within(own(listener), base)
}
srv.Handler = requests.wrap(cmp.Or[http.Handler](srv.Handler, http.DefaultServeMux))
return requests
}

// within returns a context holding the values of held that ends when base ends, with the same cause.
func within(held, base context.Context) context.Context {
joined, cancel := context.WithCancelCause(context.WithoutCancel(held))
context.AfterFunc(base, func() { cancel(context.Cause(base)) })
return joined
}

// drain shuts srv down within the grace, cancels what still runs, and closes what outlives the cancel grace.
func drain(ctx context.Context, srv *http.Server, requests *inflight, t Timeouts, logger *slog.Logger) error {
shutting, stopShutting := context.WithCancel(context.WithoutCancel(ctx))
shut := make(chan struct{})
go func() {
_ = srv.Shutdown(shutting)
close(shut)
}()
grace, endGrace := context.WithTimeout(context.WithoutCancel(ctx), t.Grace)
defer endGrace()
await(grace, shut)
if running := requests.settle(grace); running > 0 {
logger.Warn("cancelling the requests still running after the shutdown grace", "count", running)
}
requests.cancel(ErrGraceRanOut)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
cancelGrace, endCancelGrace := context.WithTimeout(context.WithoutCancel(ctx), t.CancelGrace)
defer endCancelGrace()
await(cancelGrace, shut)
running := requests.settle(cancelGrace)
if !finished(shut) {
logger.Warn("closing the connections still open after the cancel grace")
}
stopShutting()
<-shut
_ = srv.Close()
if running > 0 {
return ErrStillServing
Comment thread
SirLouen marked this conversation as resolved.
}
return nil
}

// finished reports whether shut is closed.
func finished(shut <-chan struct{}) bool {
select {
case <-shut:
return true
default:
return false
}
}

// await waits until shut closes or ctx ends.
func await(ctx context.Context, shut <-chan struct{}) {
select {
case <-shut:
case <-ctx.Done():
}
}

// inflight counts the requests one server is handling and cancels them together.
type inflight struct {
mu sync.Mutex
running int
idle chan struct{}
cancel context.CancelCauseFunc
}

// wrap returns next, counting each request while it runs.
func (in *inflight) wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
in.enter()
defer in.leave()
next.ServeHTTP(w, r)
})
}

// enter counts one more running request.
func (in *inflight) enter() {
in.mu.Lock()
defer in.mu.Unlock()
if in.running == 0 {
in.idle = make(chan struct{})
}
in.running++
}

// leave counts one running request fewer.
func (in *inflight) leave() {
in.mu.Lock()
defer in.mu.Unlock()
in.running--
if in.running == 0 {
close(in.idle)
}
}

// state returns how many requests run and a channel closed once none does.
func (in *inflight) state() (int, <-chan struct{}) {
in.mu.Lock()
defer in.mu.Unlock()
return in.running, in.idle
}

// settle waits until the requests running now end or ctx ends, then returns how many requests run.
func (in *inflight) settle(ctx context.Context) int {
running, idle := in.state()
if running == 0 {
return 0
}
select {
case <-idle:
case <-ctx.Done():
}
running, _ = in.state()
return running
}

// stopWithin calls stop, when set, under a context the stop grace bounds and the end of ctx cannot cancel.
func stopWithin(ctx context.Context, t Timeouts, stop func(context.Context) error) error {
bounded, cancel := context.WithTimeout(context.WithoutCancel(ctx), t.StopGrace)
defer cancel()
return optional(bounded, stop)
}

// optional calls fn under ctx, nothing when fn is nil.
Expand Down
Loading
Loading