Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<PID>-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`、
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>`, `--log-max-size-mb <N>`, 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 <N>`, `--log-max-size-mb <N>`, `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 <name>` / `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. |
Expand Down
16 changes: 11 additions & 5 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1619.fixed.md
Original file line number Diff line number Diff line change
@@ -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` プロセスが同じ日次ファイルへ同時追記しないようにしました。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/1625.fixed.md
Original file line number Diff line number Diff line change
@@ -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` でバイト単位の上限を調整できます。
19 changes: 15 additions & 4 deletions src/CodeIndex/Cli/GlobalToolLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<redacted>";
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;
private static readonly AsyncLocal<Session?> CurrentSession = new();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 4 additions & 1 deletion tests/CodeIndex.Tests/GlobalToolLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
59 changes: 54 additions & 5 deletions tests/CodeIndex.Tests/ProgramRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -650,18 +652,21 @@ 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");
File.WriteAllText(path, $"old {i}");
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);

Expand All @@ -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);
}
}
Expand Down
Loading