[storage] Drop QuotaDatabase's test clock in favor of mock time

IN REVIEW2026storage/browser/quotatest-fixuse-after-free
2026. 9. 10.jmsmg 프로필 이미지jmsmg

QuotaDatabase가 테스트용 클럭을 파일 전역 포인터에 담아 두는데, 테스트 픽스처가 그 클럭을 설치만 하고 되돌리지 않아 픽스처 소멸 뒤 전역이 해제된 객체를 가리키는 문제를 고쳤습니다. 같은 프로세스에서 뒤이어 도는 다른 테스트가 GetNow()를 부르는 순간 해제된 객체에 가상 호출을 해서 죽습니다. 첫 판은 세터가 base::AutoReset을 반환하게 하는 것이었지만, 리뷰어의 제안에 따라 테스트 클럭 장치 자체를 없애고 TaskEnvironment의 mock time을 쓰도록 다시 만들었습니다. crbug 없이 직접 찾은 문제입니다.

문제 설명

  • issue url: 없음 (다른 CL을 검증하다 발견, Bug: none)
  • storage/browser/quota/quota_database.cc는 파일 전역 const base::Clock* g_clock_for_testing을 두고, QuotaDatabase::GetNow()가 이 값이 있으면 그 클럭을, 없으면 base::Time::Now()를 반환합니다. SetClockForTesting()은 전역에 포인터를 대입만 합니다.
  • quota_database_unittest.ccQuotaDatabaseTest는 생성자에서 SimpleTestClock을 만들어 전역에 심고, TearDown()에서 되돌리지 않습니다. 픽스처가 소멸하면 clock_이 해제되지만 전역은 그대로 남습니다.
  • 이후 같은 storage_unittests 프로세스에서 QuotaDatabase를 만드는 테스트가 실행되면 GetNow()가 해제된 객체에 가상 호출을 합니다. ToT에서 quota 테스트를 한 배치로 돌리면 재현됩니다.
storage_unittests --gtest_filter='Quota*:*Quota*:UsageTracker*:ClientUsageTracker*:StorageDirectory*'
→ QuotaConfigs/QuotaManagerImplParamTest.ReportedQuotaConfigurability (CRASHED)
   Linux: SIGSEGV SEGV_MAPERR 0x10 / arm64 Mac: SIGBUS BUS_ADRALN — 스택은 QuotaDatabase 생성자
  • 이 테스트를 단독으로 돌리면 통과합니다. QuotaDatabaseTest가 먼저 실행된 프로세스에서만 죽기 때문에 얼핏 flaky처럼 보이지만, 실제로는 순서에 의존하는 수명 버그입니다.

해결 내용

PS1 — 세터가 스스로 되돌리게 (base::AutoReset 반환). quota_manager_unittest.cc에도 같은 set/reset 쌍이 7개 있고 전부 사이에 ASSERT_*가 있어서, 단언 하나가 실패하면 early return으로 되돌리기를 건너뛰어 엉뚱한 테스트의 크래시로 번지는 구조였습니다. 반환값을 살려 두는 동안만 클럭이 적용되는 API로 바꾸면 호출자가 잊을 수 없습니다.

PS2 — 테스트 클럭을 아예 없앰 (리뷰 반영). 리뷰어 evanstade@가 "테스트 클럭을 쓰지 말고 task environment의 mock time을 쓰는 게 최선"이라고 했고, 조사해 보니 그 말이 우리가 본 것보다 더 맞았습니다.

  • base::test::TaskEnvironmentTimeSource::MOCK_TIMEbase::Time::Now() 자체를 mock으로 바꿉니다. GetNow()는 전역이 비어 있으면 Time::Now()를 부르므로 MOCK_TIME만 켜면 클럭 장치가 필요 없습니다.
  • QuotaManagerImplTest이미 MOCK_TIME이었습니다. SetClockForTesting 호출 7쌍이 전부 이 픽스처 안에 있었고, 그중 task_environment_.GetMockClock()을 넘기던 2곳은 이미 Time::Now()가 따르는 클럭을 다시 설정하는 완전한 no-op이었습니다. 나머지 5곳은 SimpleTestClockAdvance()하는 것이 전부라 task_environment_.AdvanceClock() 한 줄과 같습니다.
  • QuotaDatabaseTestSingleThreadTaskEnvironment(SYSTEM_TIME)였지만 RunLoopFastForward도 쓰지 않으므로, MOCK_TIME으로 바꿔도 Time::Now()가 고정되는 것 외에는 아무 영향이 없습니다.

그래서 전역과 세터를 통째로 지우고 GetNow()base::Time::Now() 한 줄이 됐습니다. 댕글링 포인터를 RAII로 관리하는 대신 존재할 수 없게 만든 것입니다. 4파일 +30/−85.

-const base::Clock* g_clock_for_testing = nullptr;
 base::Time QuotaDatabase::GetNow() {
-  return g_clock_for_testing ? g_clock_for_testing->Now() : base::Time::Now();
+  return base::Time::Now();
 }
-void QuotaDatabase::SetClockForTesting(const base::Clock* clock) { ... }

계획과 달라진 점이 둘 있었고 둘 다 답글에 적었습니다.

  1. simple_test_clock.h include를 지우자 남아 있던 task_environment_.GetMockClock()->Now() 6곳이 base::Clock 불완전 타입 오류로 컴파일되지 않았습니다. clock.h를 되살리는 대신 MOCK_TIME 아래 같은 값인 base::Time::Now()로 통일했습니다.
  2. QuotaDatabaseTest.Stale이 400일 정체(stale) 경계를 정확히 400일에서 검사하고 있었습니다. 판정은 last_accessed < now - 400d로 exclusive인데, 옛 테스트는 엔트리를 Now() - 399d로 쓰고 SetNow(Now() + 1d)로 넘어가면서 두 Now() 호출 사이의 실제 시계 드리프트(µs) 덕에 우연히 통과하고 있었습니다. mock time에서는 드리프트가 0이라 실제로 실패했고, 하루에 1초를 더해 경계를 명시적으로 넘기고 주석을 달았습니다.

발굴 과정

quota의 만료 NotFatalUntil::M148 정리(CL 8377550)를 검증하다가 storage_unittests가 죽었습니다. 처음엔 제 변경 탓으로 보고 이분 탐색을 다섯 번 돌렸는데 결과가 서로 모순됐습니다. 원인은 대조군을 잘못 잡은 것이었습니다. 실패한 실행은 321개짜리 필터였는데 main과 비교할 때는 8개짜리 좁은 필터를 썼기 때문입니다. 같은 321개 필터로 main을 돌리자 똑같이 죽었고, 그제야 사전 실패라는 것이 드러났습니다. 단독 실행은 통과하고 배치에서만 죽는다는 점, 스택이 QuotaDatabase 생성자 근처라는 점(인라인된 GetNow()의 오귀속)이 전역 클럭으로 이어졌습니다.

테스트 방법

  • 패치 전 main: 위 필터의 배치 실행이 [298/305] … ReportedQuotaConfigurability/Incognito_Static_FlagEnabled (CRASHED). 같은 그룹 8개만 단독 실행하면 8/8 통과 — 배치 의존 재현.
  • PS1: TearDown에 되돌리기 한 줄만 넣은 최소 수정과 AutoReset 판 모두 321/321 (Linux).
  • PS2: storage_unittests --gtest_filter='*QuotaDatabase*' 57/57, 321 필터 배치 319/319 (arm64 Mac 기준 개수), CRASHED 0. 경계 수정 전에는 Stale/0·Stale/1 두 개가 예측대로 실패했습니다.

배운 점

  • API의 형태를 고민하기 전에 그 API가 필요한지를 먼저 묻습니다. PS1을 낼 때 ForTesting 세터의 관행 분포(void 반환 558 vs AutoReset 반환 51)와 선례까지 조사했지만, 호출처가 전부 테스트라는 사실을 확인해 놓고도 그 테스트들의 TaskEnvironment 설정은 보지 않았습니다. MOCK_TIME이 켜져 있으면 클럭 후크는 대개 이미 불필요합니다.
  • mock time은 시계 드리프트를 없애므로, 드리프트에 기대어 통과하던 경계 테스트가 드러납니다. 이런 실패는 새 버그가 아니라 원래 있던 취약한 테스트이며, 경계를 명시적으로 넘기고 이유를 주석으로 남깁니다.
  • 테스트가 깨졌을 때 대조군은 같은 필터로 돌려야 합니다. 배치 크기가 다르면 순서 의존 버그는 보였다 안 보였다 합니다.
  • git cl upload는 기존 CL에 올릴 때 Gerrit에 저장된 설명을 재사용합니다. 설계가 바뀐 패치셋은 git cl description -n +로 설명을 따로 갱신해야 합니다.

참고 자료