diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2fffdbba78..25a4117a9a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2772,12 +2772,14 @@ mock に頼らないリリース前検証として、`install.sh --reinstall-rea `XDG_CACHE_HOME/cdidx/logs/`、`XDG_RUNTIME_DIR/cdidx/logs/` の順に 見つかった場所を使い、その後に platform default として Windows では `%LOCALAPPDATA%\cdidx\logs\`、macOS では `~/Library/Logs/cdidx/`、 -Linux では `~/.local/state/cdidx/logs/` を使う。ファイル名は `stderr-YYYYMMDD.log`。 +Linux では `~/.local/state/cdidx/logs/` を使う。ファイル名はプロセス ID と +開始時刻を含む `stderr-YYYYMMDD-p-HHMMSS.log`。 `CDIDX_LOG_FORMAT=json` または `--log-format json` で 1 行 1 JSON object (`ts`、`level`、`msg`)の JSONL に切り替えられる。`CDIDX_LOG_RETAIN` / `--log-retain-count` は保持ファイル数、`CDIDX_LOG_MAX_SIZE_MB` / -`--log-max-size-mb` は日次ファイルのサイズローテーション上限を指定する。 -保持世代の既定は新しい 30 ファイルまで。通常の開発/テストサイクルで +`--log-max-size-mb` または `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` は日次ファイルの +サイズローテーション上限を指定する。サイズ上限の既定は 50 MiB、保持世代の +既定は新しい 30 ファイルまで。通常の開発/テストサイクルで ワークツリー直下に永続ログが増えないよう、`src/CodeIndex/bin/...` と `tests/.../bin/...` からのリポジトリ内開発実行は既定で対象外として いる。完全に無効化したい場合は `CDIDX_DISABLE_PERSISTENT_LOG=1`、 diff --git a/README.md b/README.md index 6eb41c2008..7ed2bc628d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Output controls: | Need | Option | |---|---| -| Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date-stamped log files. Use `--log-format text|json`, `--log-retain-count `, `--log-max-size-mb `, or the matching `CDIDX_LOG_*` environment variables to make lifecycle logs JSONL-friendly and rotate them for aggregation. | +| Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date/process-stamped log files. Use `--log-format text|json`, `--log-retain-count `, `--log-max-size-mb `, `CDIDX_LOG_*`, or `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` to make lifecycle logs JSONL-friendly and rotate them for aggregation. | | Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file and `cdidx config show` to inspect precedence. | | Workspaces | Use `cdidx.workspace.json` or `.cdidx-workspace.json` to declare monorepo members, `cdidx workspace list` to inspect them, and `cdidx workspace use ` / `cdidx workspace current` for a persisted active workspace. | | ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f8dd2c5f72..ceafe5692f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -563,9 +563,12 @@ hosts still leave traces. Local development runs from the repository's `src/CodeIndex/bin/...` or `tests/.../bin/...` outputs are excluded by default. Default locations are `%LOCALAPPDATA%\cdidx\logs\` on Windows, `~/Library/Logs/cdidx/` on macOS, and `$XDG_STATE_HOME/cdidx/logs/` (or -`~/.local/state/cdidx/logs/`) on Linux. Logs rotate daily and keep the newest -30 files. Set `CDIDX_DISABLE_PERSISTENT_LOG=1` to opt out. The opt-out -toggle accepts `1`, `true`, `yes`, or `on` case-insensitively. +`~/.local/state/cdidx/logs/`) on Linux. Logs use per-process filenames, +rotate daily, rotate again when a file reaches 50 MiB by default, and keep the +newest 30 files. Set `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` or +`--log-max-size-mb` to tune the size cap. Set +`CDIDX_DISABLE_PERSISTENT_LOG=1` to opt out. The opt-out toggle accepts `1`, +`true`, `yes`, or `on` case-insensitively. Developer and packaging smoke tests can force this lifecycle log on with `CDIDX_FORCE_GLOBAL_TOOL_LOG=1` even from repository-local build paths; use it only for troubleshooting and keep `CDIDX_DISABLE_PERSISTENT_LOG` as the higher @@ -1582,8 +1585,11 @@ hosts still leave traces. Local development runs from the repository's `src/CodeIndex/bin/...` or `tests/.../bin/...` outputs are excluded by default. Default locations are `%LOCALAPPDATA%\cdidx\logs\` on Windows, `~/Library/Logs/cdidx/` on macOS, and `$XDG_STATE_HOME/cdidx/logs/` (or -`~/.local/state/cdidx/logs/`) on Linux. Logs rotate daily and keep the newest -30 files. Set `CDIDX_DISABLE_PERSISTENT_LOG=1` to opt out. +`~/.local/state/cdidx/logs/`) on Linux. Logs use per-process filenames, +rotate daily, rotate again when a file reaches 50 MiB by default, and keep the +newest 30 files. Set `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` or +`--log-max-size-mb` to tune the size cap. Set +`CDIDX_DISABLE_PERSISTENT_LOG=1` to opt out. **If already installed**, reinstall or switch to a specific version explicitly: diff --git a/changelog.d/unreleased/1619.fixed.md b/changelog.d/unreleased/1619.fixed.md new file mode 100644 index 0000000000..874814371f --- /dev/null +++ b/changelog.d/unreleased/1619.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1619 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs +--- + +## English + +- **Global tool logs are now per process (#1619)** - persistent stderr log files now include the process ID and start time in their filename, preventing concurrent `cdidx` processes from appending to the same daily file. + +## 日本語 + +- **グローバルツールログをプロセス単位に分離しました (#1619)** - 永続 stderr ログファイル名にプロセス ID と開始時刻を含め、複数の `cdidx` プロセスが同じ日次ファイルへ同時追記しないようにしました。 diff --git a/changelog.d/unreleased/1625.fixed.md b/changelog.d/unreleased/1625.fixed.md new file mode 100644 index 0000000000..4c2a80c732 --- /dev/null +++ b/changelog.d/unreleased/1625.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1625 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - README.md + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Global tool logs now have a default size cap (#1625)** - persistent stderr logs rotate at 50 MiB by default, and operators can tune the byte cap with `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` while retaining the existing `--log-max-size-mb` option. + +## 日本語 + +- **グローバルツールログに既定のサイズ上限を追加しました (#1625)** - 永続 stderr ログは既定で 50 MiB に達するとローテーションし、既存の `--log-max-size-mb` に加えて `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` でバイト単位の上限を調整できます。 diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 74df7f8a56..23d355d9d5 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -17,6 +17,8 @@ internal static class GlobalToolLog internal const string LogFormatEnvironmentVariable = "CDIDX_LOG_FORMAT"; internal const string LogRetainEnvironmentVariable = "CDIDX_LOG_RETAIN"; internal const string LogMaxSizeMbEnvironmentVariable = "CDIDX_LOG_MAX_SIZE_MB"; + internal const string GlobalToolLogMaxBytesEnvironmentVariable = "CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES"; + private const long DefaultLogMaxSizeBytes = 50L * 1024L * 1024L; private const string RedactedValue = ""; internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; private static readonly AsyncLocal CurrentSession = new(); @@ -379,18 +381,25 @@ private static string GetHomeDirectoryOrOriginal(string original) private static string ResolveLogPath(string logDirectory, LogOptions options) { var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); + var processSuffix = CreateProcessLogSuffix(); if (options.MaxSizeBytes <= 0) - return Path.Combine(logDirectory, $"stderr-{date}.log"); + return Path.Combine(logDirectory, $"stderr-{date}-{processSuffix}.log"); for (var index = 0; index < 10_000; index++) { var suffix = index == 0 ? "" : $"-{index}"; - var candidate = Path.Combine(logDirectory, $"stderr-{date}{suffix}.log"); + var candidate = Path.Combine(logDirectory, $"stderr-{date}-{processSuffix}{suffix}.log"); if (!File.Exists(candidate) || new FileInfo(candidate).Length < options.MaxSizeBytes) return candidate; } - return Path.Combine(logDirectory, $"stderr-{date}-{Guid.NewGuid():N}.log"); + return Path.Combine(logDirectory, $"stderr-{date}-{processSuffix}-{Guid.NewGuid():N}.log"); + } + + private static string CreateProcessLogSuffix() + { + var startTime = TimeProvider.GetUtcNow().UtcDateTime.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + return FormattableString.Invariant($"p{Environment.ProcessId}-{startTime}"); } private static void PruneOldLogs(string logDirectory, int retainedLogFileCount) @@ -625,9 +634,11 @@ public static LogOptions FromEnvironment() if (int.TryParse(Environment.GetEnvironmentVariable(LogRetainEnvironmentVariable), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedRetain)) retainCount = Math.Clamp(parsedRetain, 1, 10_000); - long maxSizeBytes = 0; + var maxSizeBytes = DefaultLogMaxSizeBytes; if (int.TryParse(Environment.GetEnvironmentVariable(LogMaxSizeMbEnvironmentVariable), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedMb) && parsedMb > 0) maxSizeBytes = parsedMb * 1024L * 1024L; + else if (long.TryParse(Environment.GetEnvironmentVariable(GlobalToolLogMaxBytesEnvironmentVariable), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedBytes) && parsedBytes > 0) + maxSizeBytes = parsedBytes; return new LogOptions(format, retainCount, maxSizeBytes); } diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index 605db2adc3..5d4a689976 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -104,7 +104,10 @@ public void TryStart_WritesInvariantUtcTimestampAndStackTrace() Assert.Equal(CommandExitCodes.UnhandledException, exitCode); Assert.Contains("Run `cdidx report`", stderr); - var logPath = Path.Combine(logRoot, $"stderr-{DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}.log"); + var logPath = Assert.Single(Directory.GetFiles(logRoot, "stderr-*.log")); + Assert.Matches( + $@"^stderr-{DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}-p\d+-\d{{6}}\.log$", + Path.GetFileName(logPath)); var log = File.ReadAllText(logPath); Assert.Matches(new Regex(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[INFO\] session_start", RegexOptions.Multiline), log); Assert.Contains("unhandled_exception", log); diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 8458533918..a3600b0c3f 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Mcp; @@ -545,7 +546,8 @@ public void Run_ForcedGlobalToolLogging_OnUnix_HardensExistingAndCurrentLogFiles var expectedMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; Assert.Equal(expectedMode, File.GetUnixFileMode(oldLogPath)); - var currentLogPath = Directory.GetFiles(logDir, $"stderr-{DateTime.UtcNow:yyyyMMdd}.log", SearchOption.TopDirectoryOnly).Single(); + var currentLogPath = Directory.GetFiles(logDir, "stderr-*.log", SearchOption.TopDirectoryOnly) + .Single(path => Regex.IsMatch(Path.GetFileName(path), $@"^stderr-{DateTime.UtcNow:yyyyMMdd}-p\d+-\d{{6}}\.log$")); Assert.Equal(expectedMode, File.GetUnixFileMode(currentLogPath)); } finally @@ -631,7 +633,7 @@ public void Run_ForcedGlobalToolLogging_PrunesToThirtyDailyFiles() Assert.Equal(30, logs.Count); Assert.DoesNotContain("stderr-20240101.log", logs); Assert.DoesNotContain("stderr-20240105.log", logs); - Assert.Contains($"stderr-{DateTime.UtcNow:yyyyMMdd}.log", logs); + Assert.Contains(logs, name => Regex.IsMatch(name ?? string.Empty, $@"^stderr-{DateTime.UtcNow:yyyyMMdd}-p\d+-\d{{6}}\.log$")); } finally { @@ -650,10 +652,13 @@ public void Run_ForcedGlobalToolLogging_HonorsRetainCountAndSizeRotation() "CDIDX_GLOBAL_TOOL_LOG_DIR", GlobalToolLog.LogFormatEnvironmentVariable, GlobalToolLog.LogRetainEnvironmentVariable, - GlobalToolLog.LogMaxSizeMbEnvironmentVariable); + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); try { + var fixedNow = new DateTimeOffset(2026, 5, 31, 12, 34, 56, TimeSpan.Zero); + GlobalToolLog.TimeProvider = new ManualTimeProvider(fixedNow); for (var i = 0; i < 4; i++) { var path = Path.Combine(logDir, $"stderr-2024010{i + 1}.log"); @@ -661,7 +666,7 @@ public void Run_ForcedGlobalToolLogging_HonorsRetainCountAndSizeRotation() File.SetLastWriteTimeUtc(path, new DateTime(2024, 1, i + 1, 0, 0, 0, DateTimeKind.Utc)); } - var currentPath = Path.Combine(logDir, $"stderr-{DateTime.UtcNow:yyyyMMdd}.log"); + var currentPath = Path.Combine(logDir, $"stderr-{fixedNow:yyyyMMdd}-p{Environment.ProcessId}-{fixedNow:HHmmss}.log"); File.WriteAllBytes(currentPath, new byte[1024 * 1024]); File.SetLastWriteTimeUtc(currentPath, DateTime.UtcNow); @@ -681,10 +686,54 @@ public void Run_ForcedGlobalToolLogging_HonorsRetainCountAndSizeRotation() .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); Assert.Equal(2, logs.Length); - Assert.Contains($"stderr-{DateTime.UtcNow:yyyyMMdd}-1.log", logs); + Assert.Contains(logs, name => Regex.IsMatch(name ?? string.Empty, $@"^stderr-{fixedNow:yyyyMMdd}-p\d+-{fixedNow:HHmmss}-1\.log$")); + } + finally + { + GlobalToolLog.TimeProvider = TimeProvider.System; + TestProjectHelper.DeleteDirectory(logDir); + } + } + + [Fact] + public void Run_ForcedGlobalToolLogging_RotatesByDefaultMaxBytesEnvironmentVariable() + { + var logDir = Path.Combine(Path.GetTempPath(), $"cdidx_global_tool_log_max_bytes_{Guid.NewGuid():N}"); + Directory.CreateDirectory(logDir); + using var env = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR", + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); + + try + { + var fixedNow = new DateTimeOffset(2026, 5, 31, 12, 35, 56, TimeSpan.Zero); + GlobalToolLog.TimeProvider = new ManualTimeProvider(fixedNow); + var currentPrefix = $"stderr-{fixedNow:yyyyMMdd}-p{Environment.ProcessId}-{fixedNow:HHmmss}"; + File.WriteAllBytes(Path.Combine(logDir, $"{currentPrefix}.log"), new byte[64]); + + env.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + env.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + env.Set(GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable, "64"); + + var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["definitely-not-a-command"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("Unknown command: definitely-not-a-command", stderr); + + var logs = Directory.GetFiles(logDir, "stderr-*.log", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .ToArray(); + Assert.Contains(logs, name => Regex.IsMatch(name ?? string.Empty, $@"^stderr-{fixedNow:yyyyMMdd}-p\d+-{fixedNow:HHmmss}-1\.log$")); } finally { + GlobalToolLog.TimeProvider = TimeProvider.System; TestProjectHelper.DeleteDirectory(logDir); } }