309 lines
8.9 KiB
Go
309 lines
8.9 KiB
Go
package hw3test
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// RunTestEmptyWorkDir will check that server does exit(1) if working directory is empty.
|
|
func RunTestEmptyWorkDir(t *TC, seed int64, runner Runner) {
|
|
t.RunBySeed(seed, func(t *TC) {
|
|
r := rand.New(rand.NewSource(seed))
|
|
|
|
port, err := GetFreePort()
|
|
require.NoError(t, err, "failed to get free port for the server")
|
|
runOpts := RunOpts{
|
|
Port: port,
|
|
WorkingDirectory: "",
|
|
ServerDomain: "localhost",
|
|
ListenAddr: "0.0.0.0",
|
|
ExitCode: make(chan int),
|
|
}
|
|
|
|
// Start the solution.
|
|
runOpts.GenerateRunConfig(t, r, &EnvGen{})
|
|
stop, err := runner.Run(t, runOpts)
|
|
require.NoError(t, err, "failed to start solution")
|
|
defer stop()
|
|
|
|
select {
|
|
case <-time.After(time.Second * 10):
|
|
require.FailNow(t, "Server didn't exit(1) in 10 seconds")
|
|
case ec := <-runOpts.ExitCode:
|
|
require.Equal(t, 1, ec, "Server exited with wrong code")
|
|
}
|
|
})
|
|
}
|
|
|
|
func RunTests(t *TC, seed int64, runner Runner, envGen *EnvGen, queriesGen *QueriesGen) {
|
|
failed := !t.RunBySeed(seed, func(t *TC) {
|
|
env, err := envGen.Generate(seed)
|
|
require.NoError(t, err, "failed to generate environment")
|
|
|
|
r := rand.New(rand.NewSource(seed))
|
|
|
|
queriesSeeds := make([]int64, queriesGen.Count)
|
|
for i := range queriesSeeds {
|
|
queriesSeeds[i] = r.Int63n(100000)
|
|
}
|
|
|
|
envDirName := GenFilename(r, 16)
|
|
envDir := filepath.Join(envGen.TempDirectory, envDirName)
|
|
|
|
runOpts := RunOpts{
|
|
WorkingDirectory: envDir,
|
|
ServerDomain: "localhost",
|
|
ListenAddr: "0.0.0.0",
|
|
}
|
|
if queriesGen.AllHeaders {
|
|
possibleDomains := []string{
|
|
"localhost",
|
|
"cs.hse.ru",
|
|
"example.com",
|
|
"z0r.de",
|
|
"distsys-course.homework.net",
|
|
}
|
|
runOpts.ServerDomain = possibleDomains[r.Intn(len(possibleDomains))]
|
|
}
|
|
|
|
queries := queriesGen.Generate(t, env, envGen, queriesSeeds, runOpts, seed)
|
|
if envGen.SparseUnusedFiles {
|
|
needed := make(map[string]bool)
|
|
for _, query := range queries {
|
|
if query.Method == http.MethodGet || query.Method == http.MethodPut {
|
|
needed[path.Clean(query.Path)] = true
|
|
}
|
|
}
|
|
err = env.RootDir.WriteToDiskSelected(envDir, needed)
|
|
} else {
|
|
err = env.RootDir.WriteToDisk(envDir)
|
|
}
|
|
require.NoError(t, err, "failed to write environment to disk")
|
|
defer os.RemoveAll(envDir)
|
|
port, err := GetFreePort()
|
|
require.NoError(t, err, "failed to get free port for the server")
|
|
runOpts.Port = port
|
|
|
|
// Start the solution.
|
|
runOpts.GenerateRunConfig(t, r, envGen)
|
|
stop, err := runner.Run(t, runOpts)
|
|
require.NoError(t, err, "failed to start solution")
|
|
defer stop()
|
|
|
|
// Await server to bind to port.
|
|
err = WaitForServer(t, runOpts)
|
|
require.NoError(t, err, "failed to wait for server")
|
|
|
|
// Run the queries.
|
|
for i, query := range queries {
|
|
query := query
|
|
ok := t.RunBySeed(query.Seed, func(t *TC) {
|
|
RunQuery(t, env, envDir, runOpts, query)
|
|
})
|
|
shouldAbort := !ok
|
|
if shouldAbort {
|
|
Warn(t, "Skipping next queries because of the failed query", zap.Int("skipped", len(queries)-1-i), zap.String("failed", fmt.Sprintf("%s/%v", t.Name(), query.Seed)))
|
|
break
|
|
}
|
|
}
|
|
})
|
|
|
|
shouldAbort := failed
|
|
if shouldAbort {
|
|
Warn(t, "Skipping next tests in a group because last test has failed")
|
|
t.FailNow()
|
|
}
|
|
}
|
|
|
|
func WaitForServer(t *TC, opts RunOpts) error {
|
|
client := &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
var lastErr error
|
|
for attempt := 0; time.Until(deadline) > 0; attempt++ {
|
|
probeTimeout := time.Until(deadline)
|
|
if probeTimeout > time.Second {
|
|
probeTimeout = time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(t, probeTimeout)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", opts.Address(), nil)
|
|
if err != nil {
|
|
cancel()
|
|
return fmt.Errorf("failed to create context request: %w", err)
|
|
}
|
|
req.Host = opts.ServerDomain
|
|
resp, err := client.Do(req)
|
|
if err == nil {
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
cancel()
|
|
return nil
|
|
}
|
|
cancel()
|
|
lastErr = err
|
|
if attempt == 0 || attempt%10 == 0 {
|
|
Debug(t, "Waiting for server startup", zap.Int("attempt", attempt), zap.Error(err))
|
|
}
|
|
if remaining := time.Until(deadline); remaining > 0 {
|
|
if remaining > 100*time.Millisecond {
|
|
remaining = 100 * time.Millisecond
|
|
}
|
|
time.Sleep(remaining)
|
|
}
|
|
}
|
|
Warn(t, "Server didn't get up in time, aborting", zap.Error(lastErr))
|
|
return fmt.Errorf("server did not start in 10 seconds: %w", lastErr)
|
|
}
|
|
|
|
// RunQuery runs a single query. Server address and its configuration is taken from runOpts.
|
|
// Initial environment is described by env, but it may be changed with queries. Actual
|
|
// environment is located in envDir. Query describes the query itself.
|
|
func RunQuery(t *TC, env *Env, envDir string, opts RunOpts, query Query) {
|
|
queryURL := fmt.Sprintf("%s/%s", opts.Address(), query.Path)
|
|
Debug(
|
|
t,
|
|
"Sending query",
|
|
zap.String("method", query.Method),
|
|
zap.String("path", query.Path),
|
|
zap.String("url", queryURL),
|
|
)
|
|
|
|
action := query.Action(env, &opts)
|
|
if action != nil {
|
|
action.VerifyBefore(t, envDir)
|
|
}
|
|
|
|
req := query.CreateRequest(t, queryURL)
|
|
req.Close = true
|
|
conn, err := net.DialTimeout("tcp", req.URL.Host, 10*time.Second)
|
|
require.NoError(t, err, "failed to connect to server")
|
|
defer conn.Close()
|
|
writeDone := make(chan error, 1)
|
|
writer := &idleDeadlineWriter{Conn: conn, Timeout: 15 * time.Second}
|
|
go func() { writeDone <- req.Write(writer) }()
|
|
reader := bufio.NewReader(&idleDeadlineReader{Conn: conn, Timeout: 30 * time.Second})
|
|
|
|
resp, err := http.ReadResponse(reader, req)
|
|
require.NoError(t, err, "failed to run query on server")
|
|
defer resp.Body.Close()
|
|
body := &countingBody{ReadCloser: resp.Body}
|
|
resp.Body = body
|
|
|
|
query.CommonValidate(t, req, resp)
|
|
|
|
if action != nil {
|
|
action.VerifyResponse(t, req, resp)
|
|
}
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
require.NoError(t, err, "failed to read response body")
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
require.Equal(t, resp.ContentLength, body.Size, "Content-Length differs from response body size")
|
|
}
|
|
endDone := make(chan error, 1)
|
|
go func() { endDone <- checkResponseEnd(reader, req.Body != nil) }()
|
|
var endErr, writeErr error
|
|
writeCompleted := false
|
|
select {
|
|
case endErr = <-endDone:
|
|
// The server closed the connection before the request writer finished.
|
|
case writeErr = <-writeDone:
|
|
writeCompleted = true
|
|
var netErr net.Error
|
|
// http.Request.Write wraps body write errors without preserving Unwrap.
|
|
// Inspect the original socket error captured by the writer instead.
|
|
if errors.As(writer.lastError, &netErr) && netErr.Timeout() {
|
|
require.NoError(t, writer.lastError, "request upload stalled")
|
|
}
|
|
// A server may finish reading a large request after sending its response.
|
|
// Start the close timeout only after the request writer has finished.
|
|
closeTimeout := 2 * time.Second
|
|
if req.Body != nil {
|
|
closeTimeout = 10 * time.Second
|
|
}
|
|
require.NoError(t, conn.SetReadDeadline(time.Now().Add(closeTimeout)))
|
|
endErr = <-endDone
|
|
}
|
|
require.NoError(t, endErr, "response contains bytes beyond Content-Length or connection was not closed")
|
|
// The server may reply without consuming the whole request body. In that
|
|
// case the response and the file-system checks determine correctness.
|
|
if req.Body == nil {
|
|
if !writeCompleted {
|
|
writeErr = <-writeDone
|
|
}
|
|
require.NoError(t, writeErr, "failed to send query to server")
|
|
}
|
|
if query.VerifyHeaders && body.Size > 0 {
|
|
_, err = parseContentType(resp.Header.Get("Content-Type"))
|
|
require.NoError(t, err, "expected valid content type")
|
|
}
|
|
if action != nil {
|
|
action.VerifyAfter(t, envDir)
|
|
action.ApplyEnv(t, env)
|
|
}
|
|
}
|
|
|
|
// idleDeadlineWriter bounds pauses in sending a request without limiting the
|
|
// total time for a large request that continues to make progress.
|
|
type idleDeadlineWriter struct {
|
|
net.Conn
|
|
Timeout time.Duration
|
|
lastError error
|
|
}
|
|
|
|
func (w *idleDeadlineWriter) Write(p []byte) (int, error) {
|
|
if err := w.Conn.SetWriteDeadline(time.Now().Add(w.Timeout)); err != nil {
|
|
w.lastError = err
|
|
return 0, err
|
|
}
|
|
n, err := w.Conn.Write(p)
|
|
if err != nil {
|
|
w.lastError = err
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func checkResponseEnd(reader *bufio.Reader, requestHasBody bool) error {
|
|
_, err := reader.ReadByte()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
// Closing a connection with an unread request body can produce a TCP reset
|
|
// after a complete error response. Its error code differs by platform.
|
|
var netErr *net.OpError
|
|
if requestHasBody && errors.As(err, &netErr) && netErr.Op == "read" && !netErr.Timeout() {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("unexpected byte after response body")
|
|
}
|
|
|
|
type countingBody struct {
|
|
io.ReadCloser
|
|
Size int64
|
|
}
|
|
|
|
func (b *countingBody) Read(p []byte) (int, error) {
|
|
n, err := b.ReadCloser.Read(p)
|
|
b.Size += int64(n)
|
|
return n, err
|
|
}
|