From 6f815fcb37731fb340f0b539851bd9b64f869bc8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:00:05 +0900 Subject: [PATCH 1/4] Cap GitHub suggestion timeout env values (#2916) --- DEVELOPER_GUIDE.md | 4 ++-- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/2916.fixed.md | 18 ++++++++++++++++++ src/CodeIndex/Cli/GitHubIssueReporter.cs | 3 ++- .../GitHubIssueReporterTests.cs | 10 ++++++++++ 5 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/2916.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index fdf50598d8..4ebe3d9afe 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1125,7 +1125,7 @@ Local suggestion records use the `status` lifecycle field instead of a binary su Before creating an upstream Issue, `GitHubIssueReporter` checks whether an Issue with the same SHA256 suggestion hash already exists. It first queries GitHub Search for the hash in issue bodies, then falls back to listing Issues with the existing repository labels cdidx applies (`enhancement` for ordinary suggestions, `bug` for crash/error reports) and matching the hash in each body. The fallback avoids GitHub Search indexing latency, so a retry immediately after a lost create response can still find the just-created Issue and avoid a duplicate POST. Lookup failures remain best-effort: if both checks fail because GitHub is unavailable, the reporter proceeds to the normal create path instead of blocking a legitimate first submission. -The shared GitHub HTTP client uses an explicit 10-second submission timeout by default, configurable with `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS`, and the platform default proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, and `NO_PROXY` through .NET default proxy handling). Create failures mention proxy environment variables in their diagnostic hint. `429` responses and `403` responses with `x-ratelimit-remaining: 0` are treated as rate limits; `Retry-After` wins, then `x-ratelimit-reset`, then a one-minute fallback retry window. +The shared GitHub HTTP client uses an explicit 10-second submission timeout by default, configurable up to 300 seconds with `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS`, and the platform default proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, and `NO_PROXY` through .NET default proxy handling). Non-positive, non-numeric, and larger timeout values fall back to the 10-second default. Create failures mention proxy environment variables in their diagnostic hint. `429` responses and `403` responses with `x-ratelimit-remaining: 0` are treated as rate limits; `Retry-After` wins, then `x-ratelimit-reset`, then a one-minute fallback retry window. ### What is NOT included in the payload by design @@ -2748,7 +2748,7 @@ Unlist しても exact version restore は不可能になりません。これ upstream Issue を作成する前に、`GitHubIssueReporter` は同じ SHA256 提案ハッシュを持つ Issue が既に存在するか確認する。まず GitHub Search で Issue 本文内のハッシュを検索し、その後 backstop として `ai-suggestion` Issue を直接一覧取得して各本文内のハッシュを照合する。この fallback により GitHub Search の indexing 遅延を回避できるため、作成レスポンスが失われた直後の再試行でも、作成済み Issue を検出して重複 POST を防げる。lookup 失敗時の扱いは引き続きベストエフォートであり、GitHub 側の障害などで両方の確認に失敗した場合は、正規の初回送信をブロックせず通常の作成経路へ進む。 -共有 GitHub HTTP クライアントは 15 秒 timeout と platform default proxy(.NET の既定 proxy 処理を通じた `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY`)を使う。作成失敗の診断には proxy 環境変数の確認ヒントを含める。`429` 応答と `x-ratelimit-remaining: 0` 付きの `403` 応答は rate limit として扱い、`Retry-After`、`x-ratelimit-reset`、1 分の fallback retry window の順で再試行時刻を決める。 +共有 GitHub HTTP クライアントは既定で 10 秒 timeout と platform default proxy(.NET の既定 proxy 処理を通じた `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY`)を使う。`CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS` で最大 300 秒まで設定でき、0 以下、数値以外、または上限を超える値は 10 秒の既定値へ戻る。作成失敗の診断には proxy 環境変数の確認ヒントを含める。`429` 応答と `x-ratelimit-remaining: 0` 付きの `403` 応答は rate limit として扱い、`Retry-After`、`x-ratelimit-reset`、1 分の fallback retry window の順で再試行時刻を決める。 ### ペイロードに設計上含まれないもの diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ce94e237d2..c6bd17b383 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2056,7 +2056,7 @@ When both are set, the allowlist wins. `tools/list` only advertises enabled tool ### AI Feedback -cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). +cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline up to 300 seconds. Non-positive, non-numeric, and larger values fall back to the 10-second default. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show ` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. Use `cdidx suggestions export --format issue-drafts --open-issues open-issues.json` to emit issue-ready drafts with title, labels, evidence paths, body text, and duplicate matches from an open-issues JSON preflight. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. @@ -4179,7 +4179,7 @@ stdio トランスポートはバイト単位で挙動が変わらないため ### AIフィードバック -cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で調整できます。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 +cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で最大 300 秒まで調整できます。0 以下、数値以外、または上限を超える値は 10 秒の既定値へ戻ります。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show ` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。`cdidx suggestions export --format issue-drafts --open-issues open-issues.json` は、title、labels、evidence paths、body text、open issue JSON との重複候補を含む Issue 作成用 draft を出力します。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。 diff --git a/changelog.d/unreleased/2916.fixed.md b/changelog.d/unreleased/2916.fixed.md new file mode 100644 index 0000000000..0d2da8a759 --- /dev/null +++ b/changelog.d/unreleased/2916.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 2916 +affected: + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **GitHub suggestion submission timeout values now have an upper cap (#2916)** — `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS` now accepts only 1 through 300 seconds, falling back to the 10-second default for non-numeric, non-positive, or larger values. + +## 日本語 + +- **GitHub suggestion 送信 timeout 値に上限を設けました (#2916)** — `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS` は 1 秒から 300 秒までのみ受け付け、数値以外、0 以下、または上限を超える値は 10 秒の既定値へ戻るようになりました。 diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index 0ddc4e018f..174d20ff0b 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -48,6 +48,7 @@ internal static class GitHubIssueReporter internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan DefaultRateLimitRetryDelay = TimeSpan.FromMinutes(1); private const string TimeoutEnvironmentVariable = "CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS"; + internal const int MaxSubmitTimeoutSeconds = 300; internal const int MaxGitHubIssueTitleLength = 255; internal const int MaxScrubInputLength = 16 * 1024; internal const int MaxGitHubApiErrorBodyBytes = 4 * 1024; @@ -189,7 +190,7 @@ internal static TimeSpan ResolveSubmitTimeout() if (string.IsNullOrWhiteSpace(raw)) return DefaultTimeout; - return int.TryParse(raw, out var seconds) && seconds > 0 + return int.TryParse(raw, out var seconds) && seconds is > 0 and <= MaxSubmitTimeoutSeconds ? TimeSpan.FromSeconds(seconds) : DefaultTimeout; } diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index 0bdb59aec9..b241f683b0 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Text; using System.Text.Json.Nodes; @@ -98,6 +99,15 @@ public void ResolveSubmitTimeout_InvalidOverride_UsesDefault() Assert.Equal(GitHubIssueReporter.DefaultTimeout, GitHubIssueReporter.ResolveSubmitTimeout()); } + [Fact] + public void ResolveSubmitTimeout_AboveMaximum_UsesDefault() + { + var tooLarge = GitHubIssueReporter.MaxSubmitTimeoutSeconds + 1; + _env.Set("CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS", tooLarge.ToString(CultureInfo.InvariantCulture)); + + Assert.Equal(GitHubIssueReporter.DefaultTimeout, GitHubIssueReporter.ResolveSubmitTimeout()); + } + // --- ScrubInlineCode tests / ScrubInlineCode テスト --- [Fact] From 601c7d8ca80bccbcfdf22c0be7e106a54480a65b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:07:54 +0900 Subject: [PATCH 2/4] Cap suggestion retention settings (#2915) --- DEVELOPER_GUIDE.md | 8 ++++ USER_GUIDE.md | 4 +- changelog.d/unreleased/2915.fixed.md | 20 ++++++++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 7 ++++ src/CodeIndex/Cli/SuggestionStore.cs | 6 ++- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 40 +++++++++++++++++++ tests/CodeIndex.Tests/SuggestionStoreTests.cs | 39 ++++++++++++++++++ 7 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/2915.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 4ebe3d9afe..fa675a2332 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1115,6 +1115,8 @@ The `suggest_improvement` MCP tool allows AI agents to report gaps or errors. `SuggestionStore` first checks the SHA256 hash, then compares the candidate against the most recent suggestions in the same category and language using normalized-token Jaccard similarity. The default fuzzy threshold is `0.85`; `cdidx mcp --suggestion-dedup-threshold`, `CDIDX_SUGGESTION_DEDUP_THRESHOLD`, or `.cdidxrc.json` `suggestion_dedup_threshold` can override it with a value from `0` to `1`. Fuzzy matches are returned as duplicates before GitHub submission and log the matched hash plus score to stderr for auditability. +Local suggestion retention is bounded by `CDIDX_SUGGESTION_MAX_AGE_DAYS` and `CDIDX_SUGGESTION_MAX_COUNT`, also available as `.cdidxrc.json` `suggestion_max_age_days` and `suggestion_max_count`. The built-in defaults are 365 days and 5000 records; accepted values are capped at 3650 days and 100000 records. Non-positive, non-numeric, overflowing, or larger environment values fall back to the defaults, while larger config-file values are rejected during config validation. + ### Local lifecycle fields Local suggestion records use the `status` lifecycle field instead of a binary submitted flag. New records start as `draft`; successful GitHub submission moves them to `submitted_pending_triage` and stamps `upstream_url`, `upstream_issue_number`, and `last_synced_at` when known. Every GitHub submission attempt also stamps `last_submit_attempt`, increments `submit_attempt_count`, and records `last_submit_error` on failure; success clears the last error. GitHub rate-limit responses also stamp `next_retry_at`, and duplicate unsubmitted suggestions are not retried until that timestamp has passed. The remaining additive states are reserved for follow-up sync/listing flows: `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, and `superseded`. Older records containing `submitted_to_github` / `github_issue_url` are normalized on read to the new lifecycle fields. @@ -2740,6 +2742,12 @@ Unlist しても exact version restore は不可能になりません。これ - attribution メタデータ: `created_by_agent`、`session_id`、`client_version`、`mcp_client_name`、`mcp_client_version`、および任意の `tool_invocation_context` - SHA256 提案ハッシュ(重複排除用) +### 重複排除とローカル保持 + +`SuggestionStore` はまず SHA256 ハッシュを確認し、その後、同じ category / language の直近提案と正規化 token の Jaccard 類似度で比較する。fuzzy しきい値の既定は `0.85` で、`cdidx mcp --suggestion-dedup-threshold`、`CDIDX_SUGGESTION_DEDUP_THRESHOLD`、または `.cdidxrc.json` の `suggestion_dedup_threshold` で `0` から `1` の値へ上書きできる。fuzzy match は GitHub 送信前に重複として返され、監査用に一致先 hash と score を stderr に記録する。 + +ローカル提案の保持は `CDIDX_SUGGESTION_MAX_AGE_DAYS` と `CDIDX_SUGGESTION_MAX_COUNT` で制限され、`.cdidxrc.json` では `suggestion_max_age_days` と `suggestion_max_count` として設定できる。組み込み既定値は 365 日と 5000 件で、受け付ける値の上限は 3650 日と 100000 件。0 以下、数値以外、overflow、または上限を超える環境変数値は既定値へ戻り、上限を超える config-file 値は config validation 時に拒否される。 + ### ローカルライフサイクルフィールド ローカルの提案レコードは、送信済みかどうかの二値フラグではなく `status` ライフサイクルフィールドを使う。新規レコードは `draft` で始まり、GitHub への送信が成功すると `submitted_pending_triage` へ移行し、判明している範囲で `upstream_url`、`upstream_issue_number`、`last_synced_at` を記録する。GitHub の rate-limit 応答では `next_retry_at` も記録し、未送信の重複提案はその時刻を過ぎるまで再送しない。残りの追加状態は後続の sync / listing フロー向けに予約されている: `open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded`。`submitted_to_github` / `github_issue_url` を含む古いレコードは、読み取り時に新しいライフサイクルフィールドへ正規化される。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c6bd17b383..573924ef79 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1462,7 +1462,7 @@ Supported schema (top-level keys are snake_case; nested indexing kind keys keep } ``` -JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `suggestion_max_age_days` and `suggestion_max_count` bound the live `.cdidx/suggestions-*.json` store; pruned records are appended to `.cdidx/suggestions-*.archive.jsonl`. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. +JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `suggestion_max_age_days` and `suggestion_max_count` bound the live `.cdidx/suggestions-*.json` store; pruned records are appended to `.cdidx/suggestions-*.archive.jsonl`. Defaults are 365 days and 5000 records, and config-file values may not exceed 3650 days or 100000 records. Matching environment variables above those caps fall back to the defaults. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. ## How it works @@ -3604,7 +3604,7 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が } ``` -人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`suggestion_max_age_days` と `suggestion_max_count` は live の `.cdidx/suggestions-*.json` store の上限を設定し、prune された record は `.cdidx/suggestions-*.archive.jsonl` に追記されます。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 +人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`suggestion_max_age_days` と `suggestion_max_count` は live の `.cdidx/suggestions-*.json` store の上限を設定し、prune された record は `.cdidx/suggestions-*.archive.jsonl` に追記されます。既定値は 365 日と 5000 件で、config-file 値は 3650 日または 100000 件を超えられません。同じ環境変数がこの上限を超えた場合は既定値へ戻ります。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 ## 動作の仕組み diff --git a/changelog.d/unreleased/2915.fixed.md b/changelog.d/unreleased/2915.fixed.md new file mode 100644 index 0000000000..c39cbd5b3c --- /dev/null +++ b/changelog.d/unreleased/2915.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 2915 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - src/CodeIndex/Cli/CdidxConfigFile.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Suggestion retention settings now have upper caps (#2915)** — `CDIDX_SUGGESTION_MAX_AGE_DAYS` is now limited to 3650 days and `CDIDX_SUGGESTION_MAX_COUNT` to 100000 records, with oversized environment values falling back safely and oversized config-file values rejected during validation. + +## 日本語 + +- **Suggestion retention 設定に上限を設けました (#2915)** — `CDIDX_SUGGESTION_MAX_AGE_DAYS` は 3650 日、`CDIDX_SUGGESTION_MAX_COUNT` は 100000 件までに制限され、上限を超える環境変数値は安全に既定値へ戻り、上限を超える config-file 値は検証時に拒否されます。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 3f6028a885..b0ab919937 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using CodeIndex.Indexer; @@ -173,6 +174,9 @@ internal static LoadResult LoadAndApply( { if (!TryReadPositiveIntegerAsString(suggestionMaxAgeDays, "suggestion_max_age_days", path, out var value, out var err)) return new LoadResult(Path: path, Error: err); + var parsedMaxAgeDays = int.Parse(value!, CultureInfo.InvariantCulture); + if (parsedMaxAgeDays > SuggestionStore.MaximumMaxAgeDays) + return new LoadResult(Path: path, Error: $"[cdidx] {path}: `suggestion_max_age_days` must be <= {SuggestionStore.MaximumMaxAgeDays}."); pending.Add((SuggestionStore.MaxAgeDaysEnvironmentVariable, value!)); } @@ -180,6 +184,9 @@ internal static LoadResult LoadAndApply( { if (!TryReadPositiveIntegerAsString(suggestionMaxCount, "suggestion_max_count", path, out var value, out var err)) return new LoadResult(Path: path, Error: err); + var parsedMaxCount = int.Parse(value!, CultureInfo.InvariantCulture); + if (parsedMaxCount > SuggestionStore.MaximumMaxCount) + return new LoadResult(Path: path, Error: $"[cdidx] {path}: `suggestion_max_count` must be <= {SuggestionStore.MaximumMaxCount}."); pending.Add((SuggestionStore.MaxCountEnvironmentVariable, value!)); } diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 01b32d030d..5633ebdef0 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -33,6 +33,8 @@ public class SuggestionStore internal const int DefaultMaxAgeDays = 365; internal const int DefaultMaxCount = 5000; internal const int MaxSuggestionStoreBytes = 8 * 1024 * 1024; + internal const int MaximumMaxAgeDays = 3650; + internal const int MaximumMaxCount = 100_000; private const int FuzzyDedupRecentLimit = 100; private const string RedactedAwsAccessKey = "[REDACTED:aws_access_key]"; private const string RedactedBearerToken = "[REDACTED:bearer_token]"; @@ -828,7 +830,7 @@ private void ArchivePrunedRecords(IEnumerable records) internal static TimeSpan ResolveMaxAge() { var raw = Environment.GetEnvironmentVariable(MaxAgeDaysEnvironmentVariable); - return int.TryParse(raw, out var days) && days > 0 + return int.TryParse(raw, out var days) && days is > 0 and <= MaximumMaxAgeDays ? TimeSpan.FromDays(days) : TimeSpan.FromDays(DefaultMaxAgeDays); } @@ -836,7 +838,7 @@ internal static TimeSpan ResolveMaxAge() internal static int ResolveMaxCount() { var raw = Environment.GetEnvironmentVariable(MaxCountEnvironmentVariable); - return int.TryParse(raw, out var count) && count > 0 + return int.TryParse(raw, out var count) && count is > 0 and <= MaximumMaxCount ? count : DefaultMaxCount; } diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 60d8a36c4a..6ee8269e53 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -325,6 +325,46 @@ public void LoadAndApply_InvalidSuggestionDedupThreshold_ReturnsError() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_SuggestionMaxAgeAboveMaximum_ReturnsError() + { + var dir = CreateTempDir(); + try + { + var tooLarge = SuggestionStore.MaximumMaxAgeDays + 1; + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), + $$"""{ "suggestion_max_age_days": {{tooLarge}} }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("suggestion_max_age_days", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Fact] + public void LoadAndApply_SuggestionMaxCountAboveMaximum_ReturnsError() + { + var dir = CreateTempDir(); + try + { + var tooLarge = SuggestionStore.MaximumMaxCount + 1; + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), + $$"""{ "suggestion_max_count": {{tooLarge}} }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("suggestion_max_count", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_AllowsSchemaKeyAndComments() { diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 0512e9e46e..c0994adae0 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CodeIndex.Cli; using CodeIndex.Models; @@ -851,6 +852,44 @@ public void TryAdd_PrunesOldestRecordsOverConfiguredMaxCount() Assert.Contains("First suggestion", File.ReadAllText(Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl"))); } + [Fact] + public void ResolveMaxAge_AcceptsMaximumConfiguredValue() + { + using var env = EnvironmentVariableScope.Capture(SuggestionStore.MaxAgeDaysEnvironmentVariable); + env.Set(SuggestionStore.MaxAgeDaysEnvironmentVariable, SuggestionStore.MaximumMaxAgeDays.ToString(CultureInfo.InvariantCulture)); + + Assert.Equal(TimeSpan.FromDays(SuggestionStore.MaximumMaxAgeDays), SuggestionStore.ResolveMaxAge()); + } + + [Fact] + public void ResolveMaxAge_AboveMaximumUsesDefault() + { + using var env = EnvironmentVariableScope.Capture(SuggestionStore.MaxAgeDaysEnvironmentVariable); + var tooLarge = SuggestionStore.MaximumMaxAgeDays + 1; + env.Set(SuggestionStore.MaxAgeDaysEnvironmentVariable, tooLarge.ToString(CultureInfo.InvariantCulture)); + + Assert.Equal(TimeSpan.FromDays(SuggestionStore.DefaultMaxAgeDays), SuggestionStore.ResolveMaxAge()); + } + + [Fact] + public void ResolveMaxCount_AcceptsMaximumConfiguredValue() + { + using var env = EnvironmentVariableScope.Capture(SuggestionStore.MaxCountEnvironmentVariable); + env.Set(SuggestionStore.MaxCountEnvironmentVariable, SuggestionStore.MaximumMaxCount.ToString(CultureInfo.InvariantCulture)); + + Assert.Equal(SuggestionStore.MaximumMaxCount, SuggestionStore.ResolveMaxCount()); + } + + [Fact] + public void ResolveMaxCount_AboveMaximumUsesDefault() + { + using var env = EnvironmentVariableScope.Capture(SuggestionStore.MaxCountEnvironmentVariable); + var tooLarge = SuggestionStore.MaximumMaxCount + 1; + env.Set(SuggestionStore.MaxCountEnvironmentVariable, tooLarge.ToString(CultureInfo.InvariantCulture)); + + Assert.Equal(SuggestionStore.DefaultMaxCount, SuggestionStore.ResolveMaxCount()); + } + [Fact] public void TryAdd_DuplicateStillPersistsPrunedRecords() { From e5edf6f6a74b36dd1bd3c7d2107f068ed5dbd6fa Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:16:37 +0900 Subject: [PATCH 3/4] Cap global log size settings (#2890) --- DEVELOPER_GUIDE.md | 10 +++- README.md | 2 +- USER_GUIDE.md | 4 +- changelog.d/unreleased/2890.fixed.md | 21 ++++++++ src/CodeIndex/Cli/GlobalToolLog.cs | 14 +++-- src/CodeIndex/Cli/ProgramRunner.cs | 5 +- tests/CodeIndex.Tests/GlobalToolLogTests.cs | 57 +++++++++++++++++++++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 15 ++++++ 8 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/2890.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index fa675a2332..bebbe9d582 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1367,7 +1367,12 @@ logger commits to it, so read-only state/cache/runtime mounts fall through to the next candidate instead of losing the first log write. The file name is `stderr-YYYYMMDD.log`, timestamps inside the file are ISO-8601 UTC (`yyyy-MM-ddTHH:mm:ss.fffZ`) using invariant culture, and the logger keeps -only the newest 30 daily files. Repository-local development runs from +only the newest 30 daily files. `CDIDX_LOG_FORMAT` / `--log-format` switch +between text and JSONL, `CDIDX_LOG_RETAIN` / `--log-retain-count` set retained +file count, and `CDIDX_LOG_MAX_SIZE_MB` / `--log-max-size-mb` or +`CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` set the size-rotation cap. The default size +cap is 50 MiB and accepted values are capped at 1024 MiB / 1 GiB. +Repository-local development runs from `src/CodeIndex/bin/...` and `tests/.../bin/...` are excluded by default so ordinary build/test cycles do not accumulate persistent logs. Set `CDIDX_DISABLE_PERSISTENT_LOG=1` to opt out entirely; the toggle accepts `1`, @@ -2915,7 +2920,8 @@ Linux では `~/.local/state/cdidx/logs/` を使う。ファイル名はプロ `--log-retain-count` は保持ファイル数、`CDIDX_LOG_MAX_SIZE_MB` / `--log-max-size-mb` または `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` は日次ファイルの サイズローテーション上限を指定する。サイズ上限の既定は 50 MiB、保持世代の -既定は新しい 30 ファイルまで。通常の開発/テストサイクルで +既定は新しい 30 ファイルまでで、サイズ上限は最大 1024 MiB / 1 GiB。 +通常の開発/テストサイクルで ワークツリー直下に永続ログが増えないよう、`src/CodeIndex/bin/...` と `tests/.../bin/...` からのリポジトリ内開発実行は既定で対象外として いる。完全に無効化したい場合は `CDIDX_DISABLE_PERSISTENT_LOG=1`、 diff --git a/README.md b/README.md index 0fef9be484..364422fd3c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,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/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. | +| 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. Log size caps are limited to 1024 MiB / 1 GiB. | | 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. Use `--no-progress`, `CDIDX_DISABLE_PROGRESS=1`, or `PREFERS_REDUCED_MOTION` to keep static progress text without animation. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 573924ef79..e77d9aa0ae 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -642,7 +642,7 @@ Default locations are `%LOCALAPPDATA%\cdidx\logs\` on Windows, `~/.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 +`--log-max-size-mb` to tune the size cap up to 1024 MiB / 1 GiB. 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 @@ -1689,7 +1689,7 @@ Default locations are `%LOCALAPPDATA%\cdidx\logs\` on Windows, `~/.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 +`--log-max-size-mb` to tune the size cap up to 1024 MiB / 1 GiB. 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/2890.fixed.md b/changelog.d/unreleased/2890.fixed.md new file mode 100644 index 0000000000..2294f86249 --- /dev/null +++ b/changelog.d/unreleased/2890.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 2890 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - README.md + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Global log size settings now have an upper cap (#2890)** — `--log-max-size-mb`, `CDIDX_LOG_MAX_SIZE_MB`, and `CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` now limit lifecycle log rotation sizes to 1024 MiB / 1 GiB so accidental huge values cannot disable practical rotation. + +## 日本語 + +- **Global log size 設定に上限を設けました (#2890)** — `--log-max-size-mb`、`CDIDX_LOG_MAX_SIZE_MB`、`CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES` は lifecycle log の rotation size を最大 1024 MiB / 1 GiB に制限するようになり、誤って巨大な値を設定しても実質的な rotation が無効化されないようになりました。 diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 99217b9fff..e025e5d27b 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -19,6 +19,8 @@ internal static class GlobalToolLog 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; + internal const int MaxLogSizeMb = 1024; + internal const long MaxLogSizeBytes = MaxLogSizeMb * 1024L * 1024L; private const string RedactedValue = ""; internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; private static readonly AsyncLocal CurrentSession = new(); @@ -623,10 +625,16 @@ public static LogOptions FromEnvironment() retainCount = Math.Clamp(parsedRetain, 1, 10_000); 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) + if (int.TryParse(Environment.GetEnvironmentVariable(LogMaxSizeMbEnvironmentVariable), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMb) && parsedMb > 0) + { + if (parsedMb <= MaxLogSizeMb) + maxSizeBytes = parsedMb * 1024L * 1024L; + } + else if (long.TryParse(Environment.GetEnvironmentVariable(GlobalToolLogMaxBytesEnvironmentVariable), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes) + && parsedBytes is > 0 and <= MaxLogSizeBytes) + { maxSizeBytes = parsedBytes; + } return new LogOptions(format, retainCount, maxSizeBytes); } diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index c81f07965f..3822ce3d7e 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -682,9 +682,10 @@ internal static bool TryConsumeGlobalLogFlags(ref string[] args, out string erro if (TryConsumeValueFlag(args, ref i, arg, "--log-max-size-mb", out var maxSizeMb)) { - if (!int.TryParse(maxSizeMb, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1) + if (!int.TryParse(maxSizeMb, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + || parsed is < 1 or > GlobalToolLog.MaxLogSizeMb) { - error = "--log-max-size-mb must be a positive integer."; + error = $"--log-max-size-mb must be an integer between 1 and {GlobalToolLog.MaxLogSizeMb}."; return false; } Environment.SetEnvironmentVariable(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, parsed.ToString(CultureInfo.InvariantCulture)); diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index 198488a7ea..969b91e4d7 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -63,6 +63,63 @@ public void FormatArgs_AllowsExplicitNoRedaction() Assert.Equal("--token=abc123", formatted); } + [Fact] + public void LogOptionsFromEnvironment_AcceptsMaximumMbValue() + { + using var env = EnvironmentVariableScope.Capture( + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); + env.Set(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, GlobalToolLog.MaxLogSizeMb.ToString(CultureInfo.InvariantCulture)); + env.Set(GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable, null); + + var options = GlobalToolLog.LogOptions.FromEnvironment(); + + Assert.Equal(GlobalToolLog.MaxLogSizeBytes, options.MaxSizeBytes); + } + + [Fact] + public void LogOptionsFromEnvironment_MbAboveMaximumUsesDefault() + { + using var env = EnvironmentVariableScope.Capture( + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); + var tooLarge = GlobalToolLog.MaxLogSizeMb + 1; + env.Set(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, tooLarge.ToString(CultureInfo.InvariantCulture)); + env.Set(GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable, (GlobalToolLog.MaxLogSizeBytes / 2).ToString(CultureInfo.InvariantCulture)); + + var options = GlobalToolLog.LogOptions.FromEnvironment(); + + Assert.Equal(50L * 1024L * 1024L, options.MaxSizeBytes); + } + + [Fact] + public void LogOptionsFromEnvironment_AcceptsMaximumBytesValue() + { + using var env = EnvironmentVariableScope.Capture( + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); + env.Set(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, null); + env.Set(GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable, GlobalToolLog.MaxLogSizeBytes.ToString(CultureInfo.InvariantCulture)); + + var options = GlobalToolLog.LogOptions.FromEnvironment(); + + Assert.Equal(GlobalToolLog.MaxLogSizeBytes, options.MaxSizeBytes); + } + + [Fact] + public void LogOptionsFromEnvironment_BytesAboveMaximumUsesDefault() + { + using var env = EnvironmentVariableScope.Capture( + GlobalToolLog.LogMaxSizeMbEnvironmentVariable, + GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable); + env.Set(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, null); + env.Set(GlobalToolLog.GlobalToolLogMaxBytesEnvironmentVariable, (GlobalToolLog.MaxLogSizeBytes + 1).ToString(CultureInfo.InvariantCulture)); + + var options = GlobalToolLog.LogOptions.FromEnvironment(); + + Assert.Equal(50L * 1024L * 1024L, options.MaxSizeBytes); + } + [Fact] public void ResolveLogDirectoryForStatus_SkipsUnwritableCandidate() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 25d0d1526b..3a885a106b 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Text; using System.Text.Json; @@ -1129,6 +1130,20 @@ public void Run_ForcedGlobalToolLogging_HonorsRetainCountAndSizeRotation() } } + [Fact] + public void Run_LogMaxSizeMbAboveMaximum_ReturnsInvalidArgument() + { + using var env = EnvironmentVariableScope.Capture(GlobalToolLog.LogMaxSizeMbEnvironmentVariable); + var tooLarge = GlobalToolLog.MaxLogSizeMb + 1; + + var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + [$"--log-max-size-mb={tooLarge.ToString(CultureInfo.InvariantCulture)}", "status"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.InvalidArgument, exitCode); + Assert.Contains($"--log-max-size-mb must be an integer between 1 and {GlobalToolLog.MaxLogSizeMb}", stderr); + } + [Fact] public void Run_ForcedGlobalToolLogging_RotatesByDefaultMaxBytesEnvironmentVariable() { From a4770391e34832e8ed07904c1c53ebfda6355369 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:23:31 +0900 Subject: [PATCH 4/4] Cap SQLite performance env settings (#2889) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/2889.fixed.md | 17 ++++++++ src/CodeIndex/Database/DbContext.cs | 22 ++++++++--- tests/CodeIndex.Tests/DatabaseTests.cs | 55 ++++++++++++++++++++++---- 4 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/2889.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index bebbe9d582..0799222347 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -465,8 +465,8 @@ Operators can override the defaults with environment variables: | Variable | Default | Meaning | |---|---:|---| -| `CDIDX_SQLITE_CACHE_KB` | `65536` | Positive cache size in KiB; cdidx applies it as a negative SQLite `cache_size` value so SQLite interprets it as KiB. | -| `CDIDX_SQLITE_MMAP_BYTES` | `268435456` | Non-negative memory-map window in bytes on 64-bit processes. Use `0` to disable mmap. | +| `CDIDX_SQLITE_CACHE_KB` | `65536` | Positive cache size in KiB, up to `1048576`; cdidx applies it as a negative SQLite `cache_size` value so SQLite interprets it as KiB. Invalid or oversized values fall back to the default. | +| `CDIDX_SQLITE_MMAP_BYTES` | `268435456` | Non-negative memory-map window in bytes on 64-bit processes, up to `1073741824`. Use `0` to disable mmap. Invalid or oversized values fall back to the default. | After a successful `cdidx index` run, the writer refreshes SQLite planner statistics so large repositories do not rely on default selectivity estimates for `search`, `references`, `callers`, and related joins. A brand-new index database runs full `ANALYZE` once after the initial population; later successful index runs use SQLite's lighter `PRAGMA optimize`. This maintenance is best-effort and never changes the schema contract. diff --git a/changelog.d/unreleased/2889.fixed.md b/changelog.d/unreleased/2889.fixed.md new file mode 100644 index 0000000000..5d913c8650 --- /dev/null +++ b/changelog.d/unreleased/2889.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2889 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DatabaseTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **SQLite performance environment settings now have upper caps (#2889)** — `CDIDX_SQLITE_CACHE_KB` is limited to 1048576 KiB and `CDIDX_SQLITE_MMAP_BYTES` to 1073741824 bytes, with invalid, overflowing, or oversized values falling back to the documented defaults. + +## 日本語 + +- **SQLite performance 環境変数に上限を設けました (#2889)** — `CDIDX_SQLITE_CACHE_KB` は 1048576 KiB、`CDIDX_SQLITE_MMAP_BYTES` は 1073741824 bytes までに制限され、無効・overflow・過大な値は文書化された既定値へ戻ります。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 435454957c..4d65f2e7c2 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -14,7 +14,9 @@ public class DbContext : IDisposable { public const int ApplicationId = 0x43444958; // "CDIX" public const int DefaultCacheSizeKb = 65536; + public const int MaxCacheSizeKb = 1048576; public const long DefaultMmapSizeBytes = 268435456; + public const long MaxMmapSizeBytes = 1073741824; public const string CacheSizeEnvironmentVariable = "CDIDX_SQLITE_CACHE_KB"; public const string MmapSizeEnvironmentVariable = "CDIDX_SQLITE_MMAP_BYTES"; public const int DefaultWalAutocheckpointPages = 1000; @@ -537,22 +539,30 @@ private void WarnIfBatchInProgress() private void ApplyConnectionPerformancePragmas() { - Execute($"PRAGMA cache_size=-{ReadPositiveIntEnvironment(CacheSizeEnvironmentVariable, DefaultCacheSizeKb)}"); + Execute($"PRAGMA cache_size=-{ReadPositiveIntEnvironment(CacheSizeEnvironmentVariable, DefaultCacheSizeKb, MaxCacheSizeKb)}"); Execute("PRAGMA temp_store=MEMORY"); if (Environment.Is64BitProcess) - Execute($"PRAGMA mmap_size={ReadNonNegativeLongEnvironment(MmapSizeEnvironmentVariable, DefaultMmapSizeBytes)}"); + Execute($"PRAGMA mmap_size={ReadNonNegativeLongEnvironment(MmapSizeEnvironmentVariable, DefaultMmapSizeBytes, MaxMmapSizeBytes)}"); } - private static int ReadPositiveIntEnvironment(string name, int fallback) + private static int ReadPositiveIntEnvironment(string name, int fallback, int maximum) { var value = Environment.GetEnvironmentVariable(name); - return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback; + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + && parsed > 0 + && parsed <= maximum + ? parsed + : fallback; } - private static long ReadNonNegativeLongEnvironment(string name, long fallback) + private static long ReadNonNegativeLongEnvironment(string name, long fallback, long maximum) { var value = Environment.GetEnvironmentVariable(name); - return long.TryParse(value, out var parsed) && parsed >= 0 ? parsed : fallback; + return long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + && parsed >= 0 + && parsed <= maximum + ? parsed + : fallback; } private void ConfigureAutoVacuumForEmptyDatabase() diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index ec36b7573f..2b48550725 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; @@ -1228,27 +1229,65 @@ public void Constructor_ConfiguresConnectionPerformancePragmas() [Fact] public void Constructor_UsesSqlitePerformanceEnvironmentOverrides() + { + AssertSqlitePerformancePragmas("4096", "1048576", 4096, 1048576); + } + + [Fact] + public void Constructor_AcceptsMaximumSqlitePerformanceEnvironmentOverrides() + { + AssertSqlitePerformancePragmas( + DbContext.MaxCacheSizeKb.ToString(CultureInfo.InvariantCulture), + DbContext.MaxMmapSizeBytes.ToString(CultureInfo.InvariantCulture), + DbContext.MaxCacheSizeKb, + DbContext.MaxMmapSizeBytes); + } + + [Fact] + public void Constructor_SqlitePerformanceEnvironmentAboveMaximumUsesDefaults() + { + AssertSqlitePerformancePragmas( + (DbContext.MaxCacheSizeKb + 1).ToString(CultureInfo.InvariantCulture), + (DbContext.MaxMmapSizeBytes + 1).ToString(CultureInfo.InvariantCulture), + DbContext.DefaultCacheSizeKb, + DbContext.DefaultMmapSizeBytes); + } + + [Fact] + public void Constructor_SqlitePerformanceEnvironmentOverflowUsesDefaults() + { + AssertSqlitePerformancePragmas( + "2147483648", + "9223372036854775808", + DbContext.DefaultCacheSizeKb, + DbContext.DefaultMmapSizeBytes); + } + + private static void AssertSqlitePerformancePragmas( + string cacheSizeValue, + string mmapSizeValue, + long expectedCacheSizeKb, + long expectedMmapSizeBytes) { lock (TestConsoleLock.Gate) { var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_perf_pragmas_{Guid.NewGuid():N}.db"); - var previousCacheSize = Environment.GetEnvironmentVariable(DbContext.CacheSizeEnvironmentVariable); - var previousMmapSize = Environment.GetEnvironmentVariable(DbContext.MmapSizeEnvironmentVariable); + using var env = EnvironmentVariableScope.Capture( + DbContext.CacheSizeEnvironmentVariable, + DbContext.MmapSizeEnvironmentVariable); try { - Environment.SetEnvironmentVariable(DbContext.CacheSizeEnvironmentVariable, "4096"); - Environment.SetEnvironmentVariable(DbContext.MmapSizeEnvironmentVariable, "1048576"); + env.Set(DbContext.CacheSizeEnvironmentVariable, cacheSizeValue); + env.Set(DbContext.MmapSizeEnvironmentVariable, mmapSizeValue); using var db = new DbContext(dbPath); - Assert.Equal(-4096L, ExecuteScalarLong(db.Connection, "PRAGMA cache_size")); + Assert.Equal(-expectedCacheSizeKb, ExecuteScalarLong(db.Connection, "PRAGMA cache_size")); if (Environment.Is64BitProcess) - Assert.Equal(1048576L, ExecuteScalarLong(db.Connection, "PRAGMA mmap_size")); + Assert.Equal(expectedMmapSizeBytes, ExecuteScalarLong(db.Connection, "PRAGMA mmap_size")); } finally { - Environment.SetEnvironmentVariable(DbContext.CacheSizeEnvironmentVariable, previousCacheSize); - Environment.SetEnvironmentVariable(DbContext.MmapSizeEnvironmentVariable, previousMmapSize); SqliteConnection.ClearAllPools(); if (File.Exists(dbPath)) File.Delete(dbPath);