Repository: istio/fortio (go)

Issue:
unexpected init with empty url when using -H 
$ fortio curl -H Host:httpbin  http://35.193.254.22/headers
17:58:00 E http.go:100> unexpected init with empty url


Pull request:
Multiple improvements/fixes
- add both link to UI and Json data from run results and browse UI And a back to top link

- Fix #172 (error logged when using -H)

- Initial version of #170  &close option for echo server

- Build with both go1.8 and 1.10; put 1.10 binaries in docker images/release

- Add StdClient option for the web UI runs

- Misc cleanups to front page (grpc secure option)

- Add socket use cout (#165)

- don't consider early EOF or other an error (fix 50% errors for some call client closes - like /fortio/fetch/ does)

- Reminder of fix for #168 show grpc result codes in UI/text

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 7dcc747..14fdc5c 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,7 +6,7 @@ jobs:
   build:
     docker:
       # specify the version
-      - image: istio/fortio.build:v5
+      - image: istio/fortio.build:v6
     working_directory: /go/src/istio.io/fortio
     steps:
       - setup_remote_docker:
diff --git a/Dockerfile b/Dockerfile
index fe859af..4e19b0d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Build the binaries in larger image
-FROM istio/fortio.build:v5 as build
+FROM istio/fortio.build:v6 as build
 WORKDIR /go/src/istio.io
 COPY . fortio
 # Submodule handling
@@ -8,12 +8,16 @@ RUN make -C fortio submodule
 RUN echo "$(date +'%Y-%m-%d %H:%M') $(cd fortio; git rev-parse HEAD)" > /build-info.txt
 # Sets up the static directory outside of the go source tree and
 # the default data directory to a /var/lib/... volume
+RUN go version
 RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags \
   "-s -X istio.io/fortio/ui.resourcesDir=/usr/local/lib/fortio -X main.defaultDataDir=/var/lib/istio/fortio \
   -X \"istio.io/fortio/version.buildInfo=$(cat /build-info.txt)\" \
   -X istio.io/fortio/version.tag=$(cd fortio; git describe --tags) \
   -X istio.io/fortio/version.gitstatus=$(cd fortio; git status --porcelain | wc -l)" \
   -o fortio.bin istio.io/fortio
+# Check we still build with go 1.8 (and macos does not break)
+RUN /usr/local/go/bin/go version
+RUN CGO_ENABLED=0 GOOS=darwin /usr/local/go/bin/go build -a -ldflags -s -o fortio.go18.mac istio.io/fortio
 # Just check it stays compiling on Windows (would need to set the rsrcDir too)
 RUN CGO_ENABLED=0 GOOS=windows go build -a -o fortio.exe istio.io/fortio
 # Minimal image with just the binary and certs
diff --git a/Dockerfile.build b/Dockerfile.build
index 863ecff..4b182c0 100644
--- a/Dockerfile.build
+++ b/Dockerfile.build
@@ -5,12 +5,18 @@ RUN apt-get -y update && \
   apt-get --no-install-recommends -y upgrade && \
   apt-get --no-install-recommends -y install ca-certificates curl make git gcc \
   libc6-dev apt-transport-https ssh
-# Stay on go1.8 so we don't become incompatible with 1.8
-RUN curl https://storage.googleapis.com/golang/go1.8.7.linux-amd64.tar.gz | tar xfz - -C /usr/local
+# Install both go1.10 and go1.8 so we don't become incompatible with 1.8
+RUN curl -f https://storage.googleapis.com/golang/go1.8.7.linux-amd64.tar.gz | tar xfz - -C /usr/local
+RUN mkdir /go1.10
+RUN curl -f https://dl.google.com/go/go1.10.linux-amd64.tar.gz | tar xfz - -C /go1.10
 ENV GOPATH /go
-ENV PATH /usr/local/go/bin:$PATH:$GOPATH/bin
+RUN mkdir -p $GOPATH/bin
+ENV PATH /go1.10/go/bin:/usr/local/go/bin:$PATH:$GOPATH/bin
 # This is now handled through dep and vendor submodule
 # RUN go get -u google.golang.org/grpc
+# Install dep
+RUN curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
+# Install meta linters
 RUN go get -u github.com/alecthomas/gometalinter
 RUN gometalinter -i -u
 WORKDIR /go/src/istio.io
diff --git a/Dockerfile.echosrv b/Dockerfile.echosrv
index 8fa391a..0732bd2 100644
--- a/Dockerfile.echosrv
+++ b/Dockerfile.echosrv
@@ -1,5 +1,5 @@
 # Build the binaries in larger image
-FROM istio/fortio.build:v5 as build
+FROM istio/fortio.build:v6 as build
 WORKDIR /go/src/istio.io
 COPY . fortio
 RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s' -o echosrv.bin istio.io/fortio/echosrv
diff --git a/Makefile b/Makefile
index bad3ce7..969643c 100755
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@
 IMAGES=echosrv # plus the combo image / Dockerfile without ext.
 
 DOCKER_PREFIX := docker.io/istio/fortio
-BUILD_IMAGE_TAG := v5
+BUILD_IMAGE_TAG := v6
 BUILD_IMAGE := istio/fortio.build:$(BUILD_IMAGE_TAG)
 
 TAG:=$(USER)$(shell date +%y%m%d_%H%M%S)
diff --git a/README.md b/README.md
index 492433d..212b620 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ docker run istio/fortio load http://www.google.com/ # For a test run
 Or download the binary distribution, for instance:
 
 ```shell
-curl -L https://github.com/istio/fortio/releases/download/v0.7.2/fortio-linux_x64-0.7.2.tgz \
+curl -L https://github.com/istio/fortio/releases/download/v0.7.3/fortio-linux_x64-0.7.3.tgz \
  | sudo tar -C / -xvzpf -
 ```
 
@@ -42,7 +42,7 @@ You can get a preview of the reporting/graphing UI at https://fortio.istio.io/
 Fortio can be an http or grpc load generator, gathering statistics using the `load` subcommand, or start simple http and grpc ping servers, as well as a basic web UI, result graphing and https redirector, with the `server` command or issue grpc ping messages using the `grpcping` command. It can also fetch a single URL's for debugging when using the `curl` command (or the `-curl` flag to the load command). You can run just the redirector with `redirect`. Lastly if you saved JSON results (using the web UI or directly from the command line), you can browse and graph those results using the `report` command.
 
 ```
-Φορτίο 0.7.2 usage:
+Φορτίο 0.7.3 usage:
 	fortio command [flags] target
 where command is one of: load (load testing), server (starts grpc ping and
 http echo/ui/redirect servers), grpcping (grpc client), report (report only UI
@@ -153,8 +153,8 @@ $ fortio server &
 Https redirector running on :8081
 UI starting - visit:
 http://localhost:8080/fortio/   (or any host/ip reachable on this server)
-Fortio 0.7.2 grpc ping server listening on port :8079
-Fortio 0.7.2 echo server listening on port :8080
+Fortio 0.7.3 grpc ping server listening on port :8079
+Fortio 0.7.3 echo server listening on port :8080
 ```
 
 * By default, Fortio's web/echo servers listen on port 8080 on all interfaces.
@@ -164,8 +164,8 @@ $ fortio server -http-port 10.10.10.10:8088
 UI starting - visit:
 http://10.10.10.10:8088/fortio/
 Https redirector running on :8081
-Fortio 0.7.2 grpc ping server listening on port :8079
-Fortio 0.7.2 echo server listening on port 10.10.10.10:8088
+Fortio 0.7.3 grpc ping server listening on port :8079
+Fortio 0.7.3 echo server listening on port 10.10.10.10:8088
 ```
 * Simple grpc ping:
 ```
@@ -235,14 +235,14 @@ Content-Type: text/plain; charset=UTF-8
 Date: Mon, 08 Jan 2018 22:26:26 GMT
 Content-Length: 230
 
-Φορτίο version 0.7.2 echo debug server up for 39s on ldemailly-macbookpro - request from [::1]:65055
+Φορτίο version 0.7.3 echo debug server up for 39s on ldemailly-macbookpro - request from [::1]:65055
 
 GET /debug HTTP/1.1
 
 headers:
 
 Host: localhost:8080
-User-Agent: istio/fortio-0.7.2
+User-Agent: istio/fortio-0.7.3
 Foo: Bar
 
 body:
@@ -271,6 +271,7 @@ For instance `curl -d abcdef http://localhost:8080/` returns `abcdef` back. It s
 | delay     | duration to delay the response by. Can be a single value or a coma separated list of probabilities, e.g `delay=150us:10,2ms:5,0.5s:1` for 10% of chance of a 150 us delay, 5% of a 2ms delay and 1% of a 1/2 second delay |
 | status    | http status to return instead of 200. Can be a single value or a coma separated list of probabilities, e.g `status=404:10,503:5,429:1` for 10% of chance of a 404 status, 5% of a 503 status and 1% of a 429 status |
 | size      | size of the payload to reply instead of echoing input. Also works as probabilities list. `size=1024:10,512:5` 10% of response will be 1k and 5% will be 512 bytes payload and the rest defaults to echoing back. |
+| close     | close the socket after answering e.g `close=true` |
 - `/debug` will echo back the request in plain text for human debugging.
 - `/fortio/` A UI to
   - Run/Trigger tests and graph the results.
diff --git a/fgrpc/grpcrunner.go b/fgrpc/grpcrunner.go
index 7fe73f1..5e248f1 100644
--- a/fgrpc/grpcrunner.go
+++ b/fgrpc/grpcrunner.go
@@ -101,7 +101,7 @@ func RunGRPCTest(o *GRPCRunnerOptions) (*GRPCRunnerResults, error) {
 		Destination: o.Destination,
 	}
 	grpcstate := make([]GRPCRunnerResults, numThreads)
-
+	out := r.Options().Out // Important as the default value is set from nil to stdout inside NewPeriodicRunner
 	for i := 0; i < numThreads; i++ {
 		r.Options().Runners[i] = &grpcstate[i]
 		conn, err := Dial(o.Destination, o.Secure)
@@ -159,7 +159,7 @@ func RunGRPCTest(o *GRPCRunnerOptions) (*GRPCRunnerResults, error) {
 		}
 	}
 	for _, k := range keys {
-		fmt.Printf("Health %s : %d\n", k.String(), total.RetCodes[k])
+		fmt.Fprintf(out, "Health %s : %d\n", k.String(), total.RetCodes[k])
 	}
 	return &total, nil
 }
diff --git a/fhttp/http.go b/fhttp/http.go
index b8145ae..f316437 100644
--- a/fhttp/http.go
+++ b/fhttp/http.go
@@ -48,7 +48,8 @@ type Fetcher interface {
 	// headers)
 	Fetch() (int, []byte, int)
 	// Close() cleans up connections and state - must be paired with NewClient calls.
-	Close()
+	// returns how many sockets have been used (Fastclient only)
+	Close() int
 }
 
 var (
@@ -86,8 +87,9 @@ func (h *HTTPOptions) Init(url string) *HTTPOptions {
 	if h.HTTPReqTimeOut <= 0 {
 		h.HTTPReqTimeOut = HTTPReqTimeOutDefaultValue
 	}
-	h.ResetHeaders()
-	h.extraHeaders.Add("User-Agent", userAgent)
+	if h.extraHeaders == nil { // not already initialized from flags.
+		h.InitHeaders()
+	}
 	h.URLSchemeCheck()
 	return h
 }
@@ -152,6 +154,12 @@ func (h *HTTPOptions) ResetHeaders() {
 	h.hostOverride = ""
 }
 
+// InitHeaders initialize and/or resets the default headers.
+func (h *HTTPOptions) InitHeaders() {
+	h.ResetHeaders()
+	h.extraHeaders.Add("User-Agent", userAgent)
+}
+
 // GetHeaders returns the current set of headers.
 func (h *HTTPOptions) GetHeaders() http.Header {
 	if h.hostOverride == "" {
@@ -164,7 +172,11 @@ func (h *HTTPOptions) GetHeaders() http.Header {
 
 // AddAndValidateExtraHeader collects extra headers (see main.go for example).
 func (h *HTTPOptions) AddAndValidateExtraHeader(hdr string) error {
-	h.Init(h.URL)
+	// This function can be called from the flag settings, before we have a URL
+	// so we can't just call h.Init(h.URL)
+	if h.extraHeaders == nil {
+		h.InitHeaders()
+	}
 	s := strings.SplitN(hdr, ":", 2)
 	if len(s) != 2 {
 		return fmt.Errorf("invalid extra header '%s', expecting Key: Value", hdr)
@@ -215,7 +227,7 @@ type Client struct {
 }
 
 // Close cleans up any resources used by NewStdClient
-func (c *Client) Close() {
+func (c *Client) Close() int {
 	log.Debugf("Close() on %+v", c)
 	if c.req != nil {
 		if c.req.Body != nil {
@@ -228,6 +240,7 @@ func (c *Client) Close() {
 	if c.transport != nil {
 		c.transport.CloseIdleConnections()
 	}
+	return 0 // TODO: find a way to track std client socket usage.
 }
 
 // ChangeURL only for standard client, allows fetching a different URL
@@ -328,6 +341,7 @@ type FastClient struct {
 	req          []byte
 	dest         net.TCPAddr
 	socket       *net.TCPConn
+	socketCount  int
 	size         int
 	code         int
 	errorCount   int
@@ -344,14 +358,15 @@ type FastClient struct {
 }
 
 // Close cleans up any resources used by FastClient
-func (c *FastClient) Close() {
-	log.Debugf("Closing %+v", c)
+func (c *FastClient) Close() int {
+	log.Debugf("Closing %p %s socket count %d", c, c.url, c.socketCount)
 	if c.socket != nil {
 		if err := c.socket.Close(); err != nil {
 			log.Warnf("Error closing fast client's socket: %v", err)
 		}
 		c.socket = nil
 	}
+	return c.socketCount
 }
 
 // NewFastClient makes a basic, efficient http 1.0/1.1 client.
@@ -582,6 +597,7 @@ func (c *FastClient) returnRes() (int, []byte, int) {
 
 // connect to destination.
 func (c *FastClient) connect() *net.TCPConn {
+	c.socketCount++
 	socket, err := net.DialTCP("tcp", nil, &c.dest)
 	if err != nil {
 		log.Errf("Unable to connect to %v : %v", c.dest, err)
@@ -600,9 +616,17 @@ func (c *FastClient) connect() *net.TCPConn {
 	return socket
 }
 
+// Extra error codes outside of the HTTP Status code ranges. ie negative.
+const (
+	// SocketError is return when a transport error occurred: unexpected EOF, connection error, etc...
+	SocketError = -1
+	// RetryOnce is used internally as an error code to allow 1 retry for bad socket reuse.
+	RetryOnce = -2
+)
+
 // Fetch fetches the url content. Returns http code, data, offset of body.
 func (c *FastClient) Fetch() (int, []byte, int) {
-	c.code = -1
+	c.code = SocketError
 	c.size = 0
 	c.headerLen = 0
 	// Connect or reuse existing socket:
@@ -616,7 +640,7 @@ func (c *FastClient) Fetch() (int, []byte, int) {
 	} else {
 		log.Debugf("Reusing socket %v", *conn)
 	}
-	c.socket = nil // because of error returns
+	c.socket = nil // because of error returns and single retry
 	conErr := conn.SetReadDeadline(time.Now().Add(c.reqTimeout))
 	// Send the request:
 	n, err := conn.Write(c.req)
@@ -643,7 +667,11 @@ func (c *FastClient) Fetch() (int, []byte, int) {
 		log.Debugf("Half closed ok after sending request %v %v", conn, c.dest)
 	}
 	// Read the response:
-	c.readResponse(conn)
+	c.readResponse(conn, reuse)
+	if c.code == RetryOnce {
+		// Special "eof on reused socket" code
+		return c.Fetch() // recurse once
+	}
 	// Return the result:
 	return c.returnRes()
 }
@@ -668,10 +696,10 @@ func DebugSummary(buf []byte, max int) string {
 
 // Response reading:
 // TODO: refactor - unwiedly/ugly atm
-func (c *FastClient) readResponse(conn *net.TCPConn) {
+func (c *FastClient) readResponse(conn *net.TCPConn, reusedSocket bool) {
 	max := len(c.buffer)
 	parsedHeaders := false
-	// TODO: safer to start with -1 and fix ok for http 1.0
+	// TODO: safer to start with -1 / SocketError and fix ok for http 1.0
 	c.code = http.StatusOK // In http 1.0 mode we don't bother parsing anything
 	endofHeadersStart := retcodeOffset + 3
 	keepAlive := c.keepAlive
@@ -683,16 +711,24 @@ func (c *FastClient) readResponse(conn *net.TCPConn) {
 		// TODO: need automated tests
 		if !skipRead {
 			n, err := conn.Read(c.buffer[c.size:])
-			if err == io.EOF {
-				if c.size == 0 {
-					log.Errf("EOF before reading anything on %v %v", conn, c.dest)
-					c.code = -1
-				}
-				break
-			}
 			if err != nil {
+				if reusedSocket && c.size == 0 {
+					// Ok for reused socket to be dead once (close by server)
+					log.Infof("Closing dead socket %v (err %v at first read)", *conn, err)
+					c.errorCount++
+					err = conn.Close() // close the previous one
+					if err != nil {
+						log.Warnf("Error closing dead socket %v: %v", *conn, err)
+					}
+					c.code = RetryOnce // special "retry once" code
+					return
+				}
+				if err == io.EOF && c.size != 0 {
+					// handled below as possibly normal end of stream after we read something
+					break
+				}
 				log.Errf("Read error %v %v %d : %v", conn, c.dest, c.size, err)
-				c.code = -1
+				c.code = SocketError
 				break
 			}
 			c.size += n
@@ -1102,9 +1138,13 @@ func EchoHandler(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	}
+	if r.FormValue("close") != "" {
+		log.Debugf("Adding Connection:close / will close socket")
+		w.Header().Set("Connection", "close")
+	}
 	size := generateSize(r.FormValue("size"))
 	if size >= 0 {
-		log.Errf("Writing %d size with %d status", size, status)
+		log.LogVf("Writing %d size with %d status", size, status)
 		writePayload(w, status, size)
 		return
 	}
diff --git a/fhttp/httprunner.go b/fhttp/httprunner.go
index ef67489..c05f1a6 100644
--- a/fhttp/httprunner.go
+++ b/fhttp/httprunner.go
@@ -43,6 +43,7 @@ type HTTPRunnerResults struct {
 	Sizes       *stats.HistogramData
 	HeaderSizes *stats.HistogramData
 	URL         string
+	SocketCount int
 }
 
 // Run tests http request fetching. Main call being run at the target QPS.
@@ -129,7 +130,7 @@ func RunHTTPTest(o *HTTPRunnerOptions) (*HTTPRunnerResults, error) {
 	// unused ones. We also must cleanup all the created clients.
 	keys := []int{}
 	for i := 0; i < numThreads; i++ {
-		httpstate[i].client.Close()
+		total.SocketCount += httpstate[i].client.Close()
 		// Q: is there some copying each time stats[i] is used?
 		for k := range httpstate[i].RetCodes {
 			if _, exists := total.RetCodes[k]; !exists {
@@ -142,6 +143,7 @@ func RunHTTPTest(o *HTTPRunnerOptions) (*HTTPRunnerResults, error) {
 	}
 	sort.Ints(keys)
 	totalCount := float64(total.DurationHistogram.Count)
+	fmt.Fprintf(out, "Sockets used: %d (for perfect keepalive, would be %d)\n", total.SocketCount, r.Options().NumThreads)
 	for _, k := range keys {
 		fmt.Fprintf(out, "Code %3d : %d (%.1f %%)\n", k, total.RetCodes[k], 100.*float64(total.RetCodes[k])/totalCount)
 	}
diff --git a/release/Dockerfile.in b/release/Dockerfile.in
index ceefa55..100a27f 100644
--- a/release/Dockerfile.in
+++ b/release/Dockerfile.in
@@ -1,5 +1,5 @@
 # Concatenated after ../Dockerfile to create the tgz
-FROM istio/fortio.build:v5 as stage
+FROM istio/fortio.build:v6 as stage
 WORKDIR /stage
 COPY --from=release /usr/local usr/local
 RUN mkdir -p var/lib/istio/fortio
diff --git a/ui/templates/browse.html b/ui/templates/browse.html
index c2dc5cd..e8aea36 100644
--- a/ui/templates/browse.html
+++ b/ui/templates/browse.html
@@ -37,7 +37,7 @@ function fortio_load(url) {
       data = fortioResultToJsChartData(res)
       showChart(data)
       var urldiv = document.getElementById('url')
-      urldiv.innerHTML = "<a href='browse?url=" + url + "'>" + url + "</a>"
+      urldiv.innerHTML = "<a href='browse?url=" + url + "'>" + url + "</a> (<a href='data/" + url +"'>json</a>)"
     }).catch(err => { throw err })
   } else {
     var urldiv = document.getElementById('url')
@@ -128,5 +128,6 @@ for (var i = 0; i < files.options.length; i++) {
 fortio_load(files.value)
 </script>
 {{end}}
+<p>Go to <a href='./'>Top</a>.</p>
 </body>
 </html>
diff --git a/ui/templates/main.html b/ui/templates/main.html
index 161f32a..233263c 100644
--- a/ui/templates/main.html
+++ b/ui/templates/main.html
@@ -52,9 +52,10 @@ Headers: <br />
 {{end}}{{end}} <!-- 2 extra header lines, TODO: add a JS 'more headers' button -->
 <input type="text" name="H" size=40 value="" /> <br />
 <input type="text" name="H" size=40 value="" /> <br />
-http: <input type="radio" name="runner" value="http" checked/>,
-grpc: <input type="radio" name="runner" value="grpc"/><br/>
-Use secure transport (tls) for GRPC <input type="checkbox" name="grpc-secure"/><br/>
+Use standard go client instead of fastclient <input type="checkbox" name="stdclient"/> <br />
+Load using http: <input type="radio" name="runner" value="http" checked/> or
+grpc: <input type="radio" name="runner" value="grpc"/>
+(grpc secure transport (tls): <input type="checkbox" name="grpc-secure"/>) <br />
 JSON output: <input type="checkbox" name="json" />,
 Save output: <input type="checkbox" name="save" checked /> <br />
 <input type="submit" name="load" value="Start"/>
diff --git a/ui/uihandler.go b/ui/uihandler.go
index 8494ad8..0bec1ca 100644
--- a/ui/uihandler.go
+++ b/ui/uihandler.go
@@ -166,6 +166,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 	qps, _ := strconv.ParseFloat(r.FormValue("qps"), 64)      // nolint: gas
 	durStr := r.FormValue("t")
 	grpcSecure := (r.FormValue("grpc-secure") == "on")
+	stdClient := (r.FormValue("stdclient") == "on")
 	var dur time.Duration
 	if durStr == "on" || ((len(r.Form["t"]) > 1) && r.Form["t"][1] == "on") {
 		dur = -1
@@ -209,6 +210,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 		log.Infof("New run id %d", runid)
 	}
 	httpopts := fhttp.NewHTTPOptions(url)
+	httpopts.DisableFastClient = stdClient
 	if !JSONOnly {
 		// Normal html mode
 		if mainTemplate == nil {
@@ -322,8 +324,9 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 			log.Fatalf("Unable to json serialize result: %v", err)
 		}
 		savedAs := ""
+		id := res.Result().ID()
 		if DoSave {
-			savedAs = SaveJSON(res.Result().ID(), json)
+			savedAs = SaveJSON(id, json)
 		}
 		if JSONOnly {
 			w.Header().Set("Content-Type", "application/json")
@@ -335,7 +338,8 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 		}
 		if savedAs != "" {
 			// nolint: errcheck, gas
-			w.Write([]byte(fmt.Sprintf("Saved result to <a href='%s'>%s</a>\n", savedAs, savedAs)))
+			w.Write([]byte(fmt.Sprintf("Saved result to <a href='%s'>%s</a>"+
+				" (<a href='browse?url=%s.json' target='_new'>graph link</a>)\n", savedAs, savedAs, id)))
 		}
 		// nolint: errcheck, gas
 		w.Write([]byte(fmt.Sprintf("All done %d calls %.3f ms avg, %.1f qps\n</pre>\n<script>\n",
@@ -343,7 +347,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
 			1000.*res.Result().DurationHistogram.Avg,
 			res.Result().ActualQPS)))
 		ResultToJsData(w, json)
-		w.Write([]byte("</script></body></html>\n")) // nolint: gas
+		w.Write([]byte("</script><p>Go to <a href='./'>Top</a>.</p></body></html>\n")) // nolint: gas
 	}
 }
 


Test patch:
diff --git a/Dockerfile.test b/Dockerfile.test
index 0085fb9..d92ae13 100644
--- a/Dockerfile.test
+++ b/Dockerfile.test
@@ -1,5 +1,4 @@
 # Getting the source tree ready for running tests (sut)
-FROM istio/fortio.build:v5
+FROM istio/fortio.build:v6
 WORKDIR /go/src/istio.io
-RUN go get google.golang.org/grpc
 COPY . fortio
diff --git a/fhttp/httprunner_test.go b/fhttp/httprunner_test.go
index 1d15e5a..d8a5e97 100644
--- a/fhttp/httprunner_test.go
+++ b/fhttp/httprunner_test.go
@@ -56,6 +56,9 @@ func TestHTTPRunner(t *testing.T) {
 	if totalReq != httpOk {
 		t.Errorf("Mismatch between requests %d and ok %v", totalReq, res.RetCodes)
 	}
+	if res.SocketCount != res.RunnerResults.NumThreads {
+		t.Errorf("%d socket used, expected same as thread# %d", res.SocketCount, res.RunnerResults.NumThreads)
+	}
 	// Test raw client, should get warning about non init timeout:
 	rawOpts := HTTPOptions{
 		URL: opts.URL,
@@ -114,6 +117,12 @@ func testHTTPNotLeaking(t *testing.T, opts *HTTPRunnerOptions) {
 	if ngAfter > ngBefore2+5 {
 		t.Errorf("Goroutines after test %d, expected it to stay near %d", ngAfter, ngBefore2)
 	}
+	if !opts.DisableFastClient {
+		// only fast client so far has a socket count
+		if res.SocketCount != res.RunnerResults.NumThreads {
+			t.Errorf("%d socket used, expected same as thread# %d", res.SocketCount, res.RunnerResults.NumThreads)
+		}
+	}
 }
 
 func TestHttpNotLeakingFastClient(t *testing.T) {
@@ -146,6 +155,33 @@ func TestHTTPRunnerClientRace(t *testing.T) {
 	}
 }
 
+func TestClosingAndSocketCount(t *testing.T) {
+	port, mux := DynamicHTTPServer(false)
+	mux.HandleFunc("/echo42/", EchoHandler)
+	URL := fmt.Sprintf("http://localhost:%d/echo42/?close=1", port)
+	opts := HTTPRunnerOptions{}
+	opts.Init(URL)
+	opts.QPS = 10
+	numReq := int64(50) // can't do too many without running out of fds on mac
+	opts.Exactly = numReq
+	opts.NumThreads = 5
+	res, err := RunHTTPTest(&opts)
+	if err != nil {
+		t.Fatal(err)
+	}
+	totalReq := res.DurationHistogram.Count
+	if totalReq != numReq {
+		t.Errorf("Mismatch between requests %d and expected %d", totalReq, numReq)
+	}
+	httpOk := res.RetCodes[http.StatusOK]
+	if totalReq != httpOk {
+		t.Errorf("Mismatch between requests %d and ok %v", totalReq, res.RetCodes)
+	}
+	if int64(res.SocketCount) != numReq {
+		t.Errorf("When closing, got %d while expected as many sockets as requests %d", res.SocketCount, numReq)
+	}
+}
+
 func TestHTTPRunnerBadServer(t *testing.T) {
 	// Using http to an https server (or the current 'close all' dummy https server)
 	// should fail: