Skip to content

Commit 6cbdd3a

Browse files
test: deterministic debounce test via synctest (#238)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ec1acf commit 6cbdd3a

1 file changed

Lines changed: 89 additions & 203 deletions

File tree

Lines changed: 89 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -1,218 +1,104 @@
11
package generator
22

33
import (
4-
"bufio"
5-
"encoding/json"
6-
"fmt"
74
"io"
85
"log"
9-
"net/http"
10-
"os"
11-
"strings"
12-
"sync/atomic"
136
"testing"
7+
"testing/synctest"
148
"time"
159

1610
docker "github.com/fsouza/go-dockerclient"
17-
dockertest "github.com/fsouza/go-dockerclient/testing"
1811
"github.com/nginx-proxy/docker-gen/internal/config"
19-
"github.com/nginx-proxy/docker-gen/internal/context"
20-
"github.com/nginx-proxy/docker-gen/internal/dockerclient"
12+
"github.com/stretchr/testify/assert"
2113
)
2214

23-
func TestGenerateFromEvents(t *testing.T) {
15+
func newStartEvent() *docker.APIEvents {
16+
return &docker.APIEvents{Type: "container", Action: "start"}
17+
}
18+
19+
// TestNewDebounceChannel deterministically verifies debounce timing via testing/synctest's fake clock (replaces the flaky TestGenerateFromEvents, #238).
20+
func TestNewDebounceChannel(t *testing.T) {
21+
orig := log.Writer()
2422
log.SetOutput(io.Discard)
25-
containerID := "8dfafdbc3a40"
26-
var counter atomic.Int32
27-
28-
eventsResponse := `
29-
{"Type":"container","Action":"start","Actor": {"ID":"8dfafdbc3a40"},"Time":1374067924}
30-
{"Type":"container","Action":"stop","Actor": {"ID":"8dfafdbc3a40"},"Time":1374067966}
31-
{"Type":"container","Action":"start","Actor": {"ID":"8dfafdbc3a40"},"Time":1374067970}`
32-
infoResponse := `{"Containers":1,"Images":1,"Debug":false,"NFd":11,"NGoroutines":21,"MemoryLimit":true,"SwapLimit":false}`
33-
versionResponse := `{"Version":"19.03.12","Os":"Linux","KernelVersion":"4.19.76-linuxkit","GoVersion":"go1.13.14","GitCommit":"48a66213fe","Arch":"amd64","ApiVersion":"1.40"}`
34-
35-
server, _ := dockertest.NewServer("127.0.0.1:0", nil, nil)
36-
server.CustomHandler("/events", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37-
rsc := bufio.NewScanner(strings.NewReader(eventsResponse))
38-
for rsc.Scan() {
39-
w.Write([]byte(rsc.Text()))
40-
w.(http.Flusher).Flush()
23+
t.Cleanup(func() { log.SetOutput(orig) })
24+
25+
t.Run("passes events through when Min is zero", func(t *testing.T) {
26+
synctest.Test(t, func(t *testing.T) {
27+
input := make(chan *docker.APIEvents, 1)
28+
out := newDebounceChannel(input, &config.Wait{Min: 0, Max: 0})
29+
30+
ev := newStartEvent()
31+
input <- ev
32+
synctest.Wait()
33+
34+
select {
35+
case got := <-out:
36+
assert.Same(t, ev, got)
37+
default:
38+
t.Fatal("expected the event to pass straight through")
39+
}
40+
})
41+
})
42+
43+
t.Run("coalesces a burst and fires Min after the last event", func(t *testing.T) {
44+
synctest.Test(t, func(t *testing.T) {
45+
input := make(chan *docker.APIEvents)
46+
out := newDebounceChannel(input, &config.Wait{Min: 200 * time.Millisecond, Max: time.Second})
47+
48+
start := time.Now()
49+
var fires []time.Duration
50+
done := make(chan struct{})
51+
go func() {
52+
for range out {
53+
fires = append(fires, time.Since(start))
54+
}
55+
close(done)
56+
}()
57+
58+
input <- newStartEvent() // t=0
59+
time.Sleep(150 * time.Millisecond)
60+
input <- newStartEvent() // t=150ms (gap 150ms < Min)
4161
time.Sleep(150 * time.Millisecond)
42-
}
43-
time.Sleep(500 * time.Millisecond)
44-
}))
45-
server.CustomHandler("/info", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46-
w.Write([]byte(infoResponse))
47-
w.(http.Flusher).Flush()
48-
}))
49-
server.CustomHandler("/version", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50-
w.Write([]byte(versionResponse))
51-
w.(http.Flusher).Flush()
52-
}))
53-
server.CustomHandler("/containers/json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54-
result := []docker.APIContainers{
55-
{
56-
ID: containerID,
57-
Image: "base:latest",
58-
Command: "/bin/sh",
59-
Created: time.Now().Unix(),
60-
Status: "running",
61-
Ports: []docker.APIPort{},
62-
Names: []string{"/docker-gen-test"},
63-
},
64-
}
65-
w.Header().Set("Content-Type", "application/json")
66-
w.WriteHeader(http.StatusOK)
67-
json.NewEncoder(w).Encode(result)
68-
}))
69-
server.CustomHandler(fmt.Sprintf("/containers/%s/json", containerID), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70-
counter := counter.Add(1)
71-
container := docker.Container{
72-
Name: "docker-gen-test",
73-
ID: containerID,
74-
Created: time.Now(),
75-
Path: "/bin/sh",
76-
Args: []string{},
77-
Config: &docker.Config{
78-
Hostname: "docker-gen",
79-
AttachStdout: true,
80-
AttachStderr: true,
81-
Env: []string{fmt.Sprintf("COUNTER=%d", counter)},
82-
Cmd: []string{"/bin/sh"},
83-
Image: "base:latest",
84-
},
85-
HostConfig: &docker.HostConfig{
86-
NetworkMode: "container:d246e2c9e3d465d96359c942e91de493f6d51a01ba33900d865180d64c34ee91",
87-
},
88-
State: docker.State{
89-
Running: true,
90-
Pid: 400,
91-
ExitCode: 0,
92-
StartedAt: time.Now(),
93-
Health: docker.Health{
94-
Status: "healthy",
95-
FailingStreak: 5,
96-
Log: []docker.HealthCheck{},
97-
},
98-
},
99-
Image: "0ff407d5a7d9ed36acdf3e75de8cc127afecc9af234d05486be2981cdc01a38d",
100-
NetworkSettings: &docker.NetworkSettings{
101-
IPAddress: "10.0.0.10",
102-
IPPrefixLen: 24,
103-
Gateway: "10.0.0.1",
104-
Bridge: "docker0",
105-
PortMapping: map[string]docker.PortMapping{},
106-
Ports: map[docker.Port][]docker.PortBinding{},
107-
},
108-
ResolvConfPath: "/etc/resolv.conf",
109-
}
110-
w.Header().Set("Content-Type", "application/json")
111-
w.WriteHeader(http.StatusOK)
112-
json.NewEncoder(w).Encode(container)
113-
}))
114-
115-
serverURL := fmt.Sprintf("tcp://%s", strings.TrimRight(strings.TrimPrefix(server.URL(), "http://"), "/"))
116-
client, err := dockerclient.NewDockerClient(serverURL, false, "", "", "")
117-
if err != nil {
118-
t.Errorf("Failed to create client: %s", err)
119-
}
120-
client.SkipServerVersionCheck = true
121-
122-
tmplFile, err := os.CreateTemp(os.TempDir(), "docker-gen-tmpl")
123-
if err != nil {
124-
t.Errorf("Failed to create temp file: %v\n", err)
125-
}
126-
defer func() {
127-
tmplFile.Close()
128-
os.Remove(tmplFile.Name())
129-
}()
130-
err = os.WriteFile(tmplFile.Name(), []byte("{{range $key, $value := .}}{{$value.ID}}.{{$value.Env.COUNTER}}{{end}}"), 0644)
131-
if err != nil {
132-
t.Errorf("Failed to write to temp file: %v\n", err)
133-
}
134-
135-
var destFiles []*os.File
136-
for i := 0; i < 4; i++ {
137-
destFile, err := os.CreateTemp(os.TempDir(), "docker-gen-out")
138-
if err != nil {
139-
t.Errorf("Failed to create temp file: %v\n", err)
140-
}
141-
destFiles = append(destFiles, destFile)
142-
}
143-
defer func() {
144-
for _, destFile := range destFiles {
145-
destFile.Close()
146-
os.Remove(destFile.Name())
147-
}
148-
}()
149-
150-
apiVersion, err := client.Version()
151-
if err != nil {
152-
t.Errorf("Failed to retrieve docker server version info: %v\n", err)
153-
}
154-
context.SetDockerEnv(apiVersion) // prevents a panic
155-
156-
generator := &generator{
157-
Client: client,
158-
Endpoint: serverURL,
159-
Configs: config.ConfigFile{
160-
Config: []config.Config{
161-
{
162-
Template: tmplFile.Name(),
163-
Dest: destFiles[0].Name(),
164-
Watch: false,
165-
},
166-
{
167-
Template: tmplFile.Name(),
168-
Dest: destFiles[1].Name(),
169-
Watch: true,
170-
Wait: &config.Wait{Min: 0, Max: 0},
171-
},
172-
{
173-
Template: tmplFile.Name(),
174-
Dest: destFiles[2].Name(),
175-
Watch: true,
176-
Wait: &config.Wait{Min: 200 * time.Millisecond, Max: 250 * time.Millisecond},
177-
},
178-
{
179-
Template: tmplFile.Name(),
180-
Dest: destFiles[3].Name(),
181-
Watch: true,
182-
Wait: &config.Wait{Min: 250 * time.Millisecond, Max: 1 * time.Second},
183-
},
184-
},
185-
},
186-
retry: false,
187-
}
188-
189-
generator.generateFromEvents()
190-
generator.wg.Wait()
191-
192-
var (
193-
value []byte
194-
expected string
195-
)
196-
197-
// The counter is incremented in each output file in the following sequence:
198-
//
199-
// init 150ms 200ms 250ms 300ms 350ms 400ms 450ms 500ms 550ms 600ms 650ms 700ms
200-
// ├──────╫──────┼──────┼──────╫──────┼──────┼──────╫──────┼──────┼──────┼──────┼──────┤
201-
// File0 ├─ 1 ║ ║ ║
202-
// File1 ├─ 2 ╟─ 5 ╟─ 6 ╟─ 8
203-
// File2 ├─ 3 ╟───── max (250ms) ──║───────────> 7 ╟─────── min (200ms) ─────> 9
204-
// File3 └─ 4 ╟──────────────────> ╟──────────────────> ╟─────────── min (250ms) ────────> 10
205-
// ┌───╨───┐ ┌───╨──┐ ┌───╨───┐
206-
// │ start │ │ stop │ │ start │
207-
// └───────┘ └──────┘ └───────┘
208-
209-
expectedCounters := []int{1, 8, 9, 10}
210-
211-
for i, counter := range expectedCounters {
212-
value, _ = os.ReadFile(destFiles[i].Name())
213-
expected = fmt.Sprintf("%s.%d", containerID, counter)
214-
if string(value) != expected {
215-
t.Errorf("expected: %s. got: %s", expected, value)
216-
}
217-
}
62+
input <- newStartEvent() // t=300ms (gap 150ms < Min)
63+
time.Sleep(time.Second) // advance the fake clock so the pending timer fires
64+
synctest.Wait()
65+
66+
close(input)
67+
<-done
68+
69+
// One coalesced event, fired Min (200ms) after the last event (t=300ms).
70+
assert.Equal(t, []time.Duration{500 * time.Millisecond}, fires)
71+
})
72+
})
73+
74+
t.Run("Max caps the wait when events keep arriving", func(t *testing.T) {
75+
synctest.Test(t, func(t *testing.T) {
76+
input := make(chan *docker.APIEvents)
77+
out := newDebounceChannel(input, &config.Wait{Min: 200 * time.Millisecond, Max: 250 * time.Millisecond})
78+
79+
start := time.Now()
80+
var fires []time.Duration
81+
done := make(chan struct{})
82+
go func() {
83+
for range out {
84+
fires = append(fires, time.Since(start))
85+
}
86+
close(done)
87+
}()
88+
89+
input <- newStartEvent() // t=0: minTimer->200ms, maxTimer->250ms
90+
time.Sleep(150 * time.Millisecond)
91+
input <- newStartEvent() // t=150ms: minTimer reset->350ms, maxTimer still 250ms
92+
time.Sleep(150 * time.Millisecond) // maxTimer fires at 250ms -> first output
93+
input <- newStartEvent() // t=300ms: new burst, minTimer->500ms
94+
time.Sleep(time.Second) // advance the fake clock so the pending timer fires (500ms)
95+
synctest.Wait()
96+
97+
close(input)
98+
<-done
99+
100+
// First output capped by Max at 250ms; second is Min after the t=300ms event.
101+
assert.Equal(t, []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, fires)
102+
})
103+
})
218104
}

0 commit comments

Comments
 (0)