From 70882914d44567011e05bc16255b11ea875e43dc Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 14 Jan 2024 06:58:17 +0200 Subject: [PATCH] new NonBlocking option and Wait method on Application. See HISTORY.md for more --- HISTORY.md | 22 +++++++ configuration.go | 21 +++++++ context/configuration.go | 2 + core/netutil/addr.go | 2 +- iris.go | 131 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 88b36f56..4b27f317 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -23,6 +23,28 @@ Developers are not forced to upgrade if they don't really need it. Upgrade whene Changes apply to `main` branch. +- New `iris.NonBlocking()` configuration option to run the server without blocking the main routine, `Application.Wait(context.Context) error` method can be used to block and wait for the server to be up and running. Example: + +```go +func main() { + app := iris.New() + app.Get("/", func(ctx iris.Context) { + ctx.Writef("Hello, %s!", "World") + }) + + app.Listen(":8080", iris.NonBlocking()) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := app.Wait(ctx); err != nil { + log.Fatal(err) + } + + // [Server is up and running now, you may continue with other functions below]. +} +``` + - Add `x/mathx.RoundToInteger` math helper function. # Wed, 10 Jan 2024 | v12.2.9 diff --git a/configuration.go b/configuration.go index 1088f316..8f325640 100644 --- a/configuration.go +++ b/configuration.go @@ -216,6 +216,13 @@ func WithTimeout(timeoutDur time.Duration, htmlBody ...string) Configurator { } } +// NonBlocking sets the `Configuration.NonBlocking` field to true. +func NonBlocking() Configurator { + return func(app *Application) { + app.config.NonBlocking = true + } +} + // WithoutServerError will cause to ignore the matched "errors" // from the main application's `Run/Listen` function. // @@ -677,6 +684,10 @@ type Configuration struct { // TimeoutMessage specifies the HTML body when a handler hits its life time based // on the Timeout configuration field. TimeoutMessage string `ini:"timeout_message" json:"timeoutMessage" yaml:"TimeoutMessage" toml:"TimeoutMessage"` + // NonBlocking, if set to true then the server will start listening for incoming connections + // without blocking the main goroutine. Use the Application.Wait method to block and wait for the server to be up and running. + NonBlocking bool `ini:"non_blocking" json:"nonBlocking" yaml:"NonBlocking" toml:"NonBlocking"` + // Tunneling can be optionally set to enable ngrok http(s) tunneling for this Iris app instance. // See the `WithTunneling` Configurator too. Tunneling TunnelingConfiguration `ini:"tunneling" json:"tunneling,omitempty" yaml:"Tunneling" toml:"Tunneling"` @@ -994,6 +1005,11 @@ func (c *Configuration) GetTimeout() time.Duration { return c.Timeout } +// GetNonBlocking returns the NonBlocking field. +func (c *Configuration) GetNonBlocking() bool { + return c.NonBlocking +} + // GetTimeoutMessage returns the TimeoutMessage field. func (c *Configuration) GetTimeoutMessage() string { return c.TimeoutMessage @@ -1201,6 +1217,10 @@ func WithConfiguration(c Configuration) Configurator { main.TimeoutMessage = v } + if v := c.NonBlocking; v { + main.NonBlocking = v + } + if len(c.Tunneling.Tunnels) > 0 { main.Tunneling = c.Tunneling } @@ -1375,6 +1395,7 @@ func DefaultConfiguration() Configuration { KeepAlive: 0, Timeout: 0, TimeoutMessage: DefaultTimeoutMessage, + NonBlocking: false, DisableStartupLog: false, DisableInterruptHandler: false, DisablePathCorrection: false, diff --git a/context/configuration.go b/context/configuration.go index 0e7554d8..c754577d 100644 --- a/context/configuration.go +++ b/context/configuration.go @@ -24,6 +24,8 @@ type ConfigurationReadOnly interface { GetTimeout() time.Duration // GetTimeoutMessage returns the TimeoutMessage field. GetTimeoutMessage() string + // GetNonBlocking returns the NonBlocking field. + GetNonBlocking() bool // GetDisablePathCorrection returns the DisablePathCorrection field GetDisablePathCorrection() bool // GetDisablePathCorrectionRedirection returns the DisablePathCorrectionRedirection field. diff --git a/core/netutil/addr.go b/core/netutil/addr.go index 9731b849..df23070b 100644 --- a/core/netutil/addr.go +++ b/core/netutil/addr.go @@ -184,7 +184,7 @@ func ResolveVHost(addr string) string { } if idx := strings.IndexByte(addr, ':'); idx == 0 { - // only port, then return the 0.0.0.0 + // only port, then return the 0.0.0.0:PORT return /* "0.0.0.0" */ "localhost" + addr[idx:] } else if idx > 0 { // if 0.0.0.0:80 let's just convert it to localhost. if addr[0:idx] == "0.0.0.0" { diff --git a/iris.go b/iris.go index b434b088..cd2d2af6 100644 --- a/iris.go +++ b/iris.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log" + "math" "net" "net/http" "os" @@ -109,6 +110,8 @@ type Application struct { // Hosts field is available after `Run` or `NewHost`. Hosts []*host.Supervisor hostConfigurators []host.Configurator + runError error + runErrorMu sync.RWMutex } // New creates and returns a fresh empty iris *Application instance. @@ -627,6 +630,10 @@ func (app *Application) Shutdown(ctx stdContext.Context) error { app.mu.Lock() defer app.mu.Unlock() + defer func() { + app.setRunError(ErrServerClosed) // make sure to set the error so any .Wait calls return. + }() + for i, su := range app.Hosts { app.logger.Debugf("Host[%d]: Shutdown now", i) if err := su.Shutdown(ctx); err != nil { @@ -1006,7 +1013,8 @@ var ( // on the TCP network address "host:port" which // handles requests on incoming connections. // -// Listen always returns a non-nil error. +// Listen always returns a non-nil error except +// when NonBlocking option is being passed, so the error goes to the Wait method. // Ignore specific errors by using an `iris.WithoutServerError(iris.ErrServerClosed)` // as a second input argument. // @@ -1048,15 +1056,132 @@ func (app *Application) Run(serve Runner, withOrWithout ...Configurator) error { app.logger.Debugf("Application: running using %d host(s)", len(app.Hosts)+1 /* +1 the current */) } - // this will block until an error(unless supervisor's DeferFlow called from a Task). + if app.config.NonBlocking { + go func() { + err := app.serve(serve) + if err != nil { + app.setRunError(err) + } + }() + + return nil + } + + // this will block until an error(unless supervisor's DeferFlow called from a Task) + // or NonBlocking was passed (see above). + return app.serve(serve) +} + +func (app *Application) serve(serve Runner) error { err := serve(app) if err != nil { app.logger.Error(err) } - return err } +func (app *Application) setRunError(err error) { + app.runErrorMu.Lock() + app.runError = err + app.runErrorMu.Unlock() +} + +func (app *Application) getRunError() error { + app.runErrorMu.RLock() + err := app.runError + app.runErrorMu.RUnlock() + return err +} + +// Wait blocks the main goroutine until the server application is up and running. +// Useful only when `Run` is called with `iris.NonBlocking()` option. +func (app *Application) Wait(ctx stdContext.Context) error { + if !app.config.NonBlocking { + return nil + } + + // First check if there is an error already from the app.Run. + if err := app.getRunError(); err != nil { + return err + } + + // Set the base for exponential backoff. + base := 2.0 + + // Get the maximum number of retries by context or force to 7 retries. + var maxRetries int + // Get the deadline of the context. + if deadline, ok := ctx.Deadline(); ok { + now := time.Now() + timeout := deadline.Sub(now) + + maxRetries = getMaxRetries(timeout, base) + } else { + maxRetries = 7 // 256 seconds max. + } + + // Set the initial retry interval. + retryInterval := time.Second + + return app.tryConnect(ctx, maxRetries, retryInterval, base) +} + +// getMaxRetries calculates the maximum number of retries from the retry interval and the base. +func getMaxRetries(retryInterval time.Duration, base float64) int { + // Convert the retry interval to seconds. + seconds := retryInterval.Seconds() + // Apply the inverse formula. + retries := math.Log(seconds)/math.Log(base) - 1 + return int(math.Round(retries)) +} + +// tryConnect tries to connect to the server with the given context and retry parameters. +func (app *Application) tryConnect(ctx stdContext.Context, maxRetries int, retryInterval time.Duration, base float64) error { + address := app.config.GetVHost() // Get this server's listening address. + + // Try to connect to the server in a loop. + for i := 0; i < maxRetries; i++ { + // Check the context before each attempt. + select { + case <-ctx.Done(): + // Context is canceled, return the context error. + return ctx.Err() + default: + // Context is not canceled, proceed with the attempt. + conn, err := net.Dial("tcp", address) + if err == nil { + // Connection successful, close the connection and return nil. + conn.Close() + return nil // exit. + } // ignore error. + + // Connection failed, wait for the retry interval and try again. + time.Sleep(retryInterval) + // After each failed attempt, check the server Run's error again. + if err := app.getRunError(); err != nil { + return err + } + + // Increase the retry interval by the base raised to the power of the number of attempts. + /* + 0 2 seconds + 1 4 seconds + 2 8 seconds + 3 ~16 seconds + 4 ~32 seconds + 5 ~64 seconds + 6 ~128 seconds + 7 ~256 seconds + 8 ~512 seconds + ... + */ + retryInterval = time.Duration(math.Pow(base, float64(i+1))) * time.Second + } + } + // All attempts failed, return an error. + return fmt.Errorf("failed to connect to the server after %d retries", maxRetries) +} + // https://ngrok.com/docs func (app *Application) tryStartTunneling() { if len(app.config.Tunneling.Tunnels) == 0 {