mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-07 11:14:28 +02:00
* feat(daemon): bound daemon.log size with rotation (MUL-4330) The background daemon redirected its stdout/stderr into daemon.log opened O_APPEND and never rotated it, so the file grew without limit until it was too large to open. Every structured log line already flows through slog (including agent subprocess stderr, forwarded via newLogWriter), so the daemon's logger is effectively the sole author of the file's volume. Route the foreground daemon's slog output — both the injected component logger and the package-global slog default — through a size-based rotating writer (lumberjack) that keeps the active daemon.log small (20MB default, 5 gzip-compressed backups, 30d), all env-overridable. Raw crash output (Go runtime panics, pre-logger errors) now goes to a separate daemon.err.log so the child's inherited fds never hold daemon.log open, which would block rotation's rename on Windows. The Desktop app spawns the daemon via this same launcher and its log tail already handles size-shrink, so both CLI and Desktop are covered. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): address log-rotation review — foreground output, Windows handles, bounded err log (MUL-4330) Resolves the blocking review items on the daemon.log rotation change: 1. Windows first-upgrade rotation: a foreground managed daemon now re-points its own stdout/stderr to daemon.err.log at startup (SetStdHandle) before building the rotator, releasing any daemon.log handle an older self-update launcher inherited (Go opens files without FILE_SHARE_DELETE, which would otherwise block rename-on-rotate). No-op on Unix, where an open fd never blocks rename. 2. `daemon logs -f` vs rotation: Unix uses `tail -F` (reopen by name); Windows opens the reader with FILE_SHARE_DELETE so it can't block the rotator's rename, and reopens the file on size-shrink to follow across rotation. 3. Self-update handoff no longer briefly runs two rotators on one file: the old process closes its rotator and moves remaining handoff logs (incl. the slog default) to the crash sink before the successor starts. 4. daemon.err.log is now bounded: it rolls to a single ".1" backup once past 5MB at open time, so a crash loop can't move the growth problem to it. It is also surfaced in the troubleshooting docs. 5. Explicit `--foreground` in a terminal keeps live stdout/stderr logging (a documented debugging path); only detached/background children rotate into daemon.log. Decided by whether stderr is a terminal. Also: rotation env knobs now reject 0/negative (0 means 100MB / keep-all in lumberjack), preventing an accidental unbounded config. Adds unit tests for the err-log rolling and positive-int parsing; Windows/Linux(arm64) cross-builds and `GOOS=windows go vet` pass. Co-authored-by: multica-agent <github@multica.ai> * docs: sync zh/ja/ko troubleshooting with daemon.log rotation + daemon.err.log (MUL-4330) Co-authored-by: multica-agent <github@multica.ai> * test(handler): bump the agent's own runtime version in quick-create parent test (MUL-4330) TestQuickCreateIssueParentTrustBoundary bumped an arbitrary `LIMIT 1` agent_runtime, but the handler version-checks agent.RuntimeID — the runtime bound to the request's agent. In the shared handler test workspace, other tests register additional runtimes, so the two diverge and the agent's real runtime keeps the seed's empty cli_version, tripping the daemon-version gate (422 daemon_version_unsupported) before the parent_issue_id assertions run. Bump the runtime tied to the agent instead, making the setup deterministic. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
206 lines
6.7 KiB
Go
206 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestEnvPositiveIntOrDefault locks in the parse-or-fallback behaviour the log
|
|
// rotation knobs rely on. Critically, an explicit 0 falls back to the default
|
|
// (not through to lumberjack, where 0 means 100MB / keep-everything), so the
|
|
// retention footguns can't be tripped from the environment.
|
|
func TestEnvPositiveIntOrDefault(t *testing.T) {
|
|
const key = "MULTICA_TEST_ENV_INT"
|
|
cases := []struct {
|
|
name string
|
|
set bool
|
|
val string
|
|
def int
|
|
want int
|
|
}{
|
|
{"unset", false, "", 20, 20},
|
|
{"blank", true, "", 20, 20},
|
|
{"whitespace", true, " ", 20, 20},
|
|
{"valid", true, "50", 20, 50},
|
|
{"zero_falls_back", true, "0", 20, 20},
|
|
{"negative", true, "-1", 20, 20},
|
|
{"malformed", true, "abc", 20, 20},
|
|
{"padded", true, " 7 ", 20, 7},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
os.Unsetenv(key)
|
|
if c.set {
|
|
t.Setenv(key, c.val)
|
|
}
|
|
if got := envPositiveIntOrDefault(key, c.def); got != c.want {
|
|
t.Errorf("envPositiveIntOrDefault(%q, %d) = %d, want %d", c.val, c.def, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenBoundedErrLogRolls verifies the raw crash sink is bounded: once it is
|
|
// at/over the cap, opening it rolls the old contents to a single ".1" backup
|
|
// and starts fresh, so a crash loop can't grow daemon.err.log without limit.
|
|
func TestOpenBoundedErrLogRolls(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "daemon.err.log")
|
|
|
|
// Seed an over-cap file.
|
|
big := make([]byte, errLogMaxBytes+1024)
|
|
if err := os.WriteFile(path, big, 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
f, err := openBoundedErrLog(path)
|
|
if err != nil {
|
|
t.Fatalf("openBoundedErrLog: %v", err)
|
|
}
|
|
f.Close()
|
|
|
|
if _, err := os.Stat(path + ".1"); err != nil {
|
|
t.Errorf("expected rolled backup %s.1: %v", path, err)
|
|
}
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat active: %v", err)
|
|
}
|
|
if fi.Size() != 0 {
|
|
t.Errorf("active err log = %d bytes after roll, want 0", fi.Size())
|
|
}
|
|
}
|
|
|
|
// TestOpenBoundedErrLogKeepsSmall confirms a below-cap file is appended to, not
|
|
// rolled — the common healthy-daemon case.
|
|
func TestOpenBoundedErrLogKeepsSmall(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "daemon.err.log")
|
|
if err := os.WriteFile(path, []byte("prior crash\n"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
f, err := openBoundedErrLog(path)
|
|
if err != nil {
|
|
t.Fatalf("openBoundedErrLog: %v", err)
|
|
}
|
|
f.WriteString("next line\n")
|
|
f.Close()
|
|
|
|
if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) {
|
|
t.Errorf("did not expect a rolled backup for a small file")
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
if !strings.Contains(string(data), "prior crash") || !strings.Contains(string(data), "next line") {
|
|
t.Errorf("expected append, got %q", data)
|
|
}
|
|
}
|
|
|
|
// TestNewDaemonLogRotatorDefaults asserts the rotator is wired to the intended
|
|
// policy when no env overrides are set: the configured path, the default
|
|
// size/backups/age, and gzip compression so rotated files stay small.
|
|
func TestNewDaemonLogRotatorDefaults(t *testing.T) {
|
|
os.Unsetenv("MULTICA_DAEMON_LOG_MAX_SIZE_MB")
|
|
os.Unsetenv("MULTICA_DAEMON_LOG_MAX_BACKUPS")
|
|
os.Unsetenv("MULTICA_DAEMON_LOG_MAX_AGE_DAYS")
|
|
|
|
path := filepath.Join(t.TempDir(), "daemon.log")
|
|
r := newDaemonLogRotator(path)
|
|
if r.Filename != path {
|
|
t.Errorf("Filename = %q, want %q", r.Filename, path)
|
|
}
|
|
if r.MaxSize != defaultDaemonLogMaxSizeMB {
|
|
t.Errorf("MaxSize = %d, want %d", r.MaxSize, defaultDaemonLogMaxSizeMB)
|
|
}
|
|
if r.MaxBackups != defaultDaemonLogMaxBackups {
|
|
t.Errorf("MaxBackups = %d, want %d", r.MaxBackups, defaultDaemonLogMaxBackups)
|
|
}
|
|
if r.MaxAge != defaultDaemonLogMaxAgeDays {
|
|
t.Errorf("MaxAge = %d, want %d", r.MaxAge, defaultDaemonLogMaxAgeDays)
|
|
}
|
|
if !r.Compress {
|
|
t.Error("Compress = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestNewDaemonLogRotatorEnvOverride confirms operators can tune retention via
|
|
// env without a rebuild.
|
|
func TestNewDaemonLogRotatorEnvOverride(t *testing.T) {
|
|
t.Setenv("MULTICA_DAEMON_LOG_MAX_SIZE_MB", "5")
|
|
t.Setenv("MULTICA_DAEMON_LOG_MAX_BACKUPS", "2")
|
|
t.Setenv("MULTICA_DAEMON_LOG_MAX_AGE_DAYS", "7")
|
|
|
|
r := newDaemonLogRotator(filepath.Join(t.TempDir(), "daemon.log"))
|
|
if r.MaxSize != 5 || r.MaxBackups != 2 || r.MaxAge != 7 {
|
|
t.Errorf("rotator = {MaxSize:%d MaxBackups:%d MaxAge:%d}, want {5 2 7}", r.MaxSize, r.MaxBackups, r.MaxAge)
|
|
}
|
|
}
|
|
|
|
// TestDaemonLogRotatorRotates is the end-to-end guarantee: once the active file
|
|
// crosses MaxSize the writer rotates instead of appending forever, so daemon.log
|
|
// stays bounded (the core of MUL-4330). Uses the 1 MB floor lumberjack enforces.
|
|
func TestDaemonLogRotatorRotates(t *testing.T) {
|
|
t.Setenv("MULTICA_DAEMON_LOG_MAX_SIZE_MB", "1")
|
|
t.Setenv("MULTICA_DAEMON_LOG_MAX_BACKUPS", "3")
|
|
// Disable compression here so the assertion on the active file size is not
|
|
// racing lumberjack's async gzip of the rotated file.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "daemon.log")
|
|
r := newDaemonLogRotator(path)
|
|
r.Compress = false
|
|
|
|
// Write ~1.5 MB in chunks; at least one rotation must happen.
|
|
line := strings.Repeat("x", 1024) + "\n"
|
|
for written := 0; written < 3*1024*1024; written += len(line) {
|
|
if _, err := r.Write([]byte(line)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
if err := r.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read dir: %v", err)
|
|
}
|
|
var logFiles int
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "daemon") && strings.Contains(e.Name(), ".log") {
|
|
logFiles++
|
|
}
|
|
}
|
|
if logFiles < 2 {
|
|
t.Fatalf("expected at least 2 log files after rotation, got %d (%v)", logFiles, entries)
|
|
}
|
|
|
|
// The active file must be bounded well under the total bytes written.
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat active log: %v", err)
|
|
}
|
|
if info.Size() > 2*1024*1024 {
|
|
t.Errorf("active daemon.log = %d bytes, expected bounded (<2MB)", info.Size())
|
|
}
|
|
}
|
|
|
|
// TestDaemonStderrLogPathIsSeparate guards the invariant that the raw crash
|
|
// sink is a distinct file from the rotating log, so the child's inherited fds
|
|
// never hold daemon.log open (which would block rotation's rename on Windows).
|
|
func TestDaemonStderrLogPathIsSeparate(t *testing.T) {
|
|
logPath := daemonLogPathForProfile("")
|
|
errPath := daemonStderrLogPathForProfile("")
|
|
if logPath == "" || errPath == "" {
|
|
t.Skip("profile dir unavailable in this environment")
|
|
}
|
|
if logPath == errPath {
|
|
t.Fatalf("stderr sink %q must differ from daemon.log %q", errPath, logPath)
|
|
}
|
|
if filepath.Base(errPath) != "daemon.err.log" {
|
|
t.Errorf("stderr sink base = %q, want daemon.err.log", filepath.Base(errPath))
|
|
}
|
|
if filepath.Dir(logPath) != filepath.Dir(errPath) {
|
|
t.Errorf("sinks should share a directory: %q vs %q", filepath.Dir(logPath), filepath.Dir(errPath))
|
|
}
|
|
}
|