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
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3457.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 3457
affected:
- tools/CodeIndex.Changelog/Program.cs
- tests/CodeIndex.Tests/ChangelogToolTests.cs
---

## English

- **Changelog cleanup failures now use sanitized diagnostics (#3457)** — release preparation cleanup and rollback failures now report stable categories, affected relative paths, reason codes, and recovery hints without raw filesystem exception messages.

## 日本語

- **changelog cleanup 失敗が sanitized diagnostic を返すようになりました (#3457)** — release preparation の cleanup / rollback 失敗は、生の filesystem 例外文ではなく、安定した category、影響する相対パス、reason code、復旧ヒントを返します。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/3462.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: fixed
issues:
- 3462
affected:
- src/CodeIndex/Cli/IndexLock.cs
- src/CodeIndex/Cli/LockCleanupDiagnostic.cs
- src/CodeIndex/Mcp/McpIndexRunLock.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **Index lock metadata and cleanup failures are now safer (#3462)** — new CLI lock sidecars no longer include host or project-path metadata, and CLI/MCP lock cleanup failures now report bounded reason-code diagnostics without raw exception text.

## 日本語

- **index lock metadata と cleanup 失敗をより安全に扱うようになりました (#3462)** — 新しい CLI lock sidecar は host や project path を含めず、CLI / MCP lock の cleanup 失敗は生の例外文ではなく bounded な reason-code diagnostic として記録します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/3475.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 3475
affected:
- src/CodeIndex/Cli/PrivateLogFile.cs
- src/CodeIndex/Cli/GlobalToolLog.cs
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
---

## English

- **Private log hardening failures are now observable (#3475)** — best-effort permission and pruning failures now emit bounded `private_log_diagnostic` WARN entries with stable reason codes instead of disappearing silently.

## 日本語

- **private log の hardening 失敗を観測できるようになりました (#3475)** — 権限設定や prune のベストエフォート失敗は、黙って消える代わりに、安定した reason code を含む bounded な `private_log_diagnostic` WARN として記録されます。
47 changes: 38 additions & 9 deletions src/CodeIndex/Cli/GlobalToolLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal static class GlobalToolLog
internal const int RedactionArgumentLengthLimit = 8192;
internal const string RedactionTruncationMarker = "<truncated>";
private const string RedactedValue = "<redacted>";
private const int PrivateLogDiagnosticEmitLimit = 16;
private static readonly TimeSpan RedactionRegexTimeout = TimeSpan.FromSeconds(1);
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;
private static readonly AsyncLocal<Session?> CurrentSession = new();
Expand Down Expand Up @@ -68,15 +69,16 @@ internal static class GlobalToolLog
if (!ShouldEnable())
return null;

var privateLogDiagnostics = new List<PrivateLogFileDiagnostic>();
var logDirectory = ResolveLogDirectory();
Directory.CreateDirectory(logDirectory);
HardenLogFiles(logDirectory);
HardenLogFiles(logDirectory, privateLogDiagnostics.Add);
var options = LogOptions.FromEnvironment();
var logPath = ResolveLogPath(logDirectory, options);
writer = createWriter?.Invoke(logPath) ?? CreateLogWriter(logPath);
afterWriterCreated?.Invoke();
SetLogFilePermissions(logPath);
PruneOldLogs(logDirectory, options.RetainCount);
SetLogFilePermissions(logPath, privateLogDiagnostics.Add);
PruneOldLogs(logDirectory, options.RetainCount, privateLogDiagnostics.Add);

var session = new Session(writer, logPath, options.Format);
writer = null;
Expand All @@ -87,6 +89,7 @@ internal static class GlobalToolLog
session.Write("INFO", $"base_dir={AppContext.BaseDirectory}");
session.Write("INFO", $"cwd={Environment.CurrentDirectory}");
session.Write("INFO", $"args={FormatArgs(args)}");
WritePrivateLogDiagnostics(session, privateLogDiagnostics);
return session;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
Expand Down Expand Up @@ -410,48 +413,74 @@ private static string CreateProcessLogSuffix()
return FormattableString.Invariant($"p{Environment.ProcessId}-{startTime}");
}

private static void PruneOldLogs(string logDirectory, int retainedLogFileCount)
private static void PruneOldLogs(
string logDirectory,
int retainedLogFileCount,
Action<PrivateLogFileDiagnostic>? diagnosticSink)
{
try
{
PrivateLogFile.PruneOldFiles(logDirectory, "stderr-*.log", retainedLogFileCount);
PrivateLogFile.PruneOldFiles(logDirectory, "stderr-*.log", retainedLogFileCount, diagnosticSink);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort only / ベストエフォートのみ
}
}

private static void HardenLogFiles(string logDirectory)
private static void HardenLogFiles(string logDirectory, Action<PrivateLogFileDiagnostic>? diagnosticSink)
{
if (OperatingSystem.IsWindows())
return;

try
{
PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log");
PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log", diagnosticSink);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort only / ベストエフォートのみ
}
}

private static void SetLogFilePermissions(string logPath)
private static void SetLogFilePermissions(string logPath, Action<PrivateLogFileDiagnostic>? diagnosticSink)
{
if (OperatingSystem.IsWindows())
return;

try
{
PrivateLogFile.TrySetPrivatePermissions(logPath);
PrivateLogFile.TrySetPrivatePermissions(logPath, diagnosticSink);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort only / ベストエフォートのみ
}
}

private static void WritePrivateLogDiagnostics(Session session, IReadOnlyList<PrivateLogFileDiagnostic> diagnostics)
{
var emitted = Math.Min(diagnostics.Count, PrivateLogDiagnosticEmitLimit);
for (var i = 0; i < emitted; i++)
{
var diagnostic = diagnostics[i];
session.Write(
"WARN",
"private_log_diagnostic"
+ $" operation={QuoteLogValue(diagnostic.Operation)}"
+ $" reason={QuoteLogValue(diagnostic.Reason)}"
+ $" target={QuoteLogValue(diagnostic.Target)}");
}

if (diagnostics.Count > emitted)
{
session.Write(
"WARN",
"private_log_diagnostics_truncated"
+ $" omitted={(diagnostics.Count - emitted).ToString(CultureInfo.InvariantCulture)}");
}
}

internal static string FormatArgs(string[] args)
{
if (args.Length == 0)
Expand Down
62 changes: 27 additions & 35 deletions src/CodeIndex/Cli/IndexLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ internal sealed class IndexLock : IDisposable
private readonly string _infoPath;
private bool _disposed;

internal static Action<string> DeleteFileForTesting { get; set; } = File.Delete;
internal static Action<LockCleanupDiagnostic>? CleanupDiagnosticSinkForTesting { get; set; }

private IndexLock(FileStream stream, string lockPath, string infoPath)
{
_stream = stream;
Expand Down Expand Up @@ -99,9 +102,7 @@ public static IndexLock Acquire(string lockPath, string projectPath)
{
var info = new IndexLockInfo(
Pid: Environment.ProcessId,
StartedAt: DateTime.UtcNow,
Host: Environment.MachineName,
ProjectPath: Path.GetFullPath(projectPath));
StartedAt: DateTime.UtcNow);
DataDirectorySecurity.WritePrivateText(infoPath, SerializeInfo(info), Encoding.UTF8);
}
catch (Exception)
Expand Down Expand Up @@ -148,14 +149,7 @@ public void Dispose()
return;
_disposed = true;

try
{
File.Delete(_infoPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort. / ベストエフォート。
}
TryDeleteCleanupTarget(_infoPath, "metadata");

try
{
Expand All @@ -166,16 +160,7 @@ public void Dispose()
// Best-effort. / ベストエフォート。
}

try
{
File.Delete(_lockPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort cleanup; a leftover empty lockfile does not block future
// acquires because the next Acquire opens it with OpenOrCreate.
// 残った空 lockfile も次回 Acquire の OpenOrCreate で再利用できる。
}
TryDeleteCleanupTarget(_lockPath, "lockfile");
}

// --- Tiny key=value serializer (avoids touching JsonSerializerContext) ---
Expand All @@ -186,17 +171,13 @@ private static string SerializeInfo(IndexLockInfo info)
var sb = new StringBuilder();
sb.Append("pid=").Append(info.Pid.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("started_at=").Append(info.StartedAt.ToString("o", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("host=").Append(EscapeValue(info.Host)).Append('\n');
sb.Append("project=").Append(EscapeValue(info.ProjectPath)).Append('\n');
return sb.ToString();
}

private static IndexLockInfo? ParseInfo(string text)
{
int? pid = null;
DateTime? started = null;
string? host = null;
string? project = null;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
Expand All @@ -217,18 +198,12 @@ private static string SerializeInfo(IndexLockInfo info)
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var s))
started = s;
break;
case "host":
host = value;
break;
case "project":
project = value;
break;
}
}

if (pid is null || started is null)
return null;
return new IndexLockInfo(pid.Value, started.Value, host ?? string.Empty, project ?? string.Empty);
return new IndexLockInfo(pid.Value, started.Value);
}

private static string EscapeValue(string? value)
Expand Down Expand Up @@ -264,13 +239,30 @@ private static string UnescapeValue(string value)
}
return sb.ToString();
}

private static void TryDeleteCleanupTarget(string path, string target)
{
try
{
DeleteFileForTesting(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
ReportCleanupFailure(target, ex);
}
}

private static void ReportCleanupFailure(string target, Exception exception)
{
var diagnostic = LockCleanupDiagnostic.Create("index_lock", target, exception);
GlobalToolLog.Error(diagnostic.ToLogMessage());
CleanupDiagnosticSinkForTesting?.Invoke(diagnostic);
}
}

internal sealed record IndexLockInfo(
int Pid,
DateTime StartedAt,
string Host,
string ProjectPath);
DateTime StartedAt);

internal sealed class IndexLockConflictException : Exception
{
Expand Down
21 changes: 21 additions & 0 deletions src/CodeIndex/Cli/LockCleanupDiagnostic.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace CodeIndex.Cli;

internal sealed record LockCleanupDiagnostic(string Component, string Target, string Reason)
{
internal string ToLogMessage() =>
$"{Component}_cleanup_failed target={Target} reason={Reason}";

internal static LockCleanupDiagnostic Create(string component, string target, Exception exception) =>
new(component, target, ClassifyFailure(exception));

private static string ClassifyFailure(Exception exception) =>
exception switch
{
UnauthorizedAccessException => "permission_denied",
FileNotFoundException or DirectoryNotFoundException => "not_found",
NotSupportedException => "not_supported",
IOException => "io_error",
ObjectDisposedException => "object_disposed",
_ => "operation_failed",
};
}
Loading
Loading