Files
kopia/tests/socketactivation_test/socketactivation_test.go
T
Julio López 9025d496cf fix(testing): TestServerControlSocketActivated flake (#5643)
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.
2026-09-13 20:21:44 -07:00

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 ...'")
}