1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
|
package handlers
// Regression tests for the server-mode output dmap deadlock: with direct output
// enabled, Aggregate.Start used to block until session teardown, so the
// map command never returned, the handler's active-command count never hit
// zero, and the session never shut down — the client hung forever after
// receiving all results. The bug survived because integration-test run mode
// force-disables output (internal/config/initializer.go), so this path was
// never exercised by the integration suite. These tests drive a real
// ServerHandler (real command dispatch, file reads, output aggregate,
// shutdown handshake) exactly like the SSH layer does, just without SSH.
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/mimecast/dtail/internal/config"
"github.com/mimecast/dtail/internal/io/dlog"
"github.com/mimecast/dtail/internal/lcontext"
"github.com/mimecast/dtail/internal/omode"
"github.com/mimecast/dtail/internal/protocol"
"github.com/mimecast/dtail/internal/session"
sshserver "github.com/mimecast/dtail/internal/ssh/server"
userserver "github.com/mimecast/dtail/internal/user/server"
)
const testStatsLine = "INFO|1002-071143|1|stats.go:56|8|15|7|0.21|471h0m21s|" +
"MAPREDUCE:STATS|currentConnections=0|lifetimeConnections=1"
// resetCommonLogger installs a quiet common logger for the duration of the
// test. The mapr serialization path logs via dlog.Common, which is nil unless
// a real logger was started; a zero-value DLog silently discards everything.
func resetCommonLogger(t *testing.T) {
t.Helper()
originalLogger := dlog.Common
dlog.Common = &dlog.DLog{}
t.Cleanup(func() {
dlog.Common = originalLogger
})
}
// newMapTestHandler builds a fully wired ServerHandler with direct output
// enabled, exactly as the SSH server would (via NewServerHandler). The user
// is the continuous-query user, which bypasses per-path permission checks so
// the test can read files from t.TempDir().
func newMapTestHandler(t *testing.T) *ServerHandler {
t.Helper()
resetServerLogger(t)
resetCommonLogger(t)
user := &userserver.User{Name: config.ContinuousUser}
serverCfg := &config.ServerConfig{
MapreduceLogFormat: "default",
AuthKeyEnabled: true,
}
return NewServerHandler(user, make(chan struct{}, 4), make(chan struct{}, 4),
serverCfg, sshserver.NewAuthKeyStore(time.Hour, 5))
}
// wrapHandlerCommandsForJoin wraps every registered command handler so the
// test can wait for all asynchronously dispatched command goroutines to run
// to full completion (including their completion callbacks, which log and may
// trigger the session shutdown). Without this join a late dlog call from a
// command goroutine would race with the test-logger restore in cleanup.
func wrapHandlerCommandsForJoin(handler *ServerHandler) *sync.WaitGroup {
wg := &sync.WaitGroup{}
for name, origHandler := range handler.commands {
origHandler := origHandler
handler.commands[name] = func(ctx context.Context, ltx lcontext.LContext,
argc int, args []string, commandFinished func()) {
wg.Add(1)
finished := func() {
defer wg.Done()
commandFinished()
}
origHandler(ctx, ltx, argc, args, finished)
}
}
return wg
}
// waitForCommandJoin waits until every dispatched command goroutine has fully
// finished; failing the test on timeout instead of leaking goroutines.
func waitForCommandJoin(t *testing.T, wg *sync.WaitGroup, timeout time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
select {
case <-done:
case <-time.After(timeout):
t.Fatal("timed out waiting for command goroutines to finish")
}
}
// encodeTestCommand wraps a command in the client wire framing
// (protocol version + base64 + ';' delimiter), mirroring the client-side
// SendMessage implementation.
func encodeTestCommand(command string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(command))
return fmt.Sprintf("protocol %s base64 %s;", protocol.ProtocolCompat, encoded)
}
// testOutput collects everything the handler sends to the "client".
type testOutput struct {
mu sync.Mutex
buf strings.Builder
}
func (o *testOutput) append(p []byte) string {
o.mu.Lock()
defer o.mu.Unlock()
o.buf.Write(p)
return o.buf.String()
}
func (o *testOutput) String() string {
o.mu.Lock()
defer o.mu.Unlock()
return o.buf.String()
}
func (o *testOutput) waitForContains(t *testing.T, substr string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for !strings.Contains(o.String(), substr) {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for output to contain %q; got: %q", substr, o.String())
}
time.Sleep(20 * time.Millisecond)
}
}
// startTestReader drains handler.Read like the SSH session output
// goroutine (io.Copy) does, collecting all output. When the server initiates
// the close handshake it replies with the client's close acknowledgement, so
// the shutdown sequence completes without waiting for the 5s ack timeout.
//
// writeMu stands in for the single SSH input goroutine of a real session:
// baseHandler.Write is not safe for concurrent use, so the test serializes
// its own command writes and the reader's ack write through this mutex.
func startTestReader(handler *ServerHandler, output *testOutput,
writeMu *sync.Mutex) <-chan struct{} {
readerDone := make(chan struct{})
var ackOnce sync.Once
go func() {
defer close(readerDone)
p := make([]byte, 4096)
for {
n, err := handler.Read(p)
if n > 0 {
all := output.append(p[:n])
if strings.Contains(all, ".syn close connection") {
ackOnce.Do(func() {
writeMu.Lock()
defer writeMu.Unlock()
_, _ = handler.Write([]byte(encodeTestCommand(".ack close connection")))
})
}
}
if err == io.EOF {
return
}
}
}()
return readerDone
}
func writeTestStatsFile(t *testing.T, lines int) string {
t.Helper()
path := filepath.Join(t.TempDir(), "stats.log")
var sb strings.Builder
for i := 0; i < lines; i++ {
sb.WriteString(testStatsLine)
sb.WriteByte('\n')
}
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
t.Fatalf("write stats file: %v", err)
}
return path
}
// TestServerModeMapCommandCompletesSession runs a non-interactive dmap
// workload (legacy "map" + "cat" command stream) against a output-enabled
// handler and asserts that the session terminates on its own and delivers
// the aggregated result. Before the fix the session hung forever: the map
// command stayed blocked in Aggregate.Start after all input had been
// read, keeping the active-command count nonzero.
func TestServerModeMapCommandCompletesSession(t *testing.T) {
handler := newMapTestHandler(t)
path := writeTestStatsFile(t, 25)
spec := session.Spec{
Mode: omode.MapClient,
Files: []string{path},
Query: "from STATS select count($time),$time group by $time",
Regex: ".",
}
commands, err := spec.Commands()
if err != nil {
t.Fatalf("build commands: %v", err)
}
commandWg := wrapHandlerCommandsForJoin(handler)
output := &testOutput{}
var writeMu sync.Mutex
readerDone := startTestReader(handler, output, &writeMu)
var frames strings.Builder
for _, command := range commands {
frames.WriteString(encodeTestCommand(command))
}
writeMu.Lock()
_, writeErr := handler.Write([]byte(frames.String()))
writeMu.Unlock()
if writeErr != nil {
t.Fatalf("write commands: %v", writeErr)
}
select {
case <-handler.Done():
case <-time.After(20 * time.Second):
t.Fatal("session did not shut down after one-shot output map input was exhausted " +
"(server-mode output dmap deadlock)")
}
select {
case <-readerDone:
case <-time.After(5 * time.Second):
t.Fatal("reader did not observe EOF after handler shutdown")
}
waitForCommandJoin(t, commandWg, 10*time.Second)
if !strings.Contains(output.String(), "count($time)≔25") {
t.Fatalf("expected aggregated result count($time)≔25 in output, got: %q", output.String())
}
}
// TestServerModeMapFollowSessionKeepsStreaming is the negative case for
// over-eager finalization: a continuous map query over a TAILED log never
// reaches input-exhausted, so the output aggregate must keep emitting
// interval results and the session must stay alive. The workload runs via
// SESSION START (the interactive-query bootstrap), matching how continuous
// queries are driven in practice.
func TestServerModeMapFollowSessionKeepsStreaming(t *testing.T) {
handler := newMapTestHandler(t)
path := writeTestStatsFile(t, 5)
spec := session.Spec{
Mode: omode.TailClient,
Files: []string{path},
Query: "from STATS select count($time),$time group by $time interval 1",
Regex: ".",
}
startCommand, err := spec.StartCommand()
if err != nil {
t.Fatalf("build session start command: %v", err)
}
commandWg := wrapHandlerCommandsForJoin(handler)
output := &testOutput{}
var writeMu sync.Mutex
readerDone := startTestReader(handler, output, &writeMu)
writeMu.Lock()
_, writeErr := handler.Write([]byte(encodeTestCommand(startCommand)))
writeMu.Unlock()
if writeErr != nil {
t.Fatalf("write session start: %v", writeErr)
}
output.waitForContains(t, sessionAckStartOKPrefix, 5*time.Second)
// Keep appending lines like a live log file; the tailed input must keep
// feeding the aggregate across serialization intervals.
feederStop := make(chan struct{})
feederDone := make(chan struct{})
go func() {
defer close(feederDone)
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer func() { _ = file.Close() }()
for {
select {
case <-feederStop:
return
case <-time.After(50 * time.Millisecond):
if _, err := file.WriteString(testStatsLine + "\n"); err != nil {
return
}
}
}
}()
// An interval-serialized interim aggregate result must arrive while the
// stream is live (the fix must not finish a follow-mode aggregate).
output.waitForContains(t, "count($time)≔", 15*time.Second)
// The session must still be running: tailed input never exhausts.
select {
case <-handler.Done():
t.Fatal("follow-mode output map session shut down prematurely (over-eager finalization)")
default:
}
close(feederStop)
<-feederDone
// Tear down like a disconnecting client and join all command goroutines
// so nothing outlives the test (the session keeps commands alive until
// their contexts are cancelled by the handler shutdown).
handler.done.Shutdown()
select {
case <-readerDone:
case <-time.After(5 * time.Second):
t.Fatal("reader did not observe EOF after handler shutdown")
}
waitForCommandJoin(t, commandWg, 10*time.Second)
if pending, active := handler.PendingAndActive(); pending != 0 || active != 0 {
t.Fatalf("handler did not quiesce: pending=%d active=%d", pending, active)
}
}
|