logtail: count the message a hung sender check defers in cleanup (audit M13)

Seen on the e2e stand, the panel frozen with SIGSTOP once DATA was
answered: the client gets 451 4.7.1 after 30 s, and the warning that names
the socket is cleanup's, not smtpd's:

  postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock:
    can't read SMFIC_HEADER reply packet header: Connection timed out
  postfix/cleanup[1339]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE ...
    4.7.1 Service unavailable - try again later; ...

The counter read smtpd lines only, so Health's "messages deferred" missed
the one case that tells a hung panel from a stopped one. milterLineRe now
takes smtpd and cleanup; cleanup's "milter unix:<socket>:" warning counts a
message each time (one cleanup process serves message after message, and the
first warning ends the message); smtpd keeps once per connection. The
composed fixture is replaced by the seen lines, with two messages through one
cleanup process and OpenDKIM hung in cleanup (not counted).

e2e panel_hung_mid_message_is_counted: freezes the panel after DATA's 354,
wants a 4xx at the end of DATA, logs what mail.log says, and waits for
Health's today count to grow. kill through sh -c: the image has no kill(1).

CHANGELOG [Unreleased], audit plan line ticked, roadmap 13/61.
Evidence on the test server for this tree (with the next commit's docs):
go vet clean, go test ./... 23 packages ok, make e2e exit 0 -
renewed_certificate_picked_up 1.2 s, panel_hung_mid_message_is_counted
31.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:42:01 +03:00
co-authored by Claude Opus 5.5
parent 7af37654a1
commit db36aa2c0e
7 changed files with 185 additions and 30 deletions
+117
View File
@@ -1,8 +1,11 @@
package e2e
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"regexp"
"strconv"
@@ -187,3 +190,117 @@ func testPanelDownDefersMail(t *testing.T, sc *scenario) {
t.Fatal(err)
}
}
// senderCheckDeferredToday reads Server > Health's "Sender check" row for
// today: the messages deferred because the check did not answer.
func senderCheckDeferredToday(sc *scenario) (int, error) {
_, body, err := sc.panel.get("/server/health")
if err != nil {
return 0, err
}
m := regexp.MustCompile(`<tr><td>Today</td><td>([^<]*)</td><td>(\d+)</td></tr>`).FindStringSubmatch(body)
if m == nil {
return 0, fmt.Errorf("no Sender check row for today on Health")
}
return strconv.Atoi(m[2])
}
// testPanelHungMidMessage: a panel that is running and stops answering in the
// middle of a message - its socket still accepts, nothing replies. The
// message is deferred, and the count on Health sees it. Past the DATA
// command Postfix's cleanup, not smtpd, talks to the milters, so the warning
// that names the socket is cleanup's (audit 2026-10-12, M13: the counter read
// smtpd's lines only). The panel is frozen with SIGSTOP once DATA is
// answered, and thawed after the client has its reply.
func testPanelHungMidMessage(t *testing.T, sc *scenario) {
before, err := senderCheckDeferredToday(sc)
if err != nil {
t.Fatal(err)
}
pidOut, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "pid", "panel")
if err != nil {
t.Fatalf("panel pid: %v (%s)", err, pidOut)
}
pid := strings.TrimSpace(pidOut)
if _, err := strconv.Atoi(pid); err != nil {
t.Fatalf("panel pid: %q", pidOut)
}
signal := func(sig string) error {
// kill is the shell's: the image has no kill(1).
_, err := h.execIn("selfpost", "sh", "-c", "kill -"+sig+" "+pid)
return err
}
thawed := false
thaw := func() {
if !thawed {
thawed = true
_ = signal("CONT")
}
}
t.Cleanup(thaw)
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
t.Fatal(err)
}
_ = conn.SetDeadline(time.Now().Add(3 * time.Minute))
c, err := smtp.NewClient(conn, selfpostHostname)
if err != nil {
t.Fatal(err)
}
defer c.Close()
from := "alerts@" + senderDomain
if err := c.Hello("e2e-client.e2e.test"); err != nil {
t.Fatal(err)
}
if err := c.Auth(smtp.PlainAuth("", sc.appLogin, sc.appPassword, selfpostHostname)); err != nil {
t.Fatal(err)
}
if err := c.Mail(from); err != nil {
t.Fatal(err)
}
if err := c.Rcpt(recipient); err != nil {
t.Fatal(err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
if err := signal("STOP"); err != nil {
t.Fatalf("freeze the panel: %v", err)
}
started := time.Now()
_, err = w.Write([]byte(buildMessage(sendAttempt{from: from, to: recipient, subject: "e2e " + uniqueToken("panelhung"), body: "must be deferred"})))
if err == nil {
err = w.Close()
}
t.Logf("end of DATA answered after %s: %v", time.Since(started).Round(time.Second), err)
thaw()
var reply *textproto.Error
if !errors.As(err, &reply) || reply.Code/100 != 4 {
t.Fatalf("want a temporary 4xx at the end of DATA with the panel frozen, got %v", err)
}
_ = c.Quit()
// What Postfix wrote, so that the program and the wording are seen here
// and not assumed.
if out, err := h.execIn("selfpost", "sh", "-c", "grep -i 'milter' /data/log/mail.log | tail -n 8"); err == nil {
t.Logf("mail.log on the frozen panel:\n%s", out)
}
if err := waitFor("Health to count the message deferred mid-message", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
n, err := senderCheckDeferredToday(sc)
if err != nil {
return false, err
}
if n <= before {
return false, fmt.Errorf("messages deferred today: %d, as before", n)
}
return true, nil
}); err != nil {
t.Fatal(err)
}
}