From 20dbfa9263987941846f6a7a49b2d88240ebb2c6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 03:00:56 +0900 Subject: [PATCH 1/5] Fix bounded git helper subprocess capture (#2832) --- changelog.d/unreleased/2832.fixed.md | 16 ++++ src/CodeIndex/Cli/GitHelper.cs | 111 +++++++++++++++++++++++- tests/CodeIndex.Tests/GitHelperTests.cs | 105 ++++++++++++++++++++++ 3 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/2832.fixed.md diff --git a/changelog.d/unreleased/2832.fixed.md b/changelog.d/unreleased/2832.fixed.md new file mode 100644 index 0000000000..4dfe2669ee --- /dev/null +++ b/changelog.d/unreleased/2832.fixed.md @@ -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 のハングと無制限の出力蓄積を防ぎます。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 1d035ec1a6..6c8ea18ac4 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using CodeIndex.Indexer; @@ -50,6 +51,11 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList Unresolv "UU", }; + internal const int MaxCapturedGitOutputChars = 1024 * 1024; + internal static TimeSpan GitCommandTimeout { get; set; } = TimeSpan.FromSeconds(60); + private static readonly TimeSpan GitKillWaitTimeout = TimeSpan.FromSeconds(5); + private const int GitProcessFailureExitCode = -1; + /// /// Resolve the common git directory for a project root, handling both normal repos and worktrees. /// プロジェクトルートの共通gitディレクトリを解決する。通常リポジトリとworktreeの両方に対応。 @@ -686,20 +692,117 @@ 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, stdout.ToString(), CombineCapturedError(stderr.ToString(), failureReason!)); + } + else + { + process.WaitForExit(); + } + + var output = stdout.ToString(); + var error = stderr.ToString(); + if (failureReason != null) + return (GitProcessFailureExitCode, output, CombineCapturedError(error, failureReason)); - return (process.ExitCode, stdout.ToString(), stderr.ToString()); + return (process.ExitCode, output, error); + } + + private static void AppendBoundedCapturedLine( + StringBuilder builder, + string data, + string streamName, + Action 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) diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 6fead55cf4..e35a9522cb 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -341,6 +341,63 @@ public async Task GetChangedFilesFromCommit_DrainsLargeStderrWithoutDeadlock() } } + [Fact] + public void GetChangedFilesFromCommit_FailsWhenCapturedOutputExceedsLimit() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(_tempDir, "repo"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(_tempDir, "fake-git-output-cap"); + Directory.CreateDirectory(fakeGitDir); + WriteFakeGitThatExceedsStdoutLimit(fakeGitDir); + + var oldPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", fakeGitDir + Path.PathSeparator + oldPath); + try + { + var ex = Assert.Throws( + () => GitHelper.GetChangedFilesFromCommit(repoDir, "0123456789abcdef")); + + Assert.Contains("captured stdout exceeded", ex.Message); + } + finally + { + Environment.SetEnvironmentVariable("PATH", oldPath); + } + } + + [Fact] + public void GetChangedFilesFromCommit_FailsWhenGitCommandTimesOut() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(_tempDir, "repo-timeout"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(_tempDir, "fake-git-timeout"); + Directory.CreateDirectory(fakeGitDir); + WriteFakeGitThatHangsOnDiffTree(fakeGitDir); + + var oldPath = Environment.GetEnvironmentVariable("PATH"); + var oldTimeout = GitHelper.GitCommandTimeout; + Environment.SetEnvironmentVariable("PATH", fakeGitDir + Path.PathSeparator + oldPath); + GitHelper.GitCommandTimeout = TimeSpan.FromSeconds(1); + try + { + var ex = Assert.Throws( + () => GitHelper.GetChangedFilesFromCommit(repoDir, "0123456789abcdef")); + + Assert.Contains("timed out", ex.Message); + } + finally + { + GitHelper.GitCommandTimeout = oldTimeout; + Environment.SetEnvironmentVariable("PATH", oldPath); + } + } + [Theory] [InlineData("feature")] [InlineData("v1.0.0")] @@ -771,6 +828,54 @@ exit 1 File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } + private static void WriteFakeGitThatExceedsStdoutLimit(string directory) + { + var script = Path.Combine(directory, "git"); + File.WriteAllText(script, """ +#!/bin/sh +if [ "$1" = "rev-parse" ]; then + if [ "$2" = "--symbolic-full-name" ]; then + exit 0 + fi + if [ "$2" = "--verify" ]; then + printf '%s\n' '0123456789abcdef0123456789abcdef01234567' + exit 0 + fi +fi +if [ "$1" = "diff-tree" ]; then + perl -e 'for ($i = 0; $i < 80000; $i++) { print "M\tchanged_$i.txt\n" }' + exit 0 +fi +exit 1 +"""); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private static void WriteFakeGitThatHangsOnDiffTree(string directory) + { + var script = Path.Combine(directory, "git"); + File.WriteAllText(script, """ +#!/bin/sh +if [ "$1" = "rev-parse" ]; then + if [ "$2" = "--symbolic-full-name" ]; then + exit 0 + fi + if [ "$2" = "--verify" ]; then + printf '%s\n' '0123456789abcdef0123456789abcdef01234567' + exit 0 + fi +fi +if [ "$1" = "diff-tree" ]; then + sleep 5 + exit 0 +fi +exit 1 +"""); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + private static bool ProbeDirectoryIgnoreCaseLikeProduction(string path) { if (TryCreateCaseVariant(path, out var variant)) From 87113f455bb25a000aca8acce785f0f4855de425 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 03:02:41 +0900 Subject: [PATCH 2/5] Fix bounded upgrade installer launch (#2827) --- changelog.d/unreleased/2827.fixed.md | 16 +++++ src/CodeIndex/Cli/ProgramRunner.cs | 77 +++++++++++++++++---- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 50 +++++++++++++ 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/2827.fixed.md diff --git a/changelog.d/unreleased/2827.fixed.md b/changelog.d/unreleased/2827.fixed.md new file mode 100644 index 0000000000..bce8b905b6 --- /dev/null +++ b/changelog.d/unreleased/2827.fixed.md @@ -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 を明確な診断付きで終了するため、無期限に待ち続けなくなりました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 4e05025025..188b4c4b4a 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -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; + internal static TimeSpan InstallerRunTimeout { get; set; } = TimeSpan.FromMinutes(5); + private static readonly TimeSpan InstallerKillWaitTimeout = TimeSpan.FromSeconds(5); internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; internal static int Run( @@ -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) { @@ -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, @@ -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 diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index d05b4a8240..13ee9f32cc 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -361,6 +361,56 @@ public void BuildInstallerScriptUrl_UsesResolvedReleaseTag(string releaseTag, st Assert.Equal(expected, ProgramRunner.BuildInstallerScriptUrl(releaseTag)); } + [Fact] + public void CreateInstallerProcessStartInfo_UsesArgumentList() + { + var startInfo = ProgramRunner.CreateInstallerProcessStartInfo( + "/tmp/install script's path.sh", + "v1.27.0", + "/opt/cdidx install"); + + Assert.Equal("bash", startInfo.FileName); + Assert.False(startInfo.UseShellExecute); + Assert.Equal(string.Empty, startInfo.Arguments); + Assert.Equal(["/tmp/install script's path.sh", "v1.27.0"], startInfo.ArgumentList.ToArray()); + Assert.Equal("/opt/cdidx install", startInfo.Environment["CDIDX_INSTALL_DIR"]); + } + + [Fact] + public void RunInstallerProcess_TimesOutHungInstaller() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_installer_timeout_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + var script = Path.Combine(root, "install.sh"); + try + { + File.WriteAllText(script, """ +#!/bin/sh +sleep 5 +"""); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var startInfo = ProgramRunner.CreateInstallerProcessStartInfo(script, "v1.27.0", root); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.RunInstallerProcess(startInfo, TimeSpan.FromMilliseconds(100))); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Empty(stdout); + Assert.Contains("install.sh timed out", stderr); + Assert.Contains("rerun `install.sh` manually", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + } + [Fact] public async Task DownloadInstallerScriptAsync_CancelsStalledBody() { From 037334aa1102e48b5ae84fd0bd75ddb4b781b17c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 03:11:54 +0900 Subject: [PATCH 3/5] Isolate git helper timeout override (#2832) --- src/CodeIndex/Cli/GitHelper.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 6c8ea18ac4..efff19740a 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -52,7 +52,14 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList Unresolv }; internal const int MaxCapturedGitOutputChars = 1024 * 1024; - internal static TimeSpan GitCommandTimeout { get; set; } = TimeSpan.FromSeconds(60); + private static readonly TimeSpan DefaultGitCommandTimeout = TimeSpan.FromSeconds(60); + private static readonly AsyncLocal 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; From 4d8cf8448ab6d73670e601bba345ef52522f2cf3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 03:12:07 +0900 Subject: [PATCH 4/5] Keep upgrade installer timeout internal (#2827) --- src/CodeIndex/Cli/ProgramRunner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 188b4c4b4a..61e5e3e8c8 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -20,7 +20,7 @@ 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; - internal static TimeSpan InstallerRunTimeout { get; set; } = TimeSpan.FromMinutes(5); + private static readonly TimeSpan InstallerRunTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan InstallerKillWaitTimeout = TimeSpan.FromSeconds(5); internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; From 1dd6c3e8ffbc147bd6994d9ca4209d46aba6c0b4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 03:16:20 +0900 Subject: [PATCH 5/5] Drain git helper output after timeout kill (#2832) --- src/CodeIndex/Cli/GitHelper.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index efff19740a..0237f9b192 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -736,21 +736,28 @@ void MarkFailure(string reason) { MarkFailure($"git command timed out after {FormatDuration(GitCommandTimeout)}."); if (!process.WaitForExit(ToWaitMilliseconds(GitKillWaitTimeout))) - return (GitProcessFailureExitCode, stdout.ToString(), CombineCapturedError(stderr.ToString(), failureReason!)); + return (GitProcessFailureExitCode, ReadCaptured(stdout), CombineCapturedError(ReadCaptured(stderr), failureReason!)); + process.WaitForExit(); } else { process.WaitForExit(); } - var output = stdout.ToString(); - var error = stderr.ToString(); + var output = ReadCaptured(stdout); + var error = ReadCaptured(stderr); if (failureReason != null) return (GitProcessFailureExitCode, output, CombineCapturedError(error, failureReason)); return (process.ExitCode, output, error); } + private static string ReadCaptured(StringBuilder builder) + { + lock (builder) + return builder.ToString(); + } + private static void AppendBoundedCapturedLine( StringBuilder builder, string data,