Skip to content

perf(usage): cache empty macOS keychain scans for 30s - #22

Merged
axisrow merged 2 commits into
mainfrom
fix/keychain-miss-cache
Sep 27, 2026
Merged

axisrow merged 2 commits into
mainfrom
fix/keychain-miss-cache

Conversation

@axisrow

@axisrow axisrow commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Cache empty macOS keychain scans for 30 seconds

Every status line repaint is a new ccstatusline process, so on macOS an empty keychain scan (locked keychain / no credentials stored) was repeated from scratch on every render of the usage widgets.

This remembers an empty scan in a small cache file (keychain-fallback-empty) for 30 seconds. The credentials file and exact-token paths are still checked on every scan, so direct credential changes stay live; only the expensive candidate sweep is short-circuited. The cache write is best-effort — a read-only cache dir falls back to scanning normally.

Includes a regression test covering the 30s reuse window, expiry, and credentials-file changes overriding a cached miss.

Written by Codex; committed as-is.

🤖 Generated with Claude Code

Each status line repaint is a new process, so an empty keychain scan
repeated on every render. Remember the empty result in a cache file
for 30s while still checking the credentials file and exact-token
paths every scan, so direct credential changes stay live.

Written by Codex; committed as-is.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Обзор

PR кэширует пустой результат sweep хешированных keychain-кандидатов macOS на 30 секунд через пустой файл ~/.cache/ccstatusline/keychain-fallback-empty (читается только mtime). Дифф проверен против base main (74ba67f): один коммит b3019b9, 3 файла, +68 строк. Вердикт: approving — запрашивающих изменений нет.

Корректность

  • Кэш затрагивает ровно один путь — readUsageCredentialsFromMacKeychainCandidates(), достигаемый только для профиля по умолчанию на darwin после промаха по plain-сервису. Plain-сервис Claude Code-credentials, профильный хешированный сервис и .credentials.json по-прежнему проверяются на каждом рендере — заявление из описания PR соответствует коду.
  • TTL-логика верна: age >= 0 отбрасывает mtime из будущего (скачок часов), ровно 30 000 ms считается истёкшим. mtime обновляется только фактически выполненным sweep, поэтому 30 s остаётся честной верхней границей, а не растягивается бесконечно частыми репайнтами.
  • Запись best-effort и обёрнута в try/catch вместе с ensureCacheDirExists(): read-only кэш-директория не ломает fallback на credentials-file.
  • Кэш-файл пустой, содержимое никогда не парсится (только statSync), поэтому конкурентные записи параллельных процессов репайнта безопасны; секретов файл не содержит.
  • Профильный путь (CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR) sweep не вызывает вовсе, так что кэш на мультипрофильные сценарии не влияет.

Трейдофф задокументирован

Новый login, существующий только в хешированном keychain-сервисе, может обнаруживаться с задержкой до 30 s. Это осознанное решение, отражённое и в комментарии кода, и в описании PR; для статусбара приемлемо.

Тесты

Новый тест покрывает окно переиспользования (29 999 ms), истечение (30 000 ms), защиту от mtime в будущем (шаг назад на 1 ms) и живость credentials-file и plain-сервиса при свежем кэше (dump-keychain остаётся на 3 вызовах). Моки statSync/writeFileSync/mkdirSync в beforeEach делают тесты герметичными относительно реального ~/.cache/ccstatusline — это же снимает потенциальный флейк существующего теста в usage-token-buffer.test.ts, который иначе мог бы зацепить реальный miss-файл разработчика.

Единственное замечание — не блокирующее (см. inline): в usage-token-buffer.test.ts замокан только read-side кэша; write-side сейчас недостижим, но на будущее не защищён.

mockedExecFileSync.mockReset();
mockedExecFileSync.mockImplementation(realExecFileSync);
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
vi.spyOn(fs, 'statSync').mockImplementation(() => { throw new Error('cache missing'); });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Неблокирующее: замокан только read-side кэша (statSync). Сейчас write-путь здесь недостижим — единственный тест находит кандидата, — но будущий тест на full-miss записал бы реальный файл в ~/.cache разработчика. Стоит замокать writeFileSync и mkdirSync так же, как в usage-token.test.ts.

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #22 — perf(usage): cache empty macOS keychain scans for 30s

Вердикт: Request changes — не из-за дефектов самого diff'а (код корректен, я готов апрувить его по существу), а из-за красного обязательного чека: lint падает на 21 ошибке, унаследованной с main. Пока main не поправлен (или фикс не включён в этот PR), merge-гейт не зелёный ни для одного PR из main.

Верификация негативного кэша (опасный класс изменений) — чисто

  • Разделение профилей (src/utils/usage-fetch.ts:612-631): miss-кэш живёт только в readUsageCredentialsFromMacKeychainCandidates() (usage-fetch.ts:552), которая достижима только для дефолтного профиля. Не-дефолтные профили выходят рано через configDirService (usage-fetch.ts:624) и кэша не касаются. Кэш возвращает null (отсутствие), а не чужой результат — перепутать профили невозможно архитектурно.
  • Фингерпринт по refresh token (sirmalloc#536): негативный кэш не хранит никаких аккаунт-данных (0-байтовый файл, содержимое никогда не читается — только statSync mtime), поэтому фингерпринт-гейт дата-кэша (usage-fetch.ts:640) его не касается и наоборот. Инвалидация по logout/login для точного сервиса (Claude Code-credentials) и .credentials.json сохраняется — они проверяются на каждом вызове, до/вокруг кэшированного fast-return.
  • TTL (usage-fetch.ts:557-558): ровно age >= 0 && age < 30_000. mtime в будущем (отрицательный age) → перескан; протухший файл инертен; непустой скан файл не пишет вообще (write только после пустого прохода цикла, usage-fetch.ts:574-579).
  • Конкурентность: читается только mtime через statSync, пишется пустая строка одним вызовом — нет вектора torn-read; худший случай гонки — дублирующий sweep, безвредно. Write best-effort, read-only ~/.cache деградирует в обычный скан.
  • Решаринг sweep-находок между рендерами: если sweep что-то нашёл — файл не пишется, следующий рендер сканирует заново; ошибочного «дедупа обязательного скана» нет. Отложенное обнаружение (≤30s) возможно только для sweep-дискавери нестандартных сервисов — это осознанный трейдофф, задокументированный в комментарии usage-fetch.ts:555.

Находки

1. Блокер мержа (не вина diff'а): lint красный на main

CI «Lint & Type Check» падает с 21 ошибкой, все — в файлах, которые PR не трогает: PowerlineThemeSelector.tsx (6), PowerlineThemeSelector.test.ts (2), renderer-regular-theme.test.ts (5), separator-font-fallback.test.ts (1), cli.ts (2), powerline.ts (1), renderer.ts (4). Локальный прогон на HEAD PR воспроизвёл те же 21 ошибку один-в-один; в трёх файлах диффа (usage-fetch.ts, оба usage-теста) ошибок нет — diff линт-чистый. Вывод: main сломан для линта ещё до этого PR (наследие тем-коммитов), и это блокирует merge-гейт всем PR. 19 из 21 чинятся bun run lint:fix. Варианты: отдельный chore(lint)-коммит в main до мержа, либо включить в этот PR — на усмотрение автора.

2. Should-fix: write-путь не замокан в usage-token-buffer.test.ts:38

Поддерживаю комментарий @axisrow (PRRT_kwDOUqBlWc6mTGSl): сейчас замокан только statSync; сегодняшний тест находит кандидата и write-путь недостижим, но будущий тест на full-miss запишет реальный файл в ~/.cache разработчика. Стоит добавить vi.spyOn(fs, 'writeFileSync') + vi.spyOn(fs, 'mkdirSync') так же, как в usage-token.test.ts. Неблокирующее само по себе, но дешёвое — логично включить в тот же фикс-пуш, что и п.1.

Прогоны

  • bun install — ок; целевые тесты трёх затронутых файлов: 15 pass / 0 fail (93 expect).
  • CI Test на PR — зелёный (оба прогона); заявленное покрытие реально: новый тест в usage-token.test.ts:248 закрывает 30s-окно, границу истечения, отрицательный age (mtime в будущем), приоритет .credentials.json над свежим кэшем и точного сервиса — без лишнего sweep.
  • Локальный полный bun test дал флейки в custom-command-process.test.ts и RefreshIntervalMenu.test.ts — файлы, не затронутые PR, тайминги под локальной нагрузкой; CI те же тесты проходит.
  • bun run lint: см. находку 1.

Смоук на реальном macOS keychain

Выполнен на этой машине (её живой статус-лайн уже работает на коммите b3019b9): miss-файл реально живёт в ~/.cache/ccstatusline/, механизм подтверждён — вызов на протухшем кэше выполняет полный sweep, вызов на свежем его пропускает (11550ms → 429ms; абсолютные числа загрязнены параллельным tsc, автономный security dump-keychain здесь стоит ~0.63s). Креденшлы не печатались и не кэшировались. Дополнительных зависимостей нет, подавлений линта нет.

Итог

Сам diff: корректный, минимальный, покрыт тестами — по существу это approve. Request changes ставлю только из-за красного обязательного lint-чека (находка 1); после его снятия п.2 можно добить тем же пушем.

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review pass 2: PR #22 @ fd9f945 (merge of b35114c into fix/keychain-miss-cache)

Вердикт: approving. Дифф против нового base идентичен уже полностью
проверенному b3019b9 (3 файла, +68: usage-fetch.ts + два usage-теста) —
корректность негативного кэша подтверждена предыдущим проходом: профильная
изоляция (кэш достижим только для дефолтного профиля), честный TTL 30с с
защитой от mtime в будущем, запись только после пустого sweep, секреты не
кэшируются, гонки безвредны, fingerprint дата-кэша не затронут.

Блокер первого прохода (красный «Lint & Type Check» из-за долга main) снят:
фиксы ушли в main через #21, ветка обновлена, на этой голове все чеки
зелёные (Lint/Test/Build ×2, runs 36287843…/36287846…). В файлах диффа
lint-ошибок нет.

Неблокирующее, остаётся открытым (тред PRRT_kwDOUqBlWc6mTGSl):
в src/utils/__tests__/usage-token-buffer.test.ts:38 замокан только
read-side кэша (statSync); будущий тест на full-miss записал бы реальный
файл в ~/.cache разработчика. Стоит добавить моки writeFileSync/mkdirSync
по образцу usage-token.test.ts — удобно добрать отдельным мелким ПРом.

@axisrow
axisrow merged commit 030b57e into main Sep 27, 2026
6 checks passed
@axisrow
axisrow deleted the fix/keychain-miss-cache branch September 27, 2026 04:54
axisrow added a commit that referenced this pull request Sep 27, 2026
Follow-up to the #22 review (PRRT_kwDOUqBlWc6mTGSl): only the read
side (statSync) was mocked, so a future full-miss test would write a
real marker into the developer's ~/.cache. Mock writeFileSync and
mkdirSync like usage-token.test.ts does, and add the miss-branch test:
a dump-keychain scan finding nothing must record the
keychain-fallback-empty marker and still yield no token.

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant