mirror of
https://github.com/kopia/kopia.git
synced 2026-09-18 08:11:14 -04:00
Fix TestServerControlSocketActivated flake. - Clear `runner.ExtraFiles` before calling running other commands. This appears to be the primary source of the spurious test failures. Refactor `TestServerControlSocketActivated` to: - start server from the test (go)routine instead of a background one; - process server's output synchrounously in the test goroutine; - fix race: close the listener file descriptor synchronously in the test goroutine instead of async, which ensures the descriptor is closed before sending the first request to the (status) server; otherwise, shutdown hangs (when the listener close is delayed); - check and assert server's exit status; - improve test cleanup: ensure child server process terminates and is reaped on assertion failure. Also, address potential race in `TestServerControlSocketActivatedTooManyFDs` check with explanation comment. There is still a posible race where the stderr pipe is closed before the background async callback gets the error message. This will be addressed separately.
192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
//go:build linux || darwin
|
|
|
|
package socketactivation_test
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/kopia/kopia/internal/testlogging"
|
|
"github.com/kopia/kopia/internal/testutil"
|
|
"github.com/kopia/kopia/tests/testenv"
|
|
)
|
|
|
|
func TestServerControlSocketActivated(t *testing.T) {
|
|
var port int
|
|
|
|
serverExe := os.Getenv("KOPIA_SERVER_EXE")
|
|
if serverExe == "" {
|
|
t.Skip("skipping socket-activation test")
|
|
}
|
|
|
|
runner := testenv.NewExeRunnerWithBinary(t, serverExe)
|
|
env := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
dir0 := testutil.TempDirectory(t)
|
|
|
|
env.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env.RepoDir, "--override-username=another-user", "--override-hostname=another-host")
|
|
env.RunAndExpectSuccess(t, "snap", "create", dir0)
|
|
|
|
// The KOPIA_EXE wrapper will set the LISTEN_PID variable for us
|
|
env.Environment["LISTEN_FDS"] = "1"
|
|
|
|
ctx := testlogging.Context(t)
|
|
l1, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
|
|
|
|
require.NoError(t, err, "Failed to open Listener")
|
|
|
|
t.Cleanup(func() { l1.Close() })
|
|
|
|
port = testutil.EnsureType[*net.TCPAddr](t, l1.Addr()).Port
|
|
|
|
t.Logf("Activating socket on port %v", port)
|
|
|
|
l1File, err := testutil.EnsureType[*net.TCPListener](t, l1).File()
|
|
require.NoError(t, err, "failed to get filehandle for socket")
|
|
|
|
var sp testutil.ServerParameters
|
|
|
|
runner.ExtraFiles = append(runner.ExtraFiles, l1File)
|
|
wait, kill := env.RunAndProcessStderr(t, sp.ProcessOutput,
|
|
"server", "start", "--insecure", "--random-server-control-password", "--address=127.0.0.1:0")
|
|
|
|
// prevent other sub-processes from getting the activation file descriptor,
|
|
// which ends up causing the server to block on shutdown and leads to spurious
|
|
// test failures.
|
|
runner.ExtraFiles = nil
|
|
|
|
l1File.Close()
|
|
|
|
serverStopped := make(chan error)
|
|
|
|
go func() {
|
|
serverStopped <- wait()
|
|
|
|
close(serverStopped)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
kill()
|
|
|
|
select {
|
|
case err := <-serverStopped: // maybe drain serverStopped
|
|
t.Log("cleanup <-serverStopped:", err)
|
|
case <-time.After(3 * time.Second): // ensure cleanup exits
|
|
}
|
|
})
|
|
|
|
require.NotEmpty(t, sp.BaseURL, "Failed to start server")
|
|
require.Contains(t, sp.BaseURL, ":"+strconv.Itoa(port))
|
|
|
|
checkServerStatusFn := func(collect *assert.CollectT) {
|
|
lines := env.RunAndExpectSuccess(t, "server", "status", "--address", "http://127.0.0.1:"+strconv.Itoa(port), "--server-control-password", sp.ServerControlPassword, "--remote")
|
|
require.Len(collect, lines, 1)
|
|
require.Contains(collect, lines, "IDLE: another-user@another-host:"+dir0)
|
|
}
|
|
|
|
require.EventuallyWithT(t, checkServerStatusFn, 30*time.Second, 2*time.Second, "could not get server status, perhaps it was not listening on the control endpoint yet?")
|
|
|
|
env.RunAndExpectSuccess(t, "server", "shutdown", "--address", sp.BaseURL, "--server-control-password", sp.ServerControlPassword)
|
|
|
|
select {
|
|
case err := <-serverStopped:
|
|
require.NoError(t, err, "server exited with error")
|
|
t.Log("server shut down")
|
|
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("server did not shutdown in time")
|
|
}
|
|
}
|
|
|
|
func TestServerControlSocketActivatedTooManyFDs(t *testing.T) {
|
|
serverExe := os.Getenv("KOPIA_SERVER_EXE")
|
|
if serverExe == "" {
|
|
t.Skip("skipping socket-activation test")
|
|
}
|
|
|
|
runner := testenv.NewExeRunnerWithBinary(t, serverExe)
|
|
env := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
|
|
|
|
env.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env.RepoDir, "--override-username=another-user", "--override-hostname=another-host")
|
|
|
|
// create 2 file descriptor for a single socket and pass the descriptors to the server
|
|
ctx := testlogging.Context(t)
|
|
l1, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")
|
|
|
|
require.NoError(t, err, "Failed to open Listener")
|
|
|
|
t.Cleanup(func() { l1.Close() })
|
|
|
|
port := testutil.EnsureType[*net.TCPAddr](t, l1.Addr()).Port
|
|
|
|
t.Logf("activation socket port %v", port)
|
|
|
|
listener := testutil.EnsureType[*net.TCPListener](t, l1)
|
|
|
|
l1File, err := listener.File()
|
|
require.NoError(t, err, "failed to get 1st filehandle for socket")
|
|
|
|
t.Cleanup(func() { l1File.Close() })
|
|
|
|
l2File, err := listener.File()
|
|
require.NoError(t, err, "failed to get 2nd filehandle for socket")
|
|
|
|
t.Cleanup(func() { l2File.Close() })
|
|
|
|
runner.ExtraFiles = append(runner.ExtraFiles, l1File, l2File)
|
|
// The KOPIA_EXE wrapper will set the LISTEN_PID variable for us
|
|
env.Environment["LISTEN_FDS"] = "2"
|
|
|
|
var gotExpectedErrorMessage atomic.Bool
|
|
|
|
stderrAsyncCallback := func(line string) {
|
|
if strings.Contains(line, "Too many activated sockets found. Expected 1, got 2") {
|
|
gotExpectedErrorMessage.Store(true)
|
|
}
|
|
}
|
|
|
|
// although the server is expected to stop quickly with an error, the server's
|
|
// stderr is processed async to avoid test deadlocks if the server continues
|
|
// to run and does not exit.
|
|
wait, kill := env.RunAndProcessStderrAsync(t, func(string) bool { return false }, stderrAsyncCallback, "server", "start", "--insecure", "--random-server-control-password", "--address=127.0.0.1:0")
|
|
|
|
serverStopped := make(chan error)
|
|
|
|
t.Cleanup(func() {
|
|
kill()
|
|
|
|
select {
|
|
case err := <-serverStopped: // maybe drain serverStopped
|
|
t.Log("cleanup <-serverStopped:", err)
|
|
case <-time.After(3 * time.Second): // ensure cleanup exits
|
|
}
|
|
})
|
|
|
|
go func() {
|
|
defer close(serverStopped)
|
|
|
|
serverStopped <- wait()
|
|
}()
|
|
|
|
select {
|
|
case err := <-serverStopped:
|
|
require.Error(t, err, "server did not exit with an error")
|
|
t.Log("Done")
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("server did not exit in time")
|
|
}
|
|
|
|
// gotExpectedErrorMessage may be read before stderrAsyncCallback sets it above.
|
|
// Prevent flaky test failures by avoiding a potential race where stderrAsyncCallback
|
|
// may still be processing the server's output even after wait() has returned.
|
|
require.Eventually(t, gotExpectedErrorMessage.Load, 15*time.Second, time.Second, "expected server's stderr to contain a line along the lines of 'Too many activated sockets ...'")
|
|
}
|