github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/appserver.go (about)

     1  // Copyright 2021 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package test
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"log"
    11  	"net"
    12  	"os"
    13  	"os/exec"
    14  	"path/filepath"
    15  	"strconv"
    16  	"time"
    17  
    18  	"github.com/derat/nup/server/config"
    19  
    20  	"golang.org/x/sys/unix"
    21  )
    22  
    23  // When tethering over a cellular connection, I saw dev_appserver.py block for 4+ minutes at startup
    24  // after logging "devappserver2.py:316] Skipping SDK update check." lsof suggested that it was
    25  // blocked on an HTTP connection in SYN_SENT to some random IPv6 address owned by Akamai (?).
    26  // When I increased this timeout to 10 minutes, it eventually proceeded (without logging anything).
    27  //
    28  // Not having any network connection apparently also adds a delay, but 40 seconds seems to be enough
    29  // to compensate for it (in practice, startup seems to take around 20-30 seconds when offline).
    30  const appserverTimeout = 40 * time.Second
    31  
    32  // DevAppserver wraps a dev_appserver.py process.
    33  type DevAppserver struct {
    34  	appPort         int       // app's port for HTTP requests
    35  	cmd             *exec.Cmd // dev_appserver.py process
    36  	createIndexes   bool      // update index.yaml
    37  	watchForChanges bool      // rebuild app when files changed
    38  }
    39  
    40  // NewDevAppserver starts a dev_appserver.py process using the supplied configuration.
    41  //
    42  // storageDir is used to hold Datastore data and will be created if it doesn't exist.
    43  // dev_appserver.py's noisy output will be sent to out (which may be nil).
    44  // Close must be called later to kill the process.
    45  func NewDevAppserver(cfg *config.Config, storageDir string, out io.Writer,
    46  	opts ...DevAppserverOption) (*DevAppserver, error) {
    47  	libDir, err := CallerDir()
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  	cfgData, err := json.Marshal(cfg)
    52  	if err != nil {
    53  		return nil, err
    54  	}
    55  	if err := os.MkdirAll(storageDir, 0755); err != nil {
    56  		return nil, err
    57  	}
    58  
    59  	var srv DevAppserver
    60  	for _, o := range opts {
    61  		o(&srv)
    62  	}
    63  
    64  	// Prevent multiple instances from trying to bind to the same ports.
    65  	ports, err := FindUnusedPorts(2)
    66  	if err != nil {
    67  		return nil, err
    68  	}
    69  	if srv.appPort <= 0 {
    70  		srv.appPort = ports[0]
    71  	}
    72  	adminPort := ports[1]
    73  
    74  	args := []string{
    75  		"--application=nup-test",
    76  		"--port=" + strconv.Itoa(srv.appPort),
    77  		"--admin_port=" + strconv.Itoa(adminPort),
    78  		"--storage_path=" + storageDir,
    79  		"--env_var", "NUP_CONFIG=" + string(cfgData),
    80  		"--datastore_consistency_policy=consistent",
    81  		// TODO: This is a hack to work around forceUpdateFailures in server/main.go.
    82  		"--max_module_instances=1",
    83  	}
    84  	if !srv.createIndexes {
    85  		args = append(args, "--require_indexes=yes")
    86  	}
    87  	if !srv.watchForChanges {
    88  		args = append(args, "--watcher_ignore_re=.*")
    89  	}
    90  	args = append(args, ".")
    91  
    92  	cmd := exec.Command("dev_appserver.py", args...)
    93  	cmd.Dir = filepath.Join(libDir, "..") // directory containing app.yaml
    94  	cmd.Stdout = out
    95  	cmd.Stderr = out
    96  	if err := cmd.Start(); err != nil {
    97  		return nil, err
    98  	}
    99  
   100  	srv.cmd = cmd
   101  
   102  	// Wait for the server to accept connections.
   103  	start := time.Now()
   104  	for {
   105  		if conn, err := net.DialTimeout("tcp", srv.Addr(), time.Second); err == nil {
   106  			conn.Close()
   107  			break
   108  		} else if time.Now().Sub(start) > appserverTimeout {
   109  			srv.Close()
   110  			return nil, fmt.Errorf("couldn't connect: %v", err)
   111  		}
   112  		time.Sleep(100 * time.Millisecond)
   113  	}
   114  
   115  	// I was seeing occasional hangs in response to the first request:
   116  	//  ..
   117  	//  INFO     2021-12-06 01:48:34,122 instance.py:294] Instance PID: 18017
   118  	//  INFO     2021-12-06 01:48:34,128 instance.py:294] Instance PID: 18024
   119  	//  2021/12/06 01:48:34 http.ListenAndServe: listen tcp 127.0.0.1:20020: bind: address already in use
   120  	//  INFO     2021-12-06 01:48:34,143 module.py:883] default: "POST /config HTTP/1.1" 200 2
   121  	//
   122  	// I think the http.ListenAndServer call comes from the appengine.Main call in the app's main
   123  	// function. Oddly, the port is always already bound by the app itself. My best guess is that
   124  	// there's a race in dev_appserver.py that can be triggered when a request comes in soon after
   125  	// it starts handling requests. It happens infrequently, and I've still seen the error at least
   126  	// once with a 1-second delay. It didn't happen across dozens of runs with a 3-second delay,
   127  	// though.
   128  	time.Sleep(3 * time.Second)
   129  
   130  	return &srv, nil
   131  }
   132  
   133  // Close stops dev_appserver.py and cleans up its resources.
   134  func (srv *DevAppserver) Close() error {
   135  	// I struggled with reliably cleaning up processes on exit. In the normal-exit case, this seems
   136  	// pretty straightforward (at least for processes that we exec ourselves): start each process in
   137  	// its own process group, and at exit, send SIGTERM to the process group, which will kill all of
   138  	// its members.
   139  	//
   140  	// Things are harder when the test process is killed via SIGINT or SIGTERM. I don't think
   141  	// there's an easy way to run normal cleanup code from the signal handler: the main goroutine
   142  	// will be blocked doing initialization or running tests, and trying to explicitly kill each
   143  	// process group from the signal-handling goroutine seems inherently racy.
   144  	//
   145  	// I experimented with using unix.Setsid to put the test process (and all of its children) into
   146  	// a new session and then iterating through each process and checking its SID, but that prevents
   147  	// the test process from receiving SIGINT when Ctrl+C is typed into the terminal. I also think
   148  	// we can't put each child process into its own session, since setsid automatically creates a
   149  	// new process group as well (leaving us with no easy way to kill all processes).
   150  	//
   151  	// What I've settled on here is letting dev_appserver.py and its child processes inherit our
   152  	// process group (which appears to be rooted at the main test process), and then just sending
   153  	// SIGINT to the root dev_appserver.py process here, which appears to make it exit cleanly. The
   154  	// signal handler (see HandleSignals) sends SIGTERM to the process group, which also seems to
   155  	// make dev_appserver.py exit (possibly less-cleanly).
   156  	//
   157  	// It's also possible to do something like this earlier to make the root dev_appserver.py
   158  	// process receive SIGINT when the test process dies:
   159  	//
   160  	//  cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGINT}
   161  	//
   162  	// PR_SET_PDEATHSIG is Linux-specific and won't help with processes that are started by other
   163  	// packages, though, and it doesn't seem to be necessary here.
   164  	const sig = unix.SIGINT
   165  	if err := unix.Kill(srv.cmd.Process.Pid, sig); err != nil {
   166  		log.Printf("Failed sending %v to %v: %v", sig, srv.cmd.Process.Pid, err)
   167  	}
   168  	return srv.cmd.Wait()
   169  }
   170  
   171  // URL returns the app's slash-terminated URL.
   172  func (srv *DevAppserver) URL() string {
   173  	return fmt.Sprintf("http://%v/", srv.Addr())
   174  }
   175  
   176  // Addr returns the address of app's HTTP server, e.g. "localhost:8080".
   177  func (srv *DevAppserver) Addr() string {
   178  	return net.JoinHostPort("localhost", strconv.Itoa(srv.appPort))
   179  }
   180  
   181  // DevAppserverOption can be passed to NewDevAppserver to configure dev_appserver.py.
   182  type DevAppserverOption func(*DevAppserver)
   183  
   184  // DevAppserverPort sets the port that the app will listen on for HTTP requests.
   185  // If this option is not supplied, an arbitrary open port will be used.
   186  func DevAppserverPort(port int) DevAppserverOption {
   187  	return func(srv *DevAppserver) { srv.appPort = port }
   188  }
   189  
   190  // DevAppserverCreateIndexes specifies whether dev_appserver.py should automatically
   191  // create datastore indexes in index.yaml. By default, queries fail if they can not
   192  // be satisfied using the existing indexes.
   193  func DevAppserverCreateIndexes(create bool) DevAppserverOption {
   194  	return func(srv *DevAppserver) { srv.createIndexes = create }
   195  }
   196  
   197  // DevAppserverWatchForChanges specifies whether dev_appserver.py should watch for
   198  // changes to the app and rebuild it automatically. Defaults to false.
   199  func DevAppserverWatchForChanges(watch bool) DevAppserverOption {
   200  	return func(srv *DevAppserver) { srv.watchForChanges = watch }
   201  }