Skip to content

Commit

Permalink
commands: Improve server startup to make tests less flaky
Browse files Browse the repository at this point in the history
Do this by announcing/listen on the local address before we start the server.
  • Loading branch information
bep committed Mar 21, 2022
1 parent 0e305d6 commit 9539069
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 55 deletions.
10 changes: 9 additions & 1 deletion commands/commandeer.go
Expand Up @@ -17,6 +17,7 @@ import (
"bytes"
"errors"
"io/ioutil"
"net"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -88,7 +89,8 @@ type commandeer struct {
// Used in cases where we get flooded with events in server mode.
debounce func(f func())

serverPorts []int
serverPorts []serverPortListener

languagesConfigured bool
languages langs.Languages
doLiveReload bool
Expand All @@ -105,6 +107,11 @@ type commandeer struct {
buildErr error
}

type serverPortListener struct {
p int
ln net.Listener
}

func newCommandeerHugoState() *commandeerHugoState {
return &commandeerHugoState{
created: make(chan struct{}),
Expand Down Expand Up @@ -420,6 +427,7 @@ func (c *commandeer) loadConfig() error {
if h == nil || c.failOnInitErr {
err = createErr
}

c.hugoSites = h
// TODO(bep) improve.
if c.buildLock == nil && h != nil {
Expand Down
70 changes: 39 additions & 31 deletions commands/server.go
Expand Up @@ -48,7 +48,7 @@ import (

type serverCmd struct {
// Can be used to stop the server. Useful in tests
stop <-chan bool
stop chan bool

disableLiveReload bool
navigateToChanged bool
Expand All @@ -70,7 +70,7 @@ func (b *commandsBuilder) newServerCmd() *serverCmd {
return b.newServerCmdSignaled(nil)
}

func (b *commandsBuilder) newServerCmdSignaled(stop <-chan bool) *serverCmd {
func (b *commandsBuilder) newServerCmdSignaled(stop chan bool) *serverCmd {
cc := &serverCmd{stop: stop}

cc.baseBuilderCmd = b.newBuilderCmd(&cobra.Command{
Expand All @@ -89,7 +89,13 @@ By default hugo will also watch your files for any changes you make and
automatically rebuild the site. It will then live reload any open browser pages
and push the latest content to them. As most Hugo sites are built in a fraction
of a second, you will be able to save and see your changes nearly instantly.`,
RunE: cc.server,
RunE: func(cmd *cobra.Command, args []string) error {
err := cc.server(cmd, args)
if err != nil && cc.stop != nil {
cc.stop <- true
}
return err
},
})

cc.cmd.Flags().IntVarP(&cc.serverPort, "port", "p", 1313, "port on which the server will listen")
Expand Down Expand Up @@ -130,8 +136,6 @@ func (f noDirFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, nil
}

var serverPorts []int

func (sc *serverCmd) server(cmd *cobra.Command, args []string) error {
// If a Destination is provided via flag write to disk
destination, _ := cmd.Flags().GetString("destination")
Expand Down Expand Up @@ -166,61 +170,58 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error {

// We can only do this once.
serverCfgInit.Do(func() {
serverPorts = make([]int, 1)
c.serverPorts = make([]serverPortListener, 1)

if c.languages.IsMultihost() {
if !sc.serverAppend {
rerr = newSystemError("--appendPort=false not supported when in multihost mode")
}
serverPorts = make([]int, len(c.languages))
c.serverPorts = make([]serverPortListener, len(c.languages))
}

currentServerPort := sc.serverPort

for i := 0; i < len(serverPorts); i++ {
for i := 0; i < len(c.serverPorts); i++ {
l, err := net.Listen("tcp", net.JoinHostPort(sc.serverInterface, strconv.Itoa(currentServerPort)))
if err == nil {
l.Close()
serverPorts[i] = currentServerPort
c.serverPorts[i] = serverPortListener{ln: l, p: currentServerPort}
} else {
if i == 0 && sc.cmd.Flags().Changed("port") {
// port set explicitly by user -- he/she probably meant it!
rerr = newSystemErrorF("Server startup failed: %s", err)
return
}
c.logger.Println("port", sc.serverPort, "already in use, attempting to use an available port")
sp, err := helpers.FindAvailablePort()
l, sp, err := helpers.TCPListen()
if err != nil {
rerr = newSystemError("Unable to find alternative port to use:", err)
return
}
serverPorts[i] = sp.Port
c.serverPorts[i] = serverPortListener{ln: l, p: sp.Port}
}

currentServerPort = serverPorts[i] + 1
currentServerPort = c.serverPorts[i].p + 1
}
})

if rerr != nil {
return
}

c.serverPorts = serverPorts

c.Set("port", sc.serverPort)
if sc.liveReloadPort != -1 {
c.Set("liveReloadPort", sc.liveReloadPort)
} else {
c.Set("liveReloadPort", serverPorts[0])
c.Set("liveReloadPort", c.serverPorts[0].p)
}

isMultiHost := c.languages.IsMultihost()
for i, language := range c.languages {
var serverPort int
if isMultiHost {
serverPort = serverPorts[i]
serverPort = c.serverPorts[i].p
} else {
serverPort = serverPorts[0]
serverPort = c.serverPorts[0].p
}

baseURL, err := sc.fixURL(language, sc.baseURL, serverPort)
Expand Down Expand Up @@ -320,10 +321,11 @@ func (f *fileServer) rewriteRequest(r *http.Request, toPath string) *http.Reques
return r2
}

func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, error) {
func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string, string, error) {
baseURL := f.baseURLs[i]
root := f.roots[i]
port := f.c.serverPorts[i]
port := f.c.serverPorts[i].p
listener := f.c.serverPorts[i].ln

publishDir := f.c.Cfg.GetString("publishDir")

Expand Down Expand Up @@ -353,7 +355,7 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, erro
// We're only interested in the path
u, err := url.Parse(baseURL)
if err != nil {
return nil, "", "", errors.Wrap(err, "Invalid baseURL")
return nil, nil, "", "", errors.Wrap(err, "Invalid baseURL")
}

decorate := func(h http.Handler) http.Handler {
Expand Down Expand Up @@ -459,7 +461,7 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, erro

endpoint := net.JoinHostPort(f.s.serverInterface, strconv.Itoa(port))

return mu, u.String(), endpoint, nil
return mu, listener, u.String(), endpoint, nil
}

var logErrorRe = regexp.MustCompile(`(?s)ERROR \d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `)
Expand Down Expand Up @@ -514,8 +516,10 @@ func (c *commandeer) serve(s *serverCmd) error {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
var servers []*http.Server

wg1, ctx := errgroup.WithContext(context.Background())

for i := range baseURLs {
mu, serverURL, endpoint, err := srv.createEndpoint(i)
mu, listener, serverURL, endpoint, err := srv.createEndpoint(i)
srv := &http.Server{
Addr: endpoint,
Handler: mu,
Expand All @@ -532,13 +536,13 @@ func (c *commandeer) serve(s *serverCmd) error {
mu.HandleFunc(u.Path+"/livereload", livereload.Handler)
}
jww.FEEDBACK.Printf("Web Server is available at %s (bind address %s)\n", serverURL, s.serverInterface)
go func() {
err = srv.ListenAndServe()
wg1.Go(func() error {
err = srv.Serve(listener)
if err != nil && err != http.ErrServerClosed {
c.logger.Errorf("Error: %s\n", err.Error())
os.Exit(1)
return err
}
}()
return nil
})
}

jww.FEEDBACK.Println("Press Ctrl+C to stop")
Expand All @@ -556,15 +560,19 @@ func (c *commandeer) serve(s *serverCmd) error {

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
wg, ctx := errgroup.WithContext(ctx)
wg2, ctx := errgroup.WithContext(ctx)
for _, srv := range servers {
srv := srv
wg.Go(func() error {
wg2.Go(func() error {
return srv.Shutdown(ctx)
})
}

return wg.Wait()
err1, err2 := wg1.Wait(), wg2.Wait()
if err1 != nil {
return err1
}
return err2
}

// fixURL massages the baseURL into a form needed for serving
Expand Down
42 changes: 20 additions & 22 deletions commands/server_test.go
Expand Up @@ -34,7 +34,7 @@ import (
func TestServer(t *testing.T) {
c := qt.New(t)

r := runServerTest(c, "")
r := runServerTest(c, true, "")

c.Assert(r.err, qt.IsNil)
c.Assert(r.homeContent, qt.Contains, "List: Hugo Commands")
Expand All @@ -51,7 +51,7 @@ func TestServerPanicOnConfigError(t *testing.T) {
linenos='table'
`

r := runServerTest(c, config)
r := runServerTest(c, false, config)

c.Assert(r.err, qt.IsNotNil)
c.Assert(r.err.Error(), qt.Contains, "cannot parse 'Highlight.LineNos' as bool:")
Expand Down Expand Up @@ -88,7 +88,7 @@ baseURL="https://example.org"
args = strings.Split(test.flag, "=")
}

r := runServerTest(c, config, args...)
r := runServerTest(c, true, config, args...)

test.assert(c, r)

Expand All @@ -104,7 +104,7 @@ type serverTestResult struct {
publicDirnames map[string]bool
}

func runServerTest(c *qt.C, config string, args ...string) (result serverTestResult) {
func runServerTest(c *qt.C, getHome bool, config string, args ...string) (result serverTestResult) {
dir, clean, err := createSimpleTestSite(c, testSiteConfig{configTOML: config})
defer clean()
c.Assert(err, qt.IsNil)
Expand Down Expand Up @@ -135,34 +135,32 @@ func runServerTest(c *qt.C, config string, args ...string) (result serverTestRes
return err
})

select {
// There is no way to know exactly when the server is ready for connections.
// We could improve by something like https://golang.org/pkg/net/http/httptest/#Server
// But for now, let us sleep and pray!
case <-time.After(2 * time.Second):
case <-ctx.Done():
result.err = wg.Wait()
return
if getHome {
// Esp. on slow CI machines, we need to wait a little before the web
// server is ready.
time.Sleep(567 * time.Millisecond)
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/", port))
c.Check(err, qt.IsNil)
if err == nil {
defer resp.Body.Close()
result.homeContent = helpers.ReaderToString(resp.Body)
}
}

resp, err := http.Get(fmt.Sprintf("http://localhost:%d/", port))
c.Assert(err, qt.IsNil)
defer resp.Body.Close()
homeContent := helpers.ReaderToString(resp.Body)

// Stop the server.
stop <- true

result.homeContent = homeContent
select {
case <-stop:
case stop <- true:
}

pubFiles, err := os.ReadDir(filepath.Join(dir, "public"))
c.Assert(err, qt.IsNil)
c.Check(err, qt.IsNil)
result.publicDirnames = make(map[string]bool)
for _, f := range pubFiles {
result.publicDirnames[f.Name()] = true
}

result.err = wg.Wait()

return

}
Expand Down
15 changes: 15 additions & 0 deletions helpers/general.go
Expand Up @@ -62,6 +62,21 @@ func FindAvailablePort() (*net.TCPAddr, error) {
return nil, err
}

// TCPListen starts listening on a valid TCP port.
func TCPListen() (net.Listener, *net.TCPAddr, error) {
l, err := net.Listen("tcp", ":0")
if err != nil {
return nil, nil, err
}
addr := l.Addr()
if a, ok := addr.(*net.TCPAddr); ok {
return l, a, nil
}
l.Close()
return nil, nil, fmt.Errorf("unable to obtain a valid tcp port: %v", addr)

}

// InStringArray checks if a string is an element of a slice of strings
// and returns a boolean value.
func InStringArray(arr []string, el string) bool {
Expand Down
2 changes: 1 addition & 1 deletion magefile.go
Expand Up @@ -169,7 +169,7 @@ func testGoFlags() string {
return ""
}

return "-test.short"
return "-timeout=1m"
}

// Run tests in 32-bit mode
Expand Down

0 comments on commit 9539069

Please sign in to comment.