activator: logListener.Close shuts the accepted connection and waits for the tee (plan 3, task 3 fix round 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+92
−7
internal/activator/loglistener.go+33 −7internal/activator/loglistener_test.go+59 −0
diff --git a/internal/activator/loglistener.go b/internal/activator/loglistener.go| index 81c1eaf40..5d9bc6543 100644 |
| --- a/internal/activator/loglistener.go |
| +++ b/internal/activator/loglistener.go |
| @@ -1,7 +1,6 @@ |
| package activator |
| import ( |
| - "bufio" |
| "io" |
| "net" |
| "os" |
| @@ -16,15 +15,21 @@ import ( |
| // unit's StandardOutput=file: connects to this AF_UNIX socket once, at |
| // exec, and every line is teed to the agent's own stdout (journald) and to |
| // the app log sink, exactly as teeStd does for an exec child. One |
| -// connection per invocation is accepted; a second is closed unread. EOF |
| -// ends log collection and nothing else: it is never a sign the process is |
| -// gone (codex R1-4). |
| +// connection per listener is accepted; a second is closed unread. The |
| +// accepted flag never resets by design: a listener belongs to one |
| +// invocation, and a re-exec of the unit gets a fresh listener from |
| +// prepare. EOF ends log collection and nothing else: it is never a sign |
| +// the process is gone (codex R1-4). |
| type logListener struct { |
| ln net.Listener |
| emit func(string) |
| accepted atomic.Bool |
| closed atomic.Bool |
| - wg sync.WaitGroup |
| + // conn is the one accepted connection, kept so Close can shut it and |
| + // unblock the tee; wg covers the accept loop and the tee. |
| + mu sync.Mutex |
| + conn net.Conn |
| + wg sync.WaitGroup |
| } |
| func listenLogs(path string, emit func(string)) (*logListener, error) { |
| @@ -49,29 +54,50 @@ func (l *logListener) accept() { |
| c.Close() |
| continue |
| } |
| + l.mu.Lock() |
| + if l.closed.Load() { |
| + // Close ran between Accept and here; nothing may outlive it. |
| + l.mu.Unlock() |
| + c.Close() |
| + continue |
| + } |
| + l.conn = c |
| + l.wg.Add(1) |
| + l.mu.Unlock() |
| go l.tee(c) |
| } |
| } |
| func (l *logListener) tee(c net.Conn) { |
| + defer l.wg.Done() |
| defer c.Close() |
| var sink io.Writer = os.Stdout |
| if l.emit != nil { |
| sink = io.MultiWriter(os.Stdout, applog.NewLineWriter(execLogLineMax, l.emit)) |
| } |
| - r := bufio.NewReaderSize(c, execLogLineMax) |
| - _, _ = io.Copy(sink, r) |
| + // applog.LineWriter owns line assembly and the length cap, so no |
| + // buffering layer is needed between the connection and the sink. |
| + _, _ = io.Copy(sink, c) |
| } |
| // Exited always reports false: a log listener knows nothing about process |
| // state, and this method exists so a caller cannot mistake it for one. |
| func (l *logListener) Exited() bool { return false } |
| +// Close stops accepting, shuts the accepted connection so a blocked tee |
| +// returns, and waits for both goroutines. Nothing spawned by the listener |
| +// outlives Close: the process it belonged to is gone or being replaced, |
| +// and a late line must not reach the sink under the old invocation. |
| func (l *logListener) Close() error { |
| if l.closed.Swap(true) { |
| return nil |
| } |
| err := l.ln.Close() |
| + l.mu.Lock() |
| + if l.conn != nil { |
| + l.conn.Close() |
| + } |
| + l.mu.Unlock() |
| l.wg.Wait() |
| return err |
| } |
diff --git a/internal/activator/loglistener_test.go b/internal/activator/loglistener_test.go| index f1c25e73e..e9e1a7e4f 100644 |
| --- a/internal/activator/loglistener_test.go |
| +++ b/internal/activator/loglistener_test.go |
| @@ -63,3 +63,62 @@ func TestLogListenerTeesOneConnectionAndRefusesASecond(t *testing.T) { |
| t.Error("EOF on the log connection must not mark the process exited") |
| } |
| } |
| + |
| +// Close must shut the accepted connection and wait for the tee: a line |
| +// written after Close returns must not reach the sink, and the tee |
| +// goroutine must not outlive the listener (task 3 review, item 1). |
| +func TestLogListenerCloseShutsTheConnectionAndWaitsForTheTee(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "ll") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + path := filepath.Join(dir, "h.run.sock") |
| + var mu sync.Mutex |
| + var lines []string |
| + l, err := listenLogs(path, func(s string) { mu.Lock(); lines = append(lines, s); mu.Unlock() }) |
| + if err != nil { |
| + t.Skipf("cannot bind here (%v); run with TMPDIR=/tmp/g outside the sandbox", err) |
| + } |
| + c, err := net.Dial("unix", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer c.Close() |
| + if _, err := c.Write([]byte("before\n")); err != nil { |
| + t.Fatal(err) |
| + } |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + mu.Lock() |
| + n := len(lines) |
| + mu.Unlock() |
| + if n == 1 || time.Now().After(deadline) { |
| + break |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + done := make(chan error, 1) |
| + go func() { done <- l.Close() }() |
| + select { |
| + case err := <-done: |
| + if err != nil { |
| + t.Fatalf("Close: %v", err) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("Close blocked on the open connection instead of shutting it") |
| + } |
| + // The peer sees its end closed and a late line goes nowhere. |
| + c.SetReadDeadline(time.Now().Add(time.Second)) |
| + if _, err := c.Read(make([]byte, 1)); err == nil { |
| + t.Fatal("accepted connection still open after Close") |
| + } |
| + _, _ = c.Write([]byte("after\n")) |
| + time.Sleep(50 * time.Millisecond) |
| + mu.Lock() |
| + got := strings.Join(lines, "|") |
| + mu.Unlock() |
| + if got != "before" { |
| + t.Fatalf("lines after Close = %q, want only the line written before it", got) |
| + } |
| +} |