mirror of
https://github.com/gin-gonic/gin.git
synced 2026-06-06 20:18:19 +08:00
Merge c678dc6d8753024a73083f93d0f51d51e1d8ed3d into d75fcd4c9ab260e5225de590f1f0f8c0e0e12d11
This commit is contained in:
commit
b6f84f0d71
141
hot_reload.go
Normal file
141
hot_reload.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hotReloadListenerEnv = "GIN_LISTENER_FD"
|
||||||
|
|
||||||
|
// RunWithHotReload runs the engine and enables zero-downtime hot reload via
|
||||||
|
// SIGHUP. On SIGHUP, a child process inherits the listening socket and begins
|
||||||
|
// serving immediately while the parent drains in-flight requests (up to 30s)
|
||||||
|
// and exits. The child handles subsequent SIGHUPs the same way.
|
||||||
|
//
|
||||||
|
// Send SIGINT or SIGTERM for a clean shutdown without spawning a replacement.
|
||||||
|
//
|
||||||
|
// Note: hot reload re-executes the same binary. Rebuilding must be handled
|
||||||
|
// externally (e.g. with make or a file watcher) before sending SIGHUP.
|
||||||
|
func (engine *Engine) RunWithHotReload(addr ...string) (err error) {
|
||||||
|
defer func() { debugPrintError(err) }()
|
||||||
|
|
||||||
|
if engine.isUnsafeTrustedProxies() {
|
||||||
|
debugPrint("[WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.\n" +
|
||||||
|
"Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.")
|
||||||
|
}
|
||||||
|
engine.updateRouteTrees()
|
||||||
|
|
||||||
|
if fdStr := os.Getenv(hotReloadListenerEnv); fdStr != "" {
|
||||||
|
fd, parseErr := strconv.Atoi(fdStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return fmt.Errorf("gin: invalid %s=%q: %w", hotReloadListenerEnv, fdStr, parseErr)
|
||||||
|
}
|
||||||
|
return engine.runInherited(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
address := resolveAddress(addr)
|
||||||
|
ln, err := net.Listen("tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
debugPrint("Listening and serving HTTP on %s (hot reload enabled — send SIGHUP to reload)\n", address)
|
||||||
|
return engine.serveWithSignals(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInherited is the child-process entry point: it reconstructs the listener
|
||||||
|
// from an fd inherited via ExtraFiles and hands off to serveWithSignals.
|
||||||
|
func (engine *Engine) runInherited(fd int) error {
|
||||||
|
f := os.NewFile(uintptr(fd), "gin-listener")
|
||||||
|
ln, err := net.FileListener(f)
|
||||||
|
f.Close() // net.FileListener dups the fd; our copy is no longer needed
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gin: could not create listener from fd %d: %w", fd, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
debugPrint("Listening and serving HTTP on inherited socket (hot reload enabled — send SIGHUP to reload)\n")
|
||||||
|
return engine.serveWithSignals(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWithSignals starts the HTTP server on ln and blocks until a signal
|
||||||
|
// arrives. SIGHUP forks a child then drains and exits; SIGINT/SIGTERM drain
|
||||||
|
// and exit without spawning a replacement.
|
||||||
|
func (engine *Engine) serveWithSignals(ln net.Listener) error {
|
||||||
|
srv := &http.Server{Handler: engine.Handler()}
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer signal.Stop(sigCh)
|
||||||
|
|
||||||
|
go srv.Serve(ln) //nolint:errcheck
|
||||||
|
|
||||||
|
for sig := range sigCh {
|
||||||
|
switch sig {
|
||||||
|
case syscall.SIGHUP:
|
||||||
|
debugPrint("received SIGHUP — forking child for zero-downtime reload\n")
|
||||||
|
if err := spawnChild(ln); err != nil {
|
||||||
|
debugPrint("hot reload fork failed: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Give the child a moment to call Accept before we stop.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
err := srv.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
case syscall.SIGINT, syscall.SIGTERM:
|
||||||
|
debugPrint("received %v — shutting down gracefully\n", sig)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
err := srv.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnChild forks a new instance of the current binary, passing the listening
|
||||||
|
// socket as fd 3 via ExtraFiles and advertising it through GIN_LISTENER_FD.
|
||||||
|
func spawnChild(ln net.Listener) error {
|
||||||
|
tcpLn, ok := ln.(*net.TCPListener)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("gin: hot reload requires a TCP listener, got %T", ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := tcpLn.File()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gin: could not duplicate listener fd: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gin: could not resolve executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, os.Args[1:]...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Env = append(os.Environ(), fmt.Sprintf("%s=3", hotReloadListenerEnv))
|
||||||
|
cmd.ExtraFiles = []*os.File{f} // ExtraFiles[0] becomes fd 3 in the child
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("gin: failed to start child process: %w", err)
|
||||||
|
}
|
||||||
|
go cmd.Wait() //nolint:errcheck — best-effort zombie reap before parent exits
|
||||||
|
return nil
|
||||||
|
}
|
||||||
111
hot_reload_test.go
Normal file
111
hot_reload_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunWithHotReload_InvalidFD(t *testing.T) {
|
||||||
|
t.Setenv(hotReloadListenerEnv, "not-a-number")
|
||||||
|
err := New().RunWithHotReload()
|
||||||
|
assert.ErrorContains(t, err, "invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWithHotReload_GracefulShutdown starts the engine via RunWithHotReload
|
||||||
|
// and verifies it shuts down cleanly on SIGTERM. signal.Notify inside
|
||||||
|
// serveWithSignals captures SIGTERM before the default handler fires, so the
|
||||||
|
// test process is not terminated.
|
||||||
|
func TestRunWithHotReload_GracefulShutdown(t *testing.T) {
|
||||||
|
// Reserve a free port then release it; there is a small TOCTOU window.
|
||||||
|
ln0, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
addr := ln0.Addr().String()
|
||||||
|
ln0.Close()
|
||||||
|
|
||||||
|
engine := New()
|
||||||
|
engine.GET("/ping", func(c *Context) { c.String(200, "pong") })
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- engine.RunWithHotReload(addr) }()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}, 5*time.Second, 10*time.Millisecond, "server never became reachable")
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(os.Getpid())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, proc.Signal(syscall.SIGTERM))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("server did not shut down within 10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWithHotReload_InheritedListener exercises the child-process path by
|
||||||
|
// pre-opening a TCP socket, duplicating its fd, and advertising it via the
|
||||||
|
// environment variable that runInherited reads.
|
||||||
|
func TestRunWithHotReload_InheritedListener(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer ln.Close()
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
|
||||||
|
// tcpLn.File() dups the underlying fd; we then dup again so that
|
||||||
|
// runInherited's f.Close() doesn't affect our reference.
|
||||||
|
tcpLn := ln.(*net.TCPListener)
|
||||||
|
f, err := tcpLn.File()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
dupFD, err := syscall.Dup(int(f.Fd()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// dupFD is now owned by RunWithHotReload; do not close it here.
|
||||||
|
|
||||||
|
t.Setenv(hotReloadListenerEnv, fmt.Sprintf("%d", dupFD))
|
||||||
|
|
||||||
|
engine := New()
|
||||||
|
engine.GET("/ping", func(c *Context) { c.String(200, "pong") })
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- engine.RunWithHotReload() }()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}, 5*time.Second, 10*time.Millisecond, "inherited server never became reachable")
|
||||||
|
|
||||||
|
proc, _ := os.FindProcess(os.Getpid())
|
||||||
|
proc.Signal(syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("inherited server did not shut down within 10s")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
hot_reload_windows.go
Normal file
16
hot_reload_windows.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gin
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// RunWithHotReload is not supported on Windows because SIGHUP and fd
|
||||||
|
// inheritance via ExtraFiles are Unix-only primitives. Use an external
|
||||||
|
// hot-reload tool such as Air (https://github.com/air-verse/air) instead.
|
||||||
|
func (engine *Engine) RunWithHotReload(addr ...string) error {
|
||||||
|
return errors.New("gin: RunWithHotReload is not supported on Windows")
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user