From 2feaba84b9db4c9f54615b8542864646cfa65c0f Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:06:44 +0900 Subject: [PATCH] Fix post-commit usage cleanup and preserve query outcomes --- PRIVACY.md | 2 +- README.md | 4 +- docs/manual-accounts-design.md | 6 +- docs/query-cleanup-delivery.md | 26 +++++ src/AccountManagerForm.cs | 52 +++++++-- src/AppServerClient.cs | 3 +- src/CodexAccountRuntime.cs | 31 ++++++ src/CodexAccountStore.cs | 6 +- src/CodexAccountUsageQuery.cs | 103 ++++++++++++++++-- src/WeeklyUsageIndicator.csproj | 2 +- .../AccountUsageQueryTests.cs | 84 ++++++++++++++ .../AccountUsageUiSmoke.cs | 33 +++++- .../AppServerLifecycleTests.cs | 39 +++++++ .../LiveAccountUsageSmoke.cs | 5 +- .../LiveUsageCleanupSmoke.cs | 53 +++++++++ tests/WeeklyUsageIndicator.Tests/Program.cs | 5 + 16 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 docs/query-cleanup-delivery.md create mode 100644 tests/WeeklyUsageIndicator.Tests/LiveUsageCleanupSmoke.cs diff --git a/PRIVACY.md b/PRIVACY.md index eea6a5d..ceb5747 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -18,6 +18,6 @@ Codex Desktop, its local app-server, Claude Code, and Anthropic's API remain gov Optional Codex account management reads local Codex `auth.json` for explicit registration, switching, and identity checks while refreshing registered-account usage. Replacement happens only on an explicit switch. Additional accounts use official Codex browser login in a restricted temporary home; temporary credentials are removed after import or cancellation. The separate account vault and recovery transaction are DPAPI encrypted and ACL restricted to the Windows user; the login staging directory also permits SYSTEM. They are never sent to this project's developers. DPAPI protects data at rest; it does not protect against other software already running as the same Windows user. The active Codex credential remains owned by the live authentication file: saved snapshots never override its newer tokens during recovery. Uninstall intentionally preserves the separate account vault to avoid losing saved logins. -An explicit **사용량 조회** request for an inactive account temporarily places that account's credential in a separate, user-only private home for the official local app-server. It never replaces the active login. The helper may refresh authentication while reading usage; after its confirmed exit, updated credentials are atomically saved back to the DPAPI vault even on request failure or cancellation. A DPAPI recovery journal retains the selected commit credentials before the vault write, including recovery interrupted by another crash. Temporary plaintext files are removed after verified persistence. If the process crashes or cleanup cannot finish, the restricted staging directory and journal remain until **중단된 조회 복구** completes after Codex writers stop. Each account retains only its latest successful percentages, reset times, and check time; the application does not attribute usage to people or retain a usage timeline. +An explicit **사용량 조회** request for an inactive account temporarily places that account's credential in a separate, user-only private home for the official local app-server. It never replaces the active login. The helper may refresh authentication while reading usage; after its confirmed exit, updated credentials are atomically saved back to the DPAPI vault even on request failure or cancellation. A DPAPI recovery journal retains the selected commit credentials before the vault write, including recovery interrupted by another crash. Temporary plaintext files are removed after verified persistence. An interruption before credential commit retains staging and requires **중단된 조회 복구** after Codex writers stop. If only post-commit file cleanup remains, **임시 파일 정리** can retry while Codex stays open without modifying authentication or the vault. A failed cleanup retains only a safe error category and numeric code in the encrypted journal; raw exception text and paths are not logged. Each account retains only its latest successful percentages, reset times, and check time; the application does not attribute usage to people or retain a usage timeline. When reporting a bug, do not attach Codex or Claude logs, credential/configuration files, tokens, or screenshots containing information you do not want to publish. diff --git a/README.md b/README.md index 2677ad6..a40a14b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ Select the saved account and click **이 계정으로 전환**. The preparation Select any registered account and click **사용량 조회** to fetch its latest weekly and 5-hour remaining usage, reset times, and last successful check time. **목록 갱신** only reloads saved values. Inactive accounts are queried only on an explicit click, using a short-lived official app-server in a private isolated home. Codex Desktop and the current account stay signed in; the current widget helper is not suspended. Active-account queries reuse that helper and require Codex Desktop to be open. **조회 취소**, a timeout, or a failed request preserves the last successful usage observation. Refreshed credentials are saved even if the usage request fails or is canceled. Expired logins can be renewed through **+ 다른 계정 추가** using the same inactive account; for the active account, sign in again in Codex. -Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account background polling, quota pooling, usage history, or per-person attribution. Values past their reset time are marked **갱신 필요**. A pending switch transaction blocks polling until **미완료 전환 복구** reconciles it with actual live authentication. A query interrupted before credential cleanup offers **중단된 조회 복구**; finish Codex work and close remaining Codex writers before this exceptional recovery. Its encrypted journal preserves refreshed credentials before staging is removed. Recovery never overwrites newer live authentication. The management window supports display scaling, and its details scroll when the window is made smaller. +Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account background polling, quota pooling, usage history, or per-person attribution. Values past their reset time are marked **갱신 필요**. A pending switch transaction blocks polling until **미완료 전환 복구** reconciles it with actual live authentication. A query interrupted before credentials are safely saved offers **중단된 조회 복구**; finish Codex work and close remaining Codex writers before this exceptional recovery. Its encrypted journal preserves refreshed credentials before staging is removed. Recovery never overwrites newer live authentication. + +If credentials are already saved and only temporary files remain, the manager instead shows **임시 파일 정리 대기** with an **임시 파일 정리** button. This cleanup can run while Codex stays open and does not block account editing or switching. The next inactive usage request also retries cleanup before starting. A temporary file lock is retried automatically; a persistent failure displays its category and code. Cleanup does not turn a failed or canceled usage request into a success, and periodic list refresh preserves the original result. The management window supports display scaling, and its details scroll when the window is made smaller. The first version supports local Windows file-based ChatGPT authentication. Unsupported keyring/managed configurations fail closed. The vault is stored separately at `%LOCALAPPDATA%\CodexWeeklyUsageIndicator.Accounts`; uninstall preserves it. Delete inactive accounts from the manager before removing the app if you no longer want their saved credentials. This convenience tool does not establish that any particular multi-account usage pattern is permitted by the service terms. diff --git a/docs/manual-accounts-design.md b/docs/manual-accounts-design.md index dee8a75..440d400 100644 --- a/docs/manual-accounts-design.md +++ b/docs/manual-accounts-design.md @@ -10,7 +10,11 @@ Inactive requests hold the existing process-wide mutex and vault file lock on on Rate-limit reads can implicitly refresh authentication even with `account/read.refreshToken=false`. Query staging therefore has a separate encrypted journal, never the disposable `login-*` cleanup path. The owned helper uses a non-breakaway kill-on-close Job and must exit before credential read-back. Success, protocol failure, and cancellation all save its validated same-account credentials before removing staging. Shutdown uncertainty retains staging and journal. Before writing the vault, the chosen auth and expected prior digest are durably written to the encrypted journal; recovery can be interrupted repeatedly and the desktop can independently change accounts without reverting a committed refresh. Active live authentication remains authoritative and is never written by this path. -Crash recovery is explicit and conservatively requires all potential Codex writers to exit, accounting for the narrow process-start/Job-assignment interval. A pending query is announced on startup and blocks account mutations, while active-account usage polling remains available. Missing/corrupt credentials or inconsistent vault revisions preserve recovery evidence and fail closed. +Before credential commit, crash recovery is explicit and conservatively requires all potential Codex writers to exit, accounting for the narrow process-start/Job-assignment interval. This state blocks account mutations while active-account usage polling remains available. Missing/corrupt credentials or inconsistent vault revisions preserve recovery evidence and fail closed. + +After the vault write and credential read-back, a committed journal means only temporary-file cleanup remains; it does not imply a successful usage request. Version 1.6.1 keeps that state separate from authentication recovery. Cleanup never reads or rewrites the vault or live auth, so account rename/removal and active snapshot saves remain available. The explicit cleanup button does not suspend Desktop or its helper. The next inactive query first retries cleanup and starts only after the fixed home is reclaimed. Cleanup retries I/O failures over a bounded 1.5-second backoff, revalidating the tree each time; path-validation and access errors remain visible. Only a safe failure category and numeric code are retained. Original query success, failure, and cancellation messages survive periodic list refresh. + +The isolated helper disables plugin and bundled-skill startup work through supported configuration overrides. Shutdown retains its Job handle, terminates the owned process group, confirms zero active descendants, then waits for the parent and output readers before releasing ownership. Process exit and successful file cleanup remain separate checks. ## Authentication and recovery diff --git a/docs/query-cleanup-delivery.md b/docs/query-cleanup-delivery.md new file mode 100644 index 0000000..e2329e6 --- /dev/null +++ b/docs/query-cleanup-delivery.md @@ -0,0 +1,26 @@ +# Usage query cleanup fix — 1.6.1 + +A successful manual usage refresh in 1.6.0 could leave a committed query journal when temporary-file deletion failed. The manager treated every remaining journal as authentication recovery, disabled account actions, and replaced the original error with a generic banner during reload. + +## Diagnosis and decision + +The reported installation had a committed encrypted journal, matching saved credentials, and a new usage observation. Staged authentication had already been removed; remaining files included SQLite and plugin startup artifacts. The original deletion error was overwritten, so its exact file or locking process is not established. + +Two inherited reviewers examined transaction semantics and helper lifetime. A fixed staging home with separate pre-commit recovery and post-commit cleanup was selected. A unique-home cleanup queue would introduce additional credential-bearing directories and migration states without evidence that they are necessary. Disabling startup work alone cannot address unrelated transient file locks, so it accompanies bounded deletion retries and explicit descendant shutdown verification. + +Committed cleanup never reloads or replays saved authentication, including after the target is renamed or removed. Query outcomes and cleanup notices are independent; committed credentials do not prove a successful usage read. Only pre-commit recovery requires all Codex writers to stop. + +A fresh reviewer inspected the actual candidate without the chair's preferred conclusion and found no remaining P0. The review checked blocking-state classification, possible replay after account deletion, and descendant shutdown with file cleanup. The original locking cause remains unconfirmed. + +## Validation + +- The required release build passed all 25 regression groups, with no compiler warnings or errors. The release EXE passed the username and absolute build-path scan in UTF-8 and UTF-16. +- File-lock cases cover automatic retry, persistent cleanup on success/failure/cancel, original-result preservation, account mutation during cleanup, and rejection of a new inactive query until its fixed home is reclaimed. +- A fake app-server leaves an independently running child holding a staging file after the parent exits. Job shutdown and the production cleanup path must terminate the child and remove staging. +- Native manager tests check enabled actions, a separate cleanup button with no query/suspend/resume, and preservation of the original failure through the five-second reload. Existing focus and account recovery coverage remains in the suite. +- An explicit live upgrade check cleaned the existing 1.6.0 committed journal through the new manager button while Desktop remained running. Both live authentication and the encrypted vault stayed byte-for-byte unchanged, with no query or helper restart. +- A second explicit live check queried the selected inactive account through the manager. A new observation was saved, current authentication and active snapshot remained unchanged, Desktop stayed running, and the helper/staging/journal were removed. + +## Release and rollback + +Build the complete test suite, review the actual change, and verify the GitHub draft EXE against both SHA256SUMS and the ZIP before installation and publication. Keep the existing interactive per-user scheduled-task installer and its final-path guard. Version 1.6.1 reads existing version-1 query journals; resolve pending recovery or cleanup before downgrading. No vault schema migration is required. diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs index aeb11c7..f9eb965 100644 --- a/src/AccountManagerForm.cs +++ b/src/AccountManagerForm.cs @@ -40,6 +40,10 @@ internal sealed class AccountManagerForm : Form private bool _busy; private bool _reloading; private string? _desktopPath; + private UsageQueryStatus _usageQueryStatus = new(UsageQueryState.None); + private string? _operationMessage; + private bool _operationError; + private bool _operationSuccess; internal bool IsOperationInProgress => _busy; private SavedCodexAccount? Selected => _accounts.SelectedItem as SavedCodexAccount; @@ -59,7 +63,7 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re var root = AccountUiTheme.Stack(4); root.Padding = new Padding(24); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 76)); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 78)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 100)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); @@ -295,6 +299,18 @@ private void DeleteSelected() private async Task RecoverAsync() { + if (!_store.HasPendingRecovery && _usageQueryStatus.State == UsageQueryState.CleanupPending) + { + await RunAsync(false, "임시 파일을 정리하고 있습니다…", async () => + { + await Task.Run(_store.RetryUsageCleanup); + Reload(); + SetStatus(_usageQueryStatus.State == UsageQueryState.None ? "임시 파일 정리를 마쳤습니다." + : "임시 파일을 아직 정리하지 못했습니다. 잠시 후 다시 시도하세요.", + success: _usageQueryStatus.State == UsageQueryState.None); + }, acquireGate: false); + return; + } await RunAsync(true, "복구 조건을 확인하고 있습니다…", async () => { using var dialog = new AccountSwitchDialog("", "", recovery: true); @@ -358,12 +374,11 @@ private bool Reload(string? selectId = null, bool quiet = false) } _count.Text = $"계정 {_items.Count}개"; _empty.Visible = _items.Count == 0; _detail.Visible = _items.Count > 0; + _usageQueryStatus = _store.GetUsageQueryStatus(); + if (!quiet) { _operationMessage = null; _operationError = false; _operationSuccess = false; } ShowSelected(); UpdateActions(); - if (_store.HasPendingRecovery) SetStatus("미완료 전환이 있습니다. 복구를 완료하면 다시 사용할 수 있습니다.", error: true); - else if (_store.HasPendingUsageQuery) SetStatus("중단된 사용량 조회가 있습니다. 복구하여 로그인 정보를 보존해 주세요.", error: true); - else if (_items.Count > 0 && !_items.Any(a => a.IsActive)) SetStatus("현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요."); - else if (!quiet && _items.Count == 0) SetStatus("현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다."); - return !_store.HasPendingRecovery && !_store.HasPendingUsageQuery && (_items.Count == 0 || _items.Any(a => a.IsActive)); + RenderStatus(); + return !_store.HasPendingRecovery && _usageQueryStatus.State != UsageQueryState.RecoveryRequired && (_items.Count == 0 || _items.Any(a => a.IsActive)); } catch (Exception ex) { SetStatus(ex.Message, error: true); return false; } } @@ -391,7 +406,7 @@ private void ShowSelected() private void UpdateActions() { - var pending = _store.HasPendingRecovery || _store.HasPendingUsageQuery; + var pending = _store.HasPendingRecovery || _usageQueryStatus.State == UsageQueryState.RecoveryRequired; _accounts.Enabled = !_busy; _refresh.Enabled = !_busy; _readUsage.Enabled = !_busy && !pending && Selected is not null; @@ -402,14 +417,29 @@ private void UpdateActions() _rename.Enabled = !_busy && !pending && Selected is not null; _switch.Enabled = !_busy && !pending && _items.Any(a => a.IsActive) && Selected is { IsActive: false }; _delete.Enabled = !_busy && !pending && Selected is { IsActive: false }; - _recover.Visible = pending; _recover.Enabled = !_busy; - _recover.Text = _store.HasPendingUsageQuery ? "중단된 조회 복구" : "미완료 전환 복구"; + _recover.Visible = pending || _usageQueryStatus.State == UsageQueryState.CleanupPending; _recover.Enabled = !_busy; + _recover.Text = _store.HasPendingRecovery ? "미완료 전환 복구" : _usageQueryStatus.State == UsageQueryState.CleanupPending ? "임시 파일 정리" : "중단된 조회 복구"; } private void SetStatus(string message, bool error = false, bool success = false) { - _status.Text = message; - _status.ForeColor = error ? AccountUiTheme.Error : success ? AccountUiTheme.Accent : AccountUiTheme.Text; + _operationMessage = message; _operationError = error; _operationSuccess = success; + RenderStatus(); + } + + private void RenderStatus() + { + var recovery = _store.HasPendingRecovery ? "미완료 전환이 있습니다. 복구를 완료해 주세요." + : _usageQueryStatus.State == UsageQueryState.RecoveryRequired ? "중단된 조회의 로그인 정보 복구가 필요합니다." : null; + var cleanup = _usageQueryStatus.State == UsageQueryState.CleanupPending + ? "로그인 정보 저장 완료 · 임시 파일 정리 대기" + (_usageQueryStatus.Failure is { } failure ? $" ({failure.Summary})" : "") : null; + var fallback = _items.Count == 0 ? "현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다." + : !_items.Any(a => a.IsActive) ? "현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요." + : "계정을 선택해 상태를 확인하세요."; + _status.Text = string.Join("\n", new[] { _operationMessage, recovery, cleanup }.Where(text => text is not null)); + if (_status.Text.Length == 0) _status.Text = fallback; + _status.ForeColor = _operationError || recovery is not null ? AccountUiTheme.Error + : _operationSuccess && cleanup is null ? AccountUiTheme.Accent : AccountUiTheme.Text; } protected override void Dispose(bool disposing) diff --git a/src/AppServerClient.cs b/src/AppServerClient.cs index 401915b..4ae92ff 100644 --- a/src/AppServerClient.cs +++ b/src/AppServerClient.cs @@ -263,8 +263,8 @@ private async Task StopSessionAsync() var session = _session; if (session is null) return; session.Lifetime.Cancel(); - session.Job?.Dispose(); try { session.Process.StandardInput.Close(); } catch { } + if (session.Job is not null) await session.Job.TerminateAndWaitAsync().ConfigureAwait(false); try { if (!session.Process.HasExited) session.Process.Kill(entireProcessTree: true); @@ -279,6 +279,7 @@ private async Task StopSessionAsync() throw new IOException("Codex helper did not exit. Account switching is blocked."); } await Task.WhenAll(session.Reader, session.ErrorReader).WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); + session.Job?.Dispose(); session.Process.Dispose(); session.Lifetime.Dispose(); _session = null; diff --git a/src/CodexAccountRuntime.cs b/src/CodexAccountRuntime.cs index 16ce5df..362e641 100644 --- a/src/CodexAccountRuntime.cs +++ b/src/CodexAccountRuntime.cs @@ -340,6 +340,37 @@ internal void Attach(Process ownedProcess) public void Dispose() => _handle.Dispose(); + internal async Task TerminateAndWaitAsync() + { + // Keep the handle open to confirm that every owned descendant has exited. + if (!TerminateJobObject(_handle, 1)) throw new IOException("조회 프로세스 그룹을 종료하지 못했습니다."); + var deadline = Stopwatch.StartNew(); + while (true) + { + if (!QueryInformationJobObject(_handle, 1, out var info, (uint)Marshal.SizeOf(), IntPtr.Zero)) + throw new IOException("조회 프로세스 그룹의 종료 상태를 확인하지 못했습니다."); + if (info.ActiveProcesses == 0) return; + if (deadline.Elapsed >= TimeSpan.FromSeconds(8)) throw new IOException("조회 프로세스 그룹의 종료 시간이 초과되었습니다."); + await Task.Delay(25).ConfigureAwait(false); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct BasicAccountingInformation + { + internal long TotalUserTime, TotalKernelTime, ThisPeriodTotalUserTime, ThisPeriodTotalKernelTime; + internal uint TotalPageFaultCount, TotalProcesses, ActiveProcesses, TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryInformationJobObject(SafeFileHandle job, int infoClass, + out BasicAccountingInformation info, uint length, IntPtr returnLength); + [StructLayout(LayoutKind.Sequential)] private struct BasicLimitInformation { diff --git a/src/CodexAccountStore.cs b/src/CodexAccountStore.cs index ad4d9fc..37ad0aa 100644 --- a/src/CodexAccountStore.cs +++ b/src/CodexAccountStore.cs @@ -202,11 +202,12 @@ public void Remove(string id) public void SaveUsage(string identityKey, UsageSnapshot snapshot) { // An isolated query owns the vault briefly; live polling may keep its in-memory value. - if (!IsEnabled || HasPendingRecovery || HasPendingUsageQuery) return; + if (!IsEnabled || HasPendingRecovery) return; IDisposable gate; try { gate = AcquireLock(); } catch (AccountStoreBusyException) { return; } using var ownedGate = gate; + if (GetUsageQueryStatusCore().State == UsageQueryState.RecoveryRequired) return; RequireNoRecovery(); if (GetCurrentIdentity().Key != identityKey) return; var vault = LoadVault(); @@ -294,7 +295,8 @@ private static Vault Clone(Vault vault) => JsonSerializer.Deserialize( private void RequireNoRecovery() { if (HasPendingRecovery) throw new InvalidOperationException("미완료 계정 교체를 먼저 복구하세요."); - if (HasPendingUsageQuery) throw new InvalidOperationException("중단된 사용량 조회를 먼저 복구하세요."); + if (GetUsageQueryStatusCore().State == UsageQueryState.RecoveryRequired) + throw new InvalidOperationException("중단된 사용량 조회를 먼저 복구하세요."); } private void RequireSameAuth(byte[] expected) diff --git a/src/CodexAccountUsageQuery.cs b/src/CodexAccountUsageQuery.cs index aec201e..2410e87 100644 --- a/src/CodexAccountUsageQuery.cs +++ b/src/CodexAccountUsageQuery.cs @@ -10,6 +10,32 @@ internal sealed partial class CodexAccountStore private string UsageHome => Path.Combine(RootPath, "usage-query"); public bool HasPendingUsageQuery => File.Exists(UsageJournalPath); + internal UsageQueryStatus GetUsageQueryStatus() + { + if (!HasPendingUsageQuery) return new(UsageQueryState.None); + using var gate = AcquireLock(); + return GetUsageQueryStatusCore(); + } + + private UsageQueryStatus GetUsageQueryStatusCore() + { + if (!HasPendingUsageQuery) return new(UsageQueryState.None); + var journal = ReadEncrypted(UsageJournalPath); + ValidateUsageJournal(journal); + return new(journal.Committed ? UsageQueryState.CleanupPending : UsageQueryState.RecoveryRequired, + journal.CleanupFailure); + } + + internal void RetryUsageCleanup() + { + using var gate = AcquireLock(); + if (!HasPendingUsageQuery) return; + var journal = ReadEncrypted(UsageJournalPath); + ValidateUsageJournal(journal); + if (!journal.Committed) throw new InvalidOperationException("로그인 정보 복구를 먼저 완료해 주세요."); + TryCleanupUsageQuery(journal); + } + // The worker owns the thread-affine Windows mutex for the entire transaction. // Never await inside this method. No API on this path writes the live auth file. internal void QueryInactiveUsage(string id, Func> read, @@ -19,6 +45,12 @@ internal void QueryInactiveUsage(string id, Func(UsageJournalPath)); + if (HasPendingUsageQuery) + throw new InvalidOperationException("이전 임시 파일을 정리하지 못해 새 조회를 시작하지 못했습니다. 잠시 후 정리를 다시 시도하세요."); + } var vault = LoadVault(); var entry = vault.Accounts.SingleOrDefault(a => a.Id == id) ?? throw CorruptStore(); if (File.Exists(AuthPath) && GetCurrentIdentity().Key == entry.Key) @@ -54,20 +86,20 @@ private void RecoverUsageQueryCore(Action assertStopped) { // A parent crash can race the Job's process termination. In this exceptional // path require all potential writers to be gone before reclaiming any auth. - assertStopped(); var journal = ReadEncrypted(UsageJournalPath); + ValidateUsageJournal(journal); + if (!journal.Committed) assertStopped(); CompleteUsageQuery(journal, null, recovery: true); } private void CompleteUsageQuery(UsageQueryJournal journal, UsageSnapshot? usage, bool recovery) { - if (journal.Version != 1 || !Guid.TryParseExact(journal.AccountId, "N", out _) || - journal.BeforeDigest.Length != 64) throw CorruptStore(); - var vault = LoadVault(); - var entry = vault.Accounts.SingleOrDefault(a => a.Id == journal.AccountId && a.Key == journal.Key) - ?? throw CorruptStore(); + ValidateUsageJournal(journal); if (!journal.Committed) { + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == journal.AccountId && a.Key == journal.Key) + ?? throw CorruptStore(); var stageAuthPath = Path.Combine(UsageHome, "auth.json"); byte[]? refreshed = journal.CommitAuth ?? (File.Exists(stageAuthPath) ? ReadBounded(stageAuthPath, MaxAuthBytes) : null); if (journal.Prepared && refreshed is null) throw CorruptStore(); @@ -106,8 +138,44 @@ private void CompleteUsageQuery(UsageQueryJournal journal, UsageSnapshot? usage, journal = journal with { Committed = true }; WriteEncrypted(UsageJournalPath, journal); } - DeleteUsageHome(); - DeleteChecked(UsageJournalPath); + TryCleanupUsageQuery(journal); + } + + private static void ValidateUsageJournal(UsageQueryJournal journal) + { + if (journal.Version != 1 || !Guid.TryParseExact(journal.AccountId, "N", out _) || + journal.BeforeDigest?.Length != 64 || journal.Key?.Length != 64) throw CorruptStore(); + } + + private void TryCleanupUsageQuery(UsageQueryJournal journal) + { + ValidateUsageJournal(journal); + if (!journal.Committed) throw CorruptStore(); + // Committed means auth was saved and read back after confirmed writer exit. + // Never load the vault or replay CommitAuth here: the account may since have changed or been removed. + for (var attempt = 0; ; attempt++) + { + try + { + DeleteUsageHome(); + DeleteChecked(UsageJournalPath); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + if (ex is IOException && attempt < 4) + { + Thread.Sleep(new[] { 100, 200, 400, 800 }[attempt]); + continue; + } + // Preserve only a category/code, never an exception message, path, or auth contents. + var kind = ex is InvalidOperationException ? "validation" : ex is UnauthorizedAccessException ? "access" : "io"; + var pending = journal with { CleanupFailure = new(kind, ex.HResult) }; + try { WriteEncrypted(UsageJournalPath, pending); } + catch (Exception writeFailure) when (writeFailure is IOException or UnauthorizedAccessException) { } + return; // Cleanup cannot turn a durable commit into a failed query or hide its original failure. + } + } } private void DeleteUsageHome() @@ -128,15 +196,24 @@ void Collect(string path) } // Fixed direct child of the validated vault. Inspect before deleting; never follow a link. Collect(UsageHome); - foreach (var path in files) DeleteChecked(path); - foreach (var path in directories.AsEnumerable().Reverse()) Directory.Delete(path, false); + var auth = Path.Combine(UsageHome, "auth.json"); + foreach (var path in files.OrderBy(path => string.Equals(path, auth, StringComparison.OrdinalIgnoreCase) ? 0 : 1)) DeleteChecked(path); + foreach (var path in directories.AsEnumerable().Reverse()) { RejectReparsePath(path); Directory.Delete(path, false); } + RejectReparsePath(UsageHome); Directory.Delete(UsageHome, false); } internal sealed record UsageQueryJournal(int Version, string AccountId, string Key, string BeforeDigest, bool Committed, bool Prepared, - byte[]? CommitAuth = null); + byte[]? CommitAuth = null, UsageCleanupFailure? CleanupFailure = null); } +internal enum UsageQueryState { None, RecoveryRequired, CleanupPending } +internal sealed record UsageCleanupFailure(string Kind, int Code) +{ + internal string Summary => $"{(Kind == "validation" ? "정리 경로 확인 필요" : Kind == "access" ? "파일 접근 제한" : (Code & 0xffff) is 32 or 33 ? "파일 사용 중" : "파일 정리 오류")} · 0x{Code:X8}"; +} +internal sealed record UsageQueryStatus(UsageQueryState State, UsageCleanupFailure? Failure = null); + internal sealed class UsageHelperShutdownException : IOException { internal UsageHelperShutdownException() : base("조회 프로세스 종료를 확인하지 못했습니다. 조회 복구를 실행해 주세요.") { } @@ -168,6 +245,10 @@ internal static System.Diagnostics.ProcessStartInfo CreateStartInfo(string home) start.ArgumentList.Add("--stdio"); start.ArgumentList.Add("-c"); start.ArgumentList.Add("cli_auth_credentials_store=\"file\""); + start.ArgumentList.Add("-c"); + start.ArgumentList.Add("features.plugins=false"); + start.ArgumentList.Add("-c"); + start.ArgumentList.Add("skills.bundled.enabled=false"); return start; } } diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index feef669..9e5ca26 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.6.0 + 1.6.1 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs index 764ed56..db4589f 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs @@ -12,6 +12,9 @@ internal static async Task RunAsync() await IdentityRaceAsync(); await ExclusiveAsync(); await UnconfirmedShutdownAsync(); + await TransientCleanupAsync(); + foreach (var outcome in new[] { "success", "failure", "cancel" }) await PendingCleanupAsync(outcome); + await PendingCleanupThenQueryAsync(); } private static async Task RotationAsync(string outcome) @@ -151,6 +154,87 @@ await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, ( Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "rotated")), "recovery after confirmed stop preserves rotated auth"); } + private static async Task TransientCleanupAsync() + { + using var f = new Fixture(); + Task? unlock = null; + await Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + var held = HoldFile(home); + unlock = Task.Run(async () => { await Task.Delay(300); held.Dispose(); }); + return Task.FromResult(Result()); + }, CancellationToken.None)); + await unlock!; + Check(!f.Store.HasPendingUsageQuery, "short-lived file locks are retried to completion"); + } + + private static async Task PendingCleanupAsync(string outcome) + { + using var f = new Fixture(); + FileStream? held = null; + var original = File.ReadAllBytes(f.LiveAuth); + try + { + Exception? error = null; + try + { + await Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + held = HoldFile(home); + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth("b", "rotated")); + if (outcome == "failure") throw new IOException("original request failure"); + if (outcome == "cancel") throw new OperationCanceledException("original cancellation"); + return Task.FromResult(Result()); + }, CancellationToken.None)); + } + catch (Exception ex) { error = ex; } + Check(outcome == "success" ? error is null : outcome == "failure" ? error?.Message == "original request failure" + : error is OperationCanceledException, "cleanup must preserve the original request outcome"); + var status = f.Store.GetUsageQueryStatus(); + Check(status.State == UsageQueryState.CleanupPending && status.Failure?.Kind == "io", "durable commit has a separate safe cleanup diagnosis"); + Check(!File.Exists(Path.Combine(f.Store.RootPath, "usage-query", "auth.json")), "plaintext auth is removed before other locked files"); + var saved = f.Store.ListAccounts().Single(a => a.Id == f.Target.Id); + Check(outcome == "success" ? saved.ObservedAt is not null : saved.ObservedAt is null, "committed auth alone never claims a usage observation"); + f.Store.Rename(f.Target.Id, "Renamed"); + f.Store.SaveUsage(f.Store.GetCurrentIdentity().Key, Result().Usage); + Check(f.Store.ListAccounts().Single(a => a.IsActive).ObservedAt is not null, "cleanup does not block active snapshot saves"); + f.Store.Remove(f.Target.Id); + f.Store.RegisterCurrent("Current renamed"); + var vaultBefore = File.ReadAllBytes(Path.Combine(f.Store.RootPath, "accounts.dpapi")); + held!.Dispose(); held = null; + // Reopen the durable journal, just as the application does after an upgrade. + var reopened = new CodexAccountStore(f.Store.RootPath, f.Home); + reopened.Recover(() => throw new Exception("cleanup must not require Desktop shutdown")); + Check(!reopened.HasPendingUsageQuery && !Directory.Exists(Path.Combine(f.Store.RootPath, "usage-query")), "cleanup works after its target account was removed"); + Check(File.ReadAllBytes(Path.Combine(f.Store.RootPath, "accounts.dpapi")).SequenceEqual(vaultBefore) + && File.ReadAllBytes(f.LiveAuth).SequenceEqual(original), "cleanup never rewrites vault or live auth"); + } + finally { held?.Dispose(); } + } + + private static async Task PendingCleanupThenQueryAsync() + { + using var f = new Fixture(); + FileStream? held = null; + try + { + await Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { held = HoldFile(home); return Task.FromResult(Result()); }, CancellationToken.None)); + var calls = 0; + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (_, _) => + { calls++; return Task.FromResult(Result()); }, CancellationToken.None))); + Check(calls == 0, "a pending fixed home is never reused while cleanup still fails"); + held!.Dispose(); held = null; + await Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (_, _) => + { calls++; return Task.FromResult(Result()); }, CancellationToken.None)); + Check(calls == 1 && !f.Store.HasPendingUsageQuery, "next explicit query first reclaims old staging"); + } + finally { held?.Dispose(); } + } + + internal static FileStream HoldFile(string home) => new(Path.Combine(home, "locked.sqlite"), FileMode.Create, + FileAccess.ReadWrite, FileShare.None); + internal static CodexAccountUsage Result() => new(new UsageSnapshot(27, DateTimeOffset.UtcNow.AddDays(2), 10080, "codex", new UsageWindow(13, DateTimeOffset.UtcNow.AddHours(2), 300)), "b@example.invalid", "pro", true); internal static byte[] Auth(string user, string revision) => (byte[])typeof(AccountStoreTests) diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs index c86b9b1..22a7b32 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs @@ -25,12 +25,15 @@ internal static Task RunAsync() var target = store.ImportLoginFile(import, "Pro B · 저장된 계정"); var current = store.ListAccounts().Single(a => a.IsActive); var calls = 0; var restarts = 0; var mode = "success"; + FileStream? held = null; using var form = new AccountManagerForm(store, () => { restarts++; return Task.CompletedTask; }, () => restarts++, - queryUsage: (account, token) => Task.Run(() => store.QueryInactiveUsage(account.Id, async (_, ct) => + queryUsage: (account, token) => Task.Run(() => store.QueryInactiveUsage(account.Id, async (stage, ct) => { Interlocked.Increment(ref calls); + if (mode.StartsWith("cleanup", StringComparison.Ordinal)) held = AccountUsageQueryTests.HoldFile(stage); await Task.Delay(mode == "cancel" ? 60000 : 100, ct).ConfigureAwait(false); - if (mode == "failure") throw new IOException("synthetic failure"); + if (mode is "failure" or "cleanup-failure") throw new IOException("synthetic failure"); + if (mode == "cleanup-cancel") throw new OperationCanceledException(); return AccountUsageQueryTests.Result(); }, token), token)); T Find(string name) where T : Control => (T)form.Controls.Find(name, true).Single(); @@ -64,10 +67,34 @@ internal static Task RunAsync() Find