File System Access: Propagate regular file write errors

MERGED2026file-system-accessopfs
2026. 8. 14.zbnerd 프로필 이미지zbnerd

실제 I/O 실패를 성공으로 오인해 웹 애플리케이션이 데이터가 저장됐다고 잘못 판단할 수 있었던 오류 전파 버그를
Chromium/Blink upstream에서 수정하고, 실제 운영체제 오류를 재현하는 Blink 회귀 테스트를 추가했습니다.

문제 설명

FileSystemAccessRegularFileDelegate::Write()의 반환형은 base::FileErrorOr<int>이지만, 다음 삼항 연산자에서
base::File::Error가 정수로 변환되는 문제가 있었습니다.

return bytes_written.has_value()
           ? base::checked_cast<int>(*bytes_written)
           : base::File::GetLastFileError();

파일 쓰기가 ENOSPC로 실패하면 FILE_ERROR_NO_SPACE(-8)가 error channel이 아니라 정상적인 바이트 수인 value()
저장되었습니다. 이로 인해 OPFS의 FileSystemSyncAccessHandle.write()에서 QuotaExceededError가 발생하지 않고
JavaScript에 잘못된 값이 반환될 수 있었습니다.

리뷰 과정에서 GetLength()에도 같은 종류의 오류가 있음을 확인했습니다.

return length >= 0 ? length : base::File::GetLastFileError();

이 코드 역시 파일 길이 조회에 실패했을 때 base::File::Errorint64_t 성공값으로 변환하고 있었습니다.

해결 내용

  1. Write()에서 실패와 성공 반환을 명시적인 분기로 분리하고, 실패 시 base::unexpected()를 사용했습니다.

    - return bytes_written.has_value()
    -            ? base::checked_cast<int>(*bytes_written)
    -            : base::File::GetLastFileError();
    + if (!bytes_written.has_value()) {
    +   return base::unexpected(base::File::GetLastFileError());
    + }
    +
    + return base::checked_cast<int>(*bytes_written);
  2. 리뷰어가 지적한 GetLength()도 같은 방식으로 수정했습니다.

    - return length >= 0 ? length : base::File::GetLastFileError();
    + if (length < 0) {
    +   return base::unexpected(base::File::GetLastFileError());
    + }
    + return length;
  3. Linux 및 ChromeOS에서 실제 파일 오류를 재현하는 Blink 회귀 테스트를 추가했습니다.

    • /dev/full에 데이터를 써서 ENOSPC를 발생시키고 FILE_ERROR_NO_SPACE가 error channel로 반환되는지 검증
    • 닫힌 파일 descriptor에 GetLength()를 호출해 EBADF를 발생시키고 FILE_ERROR_FAILED가 error channel로
      반환되는지 검증
  4. blink_unittests에서 delegate를 생성할 수 있도록 FileSystemAccessFileDelegate::Create()에만 MODULES_EXPORT
    적용했습니다.

    component build에서 테스트 실행 파일이 factory method를 호출할 때 symbol visibility 문제로 링크가 실패했습니다.
    클래스 전체를 export하지 않고 테스트에 필요한 factory method만 export하도록 변경 범위를 최소화했습니다.

테스트 방법

blink_unittests 타깃을 빌드했습니다.

autoninja -C out/Default blink_unittests

추가한 회귀 테스트를 실행해 모두 통과하는 것을 확인했습니다.

out/Default/blink_unittests \
  --gtest_filter='FileSystemAccessRegularFileDelegateTest.*'

검증한 테스트:

  • FileSystemAccessRegularFileDelegateTest.WriteReturnsNoSpaceError
  • FileSystemAccessRegularFileDelegateTest.GetLengthReturnsFileError

추가로 다음 검사를 수행했습니다.

gn format --dry-run third_party/blink/renderer/modules/file_system_access/BUILD.gn
git diff --check

배운 점

base::FileErrorOr<T>base::expected<T, base::File::Error>이지만, 삼항 연산자 안에서 성공값과 unscoped enum인
base::File::Error를 함께 사용하면 expected로 변환되기 전에 enum이 정수형으로 승격될 수 있다는 점을 배웠습니다.
오류를 error channel로 전달하려면 base::unexpected()를 명시적으로 사용해야 합니다.

실제 운영체제 오류를 테스트하기 위해 /dev/fullENOSPC를 재현하고, 닫힌 file descriptor로 EBADF를 결정적으로
발생시키는 방법을 확인했습니다.

또한 테스트를 추가하는 과정에서 component build의 symbol visibility로 인해 링크 오류가 발생했습니다. nm -C
FileSystemAccessFileDelegate::Create()가 local symbol임을 확인하고, 클래스 전체가 아닌 필요한 factory method만
export하여 문제를 해결했습니다.

리뷰 과정에서는 GetLength()의 같은 오류를 추가로 발견해 현재 CL에 포함했지만, 별도 구현인 Incognito delegate의 유사
문제는 범위를 확장하지 않고 후속 작업으로 분리했습니다. 이를 통해 같은 근본 원인의 버그라도 실제 구현 구조와 CL 범위를
구분해서 판단해야 한다는 점을 배웠습니다.

참고 자료