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/2827.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2827
affected:
- src/CodeIndex/Cli/ProgramRunner.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **`cdidx upgrade` now launches the installer with `ArgumentList` and a timeout (#2827)** — the upgrade path avoids shell-joined installer arguments and terminates a stalled installer with a clear diagnostic instead of waiting indefinitely.

## 日本語

- **`cdidx upgrade` は installer を `ArgumentList` と timeout 付きで起動するようになりました (#2827)** — upgrade 経路は shell 連結の installer 引数を使わず、停止した installer を明確な診断付きで終了するため、無期限に待ち続けなくなりました。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2832.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2832
affected:
- src/CodeIndex/Cli/GitHelper.cs
- tests/CodeIndex.Tests/GitHelperTests.cs
---

## English

- **Git helper subprocesses now have bounded runtime and capture size (#2832)** — git helper commands now fail with explicit diagnostics when a git subprocess times out or captured stdout/stderr exceeds the configured cap, preventing hung helpers and unbounded output accumulation.

## 日本語

- **Git helper の subprocess に実行時間とキャプチャサイズの上限を追加しました (#2832)** — git helper コマンドは git subprocess が timeout した場合や stdout/stderr のキャプチャが上限を超えた場合に明示的な診断で失敗するようになり、helper のハングと無制限の出力蓄積を防ぎます。
125 changes: 121 additions & 4 deletions src/CodeIndex/Cli/GitHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using CodeIndex.Indexer;
Expand Down Expand Up @@ -50,6 +51,18 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList<string> Unresolv
"UU",
};

internal const int MaxCapturedGitOutputChars = 1024 * 1024;
private static readonly TimeSpan DefaultGitCommandTimeout = TimeSpan.FromSeconds(60);
private static readonly AsyncLocal<TimeSpan?> GitCommandTimeoutOverride = new();
internal static TimeSpan GitCommandTimeout
{
get => GitCommandTimeoutOverride.Value ?? DefaultGitCommandTimeout;
set => GitCommandTimeoutOverride.Value = value;
}

private static readonly TimeSpan GitKillWaitTimeout = TimeSpan.FromSeconds(5);
private const int GitProcessFailureExitCode = -1;

/// <summary>
/// Resolve the common git directory for a project root, handling both normal repos and worktrees.
/// プロジェクトルートの共通gitディレクトリを解決する。通常リポジトリとworktreeの両方に対応。
Expand Down Expand Up @@ -686,20 +699,124 @@ private static (int ExitCode, string Output, string Error)? RunProcessCapturingO
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
string? failureReason = null;
var failureLock = new object();

void MarkFailure(string reason)
{
lock (failureLock)
{
if (failureReason != null)
return;
failureReason = reason;
}
TryKillProcessTree(process);
}

// Always terminate captured lines with '\n' (not Environment.NewLine) so callers that
// split on '\n' see identical output on Windows and POSIX — git writes LF-only to pipes.
// キャプチャ行は常に '\n' 区切りにし、Windows/POSIX 双方で git のパイプ出力(LF)と一致させる。
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Append(e.Data).Append('\n'); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.Append(e.Data).Append('\n'); };
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null)
AppendBoundedCapturedLine(stdout, e.Data, "stdout", MarkFailure);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null)
AppendBoundedCapturedLine(stderr, e.Data, "stderr", MarkFailure);
};

if (!process.Start())
return null;

process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
if (!process.WaitForExit(ToWaitMilliseconds(GitCommandTimeout)))
{
MarkFailure($"git command timed out after {FormatDuration(GitCommandTimeout)}.");
if (!process.WaitForExit(ToWaitMilliseconds(GitKillWaitTimeout)))
return (GitProcessFailureExitCode, ReadCaptured(stdout), CombineCapturedError(ReadCaptured(stderr), failureReason!));
process.WaitForExit();
}
else
{
process.WaitForExit();
}

var output = ReadCaptured(stdout);
var error = ReadCaptured(stderr);
if (failureReason != null)
return (GitProcessFailureExitCode, output, CombineCapturedError(error, failureReason));

return (process.ExitCode, stdout.ToString(), stderr.ToString());
return (process.ExitCode, output, error);
}

private static string ReadCaptured(StringBuilder builder)
{
lock (builder)
return builder.ToString();
}

private static void AppendBoundedCapturedLine(
StringBuilder builder,
string data,
string streamName,
Action<string> markFailure)
{
lock (builder)
{
var remaining = MaxCapturedGitOutputChars - builder.Length;
if (remaining <= 0)
{
markFailure(BuildCaptureLimitMessage(streamName));
return;
}

var required = data.Length + 1;
if (required <= remaining)
{
builder.Append(data).Append('\n');
return;
}

builder.Append(data.AsSpan(0, Math.Min(data.Length, remaining)));
}

markFailure(BuildCaptureLimitMessage(streamName));
}

private static string BuildCaptureLimitMessage(string streamName)
=> $"git command captured {streamName} exceeded {MaxCapturedGitOutputChars.ToString(CultureInfo.InvariantCulture)} characters.";

private static string CombineCapturedError(string stderr, string diagnostic)
=> string.IsNullOrWhiteSpace(stderr)
? diagnostic
: stderr.TrimEnd('\r', '\n') + "\n" + diagnostic;

private static int ToWaitMilliseconds(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
return 1;
if (timeout.TotalMilliseconds >= int.MaxValue)
return int.MaxValue;
return Math.Max(1, (int)Math.Ceiling(timeout.TotalMilliseconds));
}

private static string FormatDuration(TimeSpan timeout)
=> timeout.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture) + "s";

private static void TryKillProcessTree(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// Best-effort cleanup only; callers receive the timeout/capture diagnostic.
}
}

private static bool ProbeFileSystemIgnoreCase(string projectRoot)
Expand Down
77 changes: 62 additions & 15 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal static class ProgramRunner
internal const string QuietEnvironmentVariable = "CDIDX_QUIET";
private const string InstallerScriptUrlTemplate = "https://raw.githubusercontent.com/Widthdom/CodeIndex/{0}/install.sh";
private const long MaxInstallerScriptBytes = 1024 * 1024;
private static readonly TimeSpan InstallerRunTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan InstallerKillWaitTimeout = TimeSpan.FromSeconds(5);
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;

internal static int Run(
Expand Down Expand Up @@ -2209,19 +2211,8 @@ internal static int RunUpgrade(string[] cmdArgs, JsonSerializerOptions jsonOptio
.GetResult();
}

var startInfo = new ProcessStartInfo("bash", $"{QuoteShellArg(scriptPath)} {QuoteShellArg(result.LatestVersion)}")
{
UseShellExecute = false,
};
startInfo.Environment["CDIDX_INSTALL_DIR"] = installDir;
var process = Process.Start(startInfo);
if (process == null)
{
Console.Error.WriteLine("Error: failed to start install.sh for upgrade.");
return CommandExitCodes.DatabaseError;
}
process.WaitForExit();
return process.ExitCode;
var startInfo = CreateInstallerProcessStartInfo(scriptPath, result.LatestVersion, installDir);
return RunInstallerProcess(startInfo, InstallerRunTimeout);
}
catch (Exception ex)
{
Expand All @@ -2235,6 +2226,40 @@ internal static int RunUpgrade(string[] cmdArgs, JsonSerializerOptions jsonOptio
}
}

internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPath, string releaseTag, string installDir)
{
var startInfo = new ProcessStartInfo
{
FileName = "bash",
UseShellExecute = false,
};
startInfo.ArgumentList.Add(scriptPath);
startInfo.ArgumentList.Add(releaseTag);
startInfo.Environment["CDIDX_INSTALL_DIR"] = installDir;
return startInfo;
}

internal static int RunInstallerProcess(ProcessStartInfo startInfo, TimeSpan timeout)
{
using var process = Process.Start(startInfo);
if (process == null)
{
Console.Error.WriteLine("Error: failed to start install.sh for upgrade.");
return CommandExitCodes.DatabaseError;
}

if (process.WaitForExit(ToWaitMilliseconds(timeout)))
return process.ExitCode;

TryKillProcessTree(process);
if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout)))
Console.Error.WriteLine("Error: install.sh timed out and did not exit after cancellation.");
else
Console.Error.WriteLine($"Error: install.sh timed out after {FormatDuration(timeout)}.");
Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release.");
return CommandExitCodes.DatabaseError;
}

internal static string BuildInstallerScriptUrl(string releaseTag)
=> string.Format(
CultureInfo.InvariantCulture,
Expand Down Expand Up @@ -2279,8 +2304,30 @@ private static bool CanWriteDirectory(string directory)
}
}

private static string QuoteShellArg(string value)
=> "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
private static int ToWaitMilliseconds(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
return 1;
if (timeout.TotalMilliseconds >= int.MaxValue)
return int.MaxValue;
return Math.Max(1, (int)Math.Ceiling(timeout.TotalMilliseconds));
}

private static string FormatDuration(TimeSpan timeout)
=> timeout.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture) + "s";

private static void TryKillProcessTree(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// Best-effort cleanup only; callers receive the timeout diagnostic.
}
}

// `--version` is now build-aware so dev builds from main are not
// indistinguishable from tagged releases in bug reports (#1550). Human
Expand Down
Loading
Loading