detached 0b823c5

test: stop CI flakes in socket, daemon election, web reconnect, and QUIC tests

commit
0b823c50bcbc2728b9de7e7bf9696c7c663caaa8
author
brice · · 2026-09-23 21:37 UTC
parent
e77fed7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +26 -4
M internal/adapters/ipc/mux_test.go +10 -3
@@ -259,7 +259,14 @@ func createStaleMuxSocket(t *testing.T, path string) os.FileInfo {
259259 // recovered race-safely and the carriage binds and serves.
260260 func TestListenMuxStaleSocketRecovered(t *testing.T) {
261261 path := muxTestPath(t)
262- stale := createStaleMuxSocket(t, path)
262+ createStaleMuxSocket(t, path)
263+ // Mark the stale file: a filesystem such as ext4 may hand the freed inode
264+ // number straight to the recovered socket, so inode identity alone cannot
265+ // prove the file was replaced.
266+ marked := time.Unix(1, 0)
267+ if err := os.Chtimes(path, marked, marked); err != nil {
268+ t.Fatalf("mark stale socket: %v", err)
269+ }
263270
264271 mustListenMux(t, path)
265272
@@ -267,8 +274,8 @@ func TestListenMuxStaleSocketRecovered(t *testing.T) {
267274 if err != nil {
268275 t.Fatalf("stat recovered socket: %v", err)
269276 }
270- if os.SameFile(stale, recovered) {
271- t.Fatal("stale socket inode was not replaced by the recovered listener")
277+ if recovered.ModTime().Equal(marked) {
278+ t.Fatal("stale socket was not replaced by the recovered listener")
272279 }
273280
274281 // The recovered carriage serves a same-user connection.
@@ -259,7 +259,14 @@ func createStaleMuxSocket(t *testing.T, path string) os.FileInfo {
259 // recovered race-safely and the carriage binds and serves.259 // recovered race-safely and the carriage binds and serves.
260 func TestListenMuxStaleSocketRecovered(t *testing.T) {260 func TestListenMuxStaleSocketRecovered(t *testing.T) {
261 path := muxTestPath(t)261 path := muxTestPath(t)
262- stale := createStaleMuxSocket(t, path)262+ createStaleMuxSocket(t, path)
263+ // Mark the stale file: a filesystem such as ext4 may hand the freed inode
264+ // number straight to the recovered socket, so inode identity alone cannot
265+ // prove the file was replaced.
266+ marked := time.Unix(1, 0)
267+ if err := os.Chtimes(path, marked, marked); err != nil {
268+ t.Fatalf("mark stale socket: %v", err)
269+ }
263 270
264 mustListenMux(t, path)271 mustListenMux(t, path)
265 272
@@ -267,8 +274,8 @@ func TestListenMuxStaleSocketRecovered(t *testing.T) {
267 if err != nil {274 if err != nil {
268 t.Fatalf("stat recovered socket: %v", err)275 t.Fatalf("stat recovered socket: %v", err)
269 }276 }
270- if os.SameFile(stale, recovered) {277+ if recovered.ModTime().Equal(marked) {
271- t.Fatal("stale socket inode was not replaced by the recovered listener")278+ t.Fatal("stale socket was not replaced by the recovered listener")
272 }279 }
273 280
274 // The recovered carriage serves a same-user connection.281 // The recovered carriage serves a same-user connection.
M internal/adapters/quicnettest/proxy_test.go +6 -0
@@ -54,6 +54,12 @@ func serveUDP(t *testing.T, echo bool) (*net.UDPConn, <-chan datagram) {
5454 t.Fatalf("listen udp server: %v", err)
5555 }
5656 t.Cleanup(func() { _ = conn.Close() })
57+ // Flows burst hundreds of MTU-sized datagrams. The default receive buffer
58+ // (about 200 KiB) holds too few of them when a loaded machine delays the
59+ // reader, and the kernel then drops datagrams the test is still counting.
60+ if err := conn.SetReadBuffer(4 << 20); err != nil {
61+ t.Fatalf("size udp server buffer: %v", err)
62+ }
5763 packets := make(chan datagram, 4096)
5864 go func() {
5965 buf := make([]byte, 2048)
@@ -54,6 +54,12 @@ func serveUDP(t *testing.T, echo bool) (*net.UDPConn, <-chan datagram) {
54 t.Fatalf("listen udp server: %v", err)54 t.Fatalf("listen udp server: %v", err)
55 }55 }
56 t.Cleanup(func() { _ = conn.Close() })56 t.Cleanup(func() { _ = conn.Close() })
57+ // Flows burst hundreds of MTU-sized datagrams. The default receive buffer
58+ // (about 200 KiB) holds too few of them when a loaded machine delays the
59+ // reader, and the kernel then drops datagrams the test is still counting.
60+ if err := conn.SetReadBuffer(4 << 20); err != nil {
61+ t.Fatalf("size udp server buffer: %v", err)
62+ }
57 packets := make(chan datagram, 4096)63 packets := make(chan datagram, 4096)
58 go func() {64 go func() {
59 buf := make([]byte, 2048)65 buf := make([]byte, 2048)
M internal/app/broker_daemon_start_test.go +7 -0
@@ -429,6 +429,13 @@ func TestDaemonStartConcurrentRequestsShareOneElection(t *testing.T) {
429429 require.NoError(t, err)
430430 require.True(t, acquired, "the test must hold the spawn lock")
431431 t.Cleanup(release)
432+ // The daemon being started takes lifecycle ownership at once and keeps it
433+ // for its whole run, as the real daemon does. Without it, a caller that
434+ // dialed just before the daemon was published could win the spawn lock
435+ // once it is released and elect itself.
436+ daemonOwner, err := daemonLifecycleProbe.TryAcquire(filepath.Dir(carriage))
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = daemonOwner.Release() })
432439
433440 // The target is resolved once: require.* is not goroutine-safe, and the
434441 // resolved target is immutable and shared by every caller.
@@ -429,6 +429,13 @@ func TestDaemonStartConcurrentRequestsShareOneElection(t *testing.T) {
429 require.NoError(t, err)429 require.NoError(t, err)
430 require.True(t, acquired, "the test must hold the spawn lock")430 require.True(t, acquired, "the test must hold the spawn lock")
431 t.Cleanup(release)431 t.Cleanup(release)
432+ // The daemon being started takes lifecycle ownership at once and keeps it
433+ // for its whole run, as the real daemon does. Without it, a caller that
434+ // dialed just before the daemon was published could win the spawn lock
435+ // once it is released and elect itself.
436+ daemonOwner, err := daemonLifecycleProbe.TryAcquire(filepath.Dir(carriage))
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = daemonOwner.Release() })
432 439
433 // The target is resolved once: require.* is not goroutine-safe, and the440 // The target is resolved once: require.* is not goroutine-safe, and the
434 // resolved target is immutable and shared by every caller.441 // resolved target is immutable and shared by every caller.
M internal/app/web_daemon_broker_test.go +3 -1
@@ -635,7 +635,9 @@ func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
635635 t.Fatalf("broker loss ended the browser run: %v", tab.readerErr())
636636 default:
637637 }
638- require.GreaterOrEqual(t, calls.Load(), int32(2), "a lost connection must be retried, never fatal")
638+ // A repaint can land before the first reconnect attempt, so wait for it.
639+ require.Eventually(t, func() bool { return calls.Load() >= 2 }, brokerTestWait, 5*time.Millisecond,
640+ "a lost connection must be retried, never fatal")
639641 }
640642
641643 // TestWebCompositionUsesTheBrokerConnector pins the production composition
@@ -635,7 +635,9 @@ func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
635 t.Fatalf("broker loss ended the browser run: %v", tab.readerErr())635 t.Fatalf("broker loss ended the browser run: %v", tab.readerErr())
636 default:636 default:
637 }637 }
638- require.GreaterOrEqual(t, calls.Load(), int32(2), "a lost connection must be retried, never fatal")638+ // A repaint can land before the first reconnect attempt, so wait for it.
639+ require.Eventually(t, func() bool { return calls.Load() >= 2 }, brokerTestWait, 5*time.Millisecond,
640+ "a lost connection must be retried, never fatal")
639 }641 }
640 642
641 // TestWebCompositionUsesTheBrokerConnector pins the production composition643 // TestWebCompositionUsesTheBrokerConnector pins the production composition

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close