Skip to content

OPFSCoopSyncVFS reports an opaque "disk I/O error" instead of SQLITE_FULL when OPFS is out of quota #336

Description

@hyperknot

OPFSCoopSyncVFS reports an opaque "disk I/O error" instead of SQLITE_FULL when OPFS is out of quota

Summary

When an OPFS write in OPFSCoopSyncVFS runs out of storage quota, the failure surfaces to callers as a generic SQLite disk I/O error (SQLITE_IOERR_WRITE, internally Error('short write')) instead of SQLITE_FULL. This is easy to misdiagnose as database corruption or a VFS bug, and it hides an actionable "out of space" condition.

It is very easy to hit with VACUUM (including a page_size change), because VACUUM builds a full second copy of the database before swapping, so it transiently needs roughly 2× the database size on disk. A database that itself fits comfortably in quota can still overflow during the VACUUM.

Root cause

At the quota boundary, browsers do not throw QuotaExceededError as the spec requires:

  • Chrome 150 returns 4294967288 (0xFFFFFFF8 = -8 as a uint32, Chromium's internal FILE_ERROR_NO_SPACE) — a value larger than the buffer. (crbug 541725401)
  • Firefox 153 returns a truthful short write count (bytes that fit in the remaining quota, i.e. smaller than the buffer); a further write when full returns 0. (bugzilla 2060133)

I filed both as browser bugs (crbug 541725401, bugzilla 2060133), but neither throws today, so in both cases nBytes !== pData.byteLength and the VFS must handle it.

OPFSCoopSyncVFS.jWrite() then does:

const nBytes = accessHandle.write(pData.subarray(), { at: iOffset });
if (nBytes !== pData.byteLength) throw new Error('short write');
return VFS.SQLITE_OK;
// ...catch => this.lastError = e; return VFS.SQLITE_IOERR_WRITE;

nBytes is 4294967288, so the mismatch branch throws Error('short write') and returns SQLITE_IOERR_WRITE, which SQLite surfaces as disk I/O error. The real cause (no space) is lost.

Reproduction

Two small attached files, opfs-vacuum-repro.html and opfs-vacuum-repro.worker.js. Drop both in the wa-sqlite repo root (so they can import ./dist/wa-sqlite.mjs, ./src/sqlite-api.js, and ./src/examples/OPFSCoopSyncVFS.js), serve the repo root, and open the page:

python3 -m http.server 8000
# http://localhost:8000/opfs-vacuum-repro.html  -> click Run

Run it in a storage-constrained context so the quota is small enough to hit quickly — a Chrome Incognito window works (enforced quota ~1 GiB). The worker:

  1. Fills OPFS to the quota with a ballast file, then frees a ~64 MiB margin. The final ballast write() also shows the browser's non-throwing behaviour (Chrome returns 4294967288 = 0xFFFFFFF8; Firefox a short write / 0).
  2. Builds a ~50 MiB database with OPFSCoopSyncVFS and runs VACUUM, which needs a second copy that overflows the free margin.

Observed result (Chrome, quota constrained):

{
  "ballastLastWriteReturn": 4294967288,
  "freeMarginBytes": 67108864,
  "dbPageCount": 12011,
  "result": "VACUUM FAILED: disk I/O error  (expected SQLITE_FULL; got an opaque disk I/O error)"
}

With an unconstrained quota the same steps succeed, so the trigger is purely the quota, not the data.

Note: in the failing context navigator.storage.estimate().quota still reports multiple GiB, so callers cannot rely on estimate() to avoid this.

Environment

  • Google Chrome 150.0.7871.187 (Playwright channel: 'chrome'); also confirmed in Chrome Incognito (~1 GiB enforced quota vs 3 GiB reported by estimate())
  • Firefox 153 exhibits the same missing throw (returns a short write / 0)
  • wa-sqlite fb1bdf4 (also reproduces on 7792195)
  • default synchronous build, OPFSCoopSyncVFS

Suggested fix

For an OPFS sync access handle, a write() that returns anything other than the full buffer length means it ran out of space (Chrome an out-of-range sentinel, Firefox a truthful short write). Map that whole case to SQLITE_FULL, and also map a future spec-compliant QuotaExceededError throw to SQLITE_FULL:

jWrite(fileId, pData, iOffset) {
  try {
    const file = this.mapIdToFile.get(fileId);
    const accessHandle = file.accessHandle || file.persistentFile.accessHandle;
    const buffer = pData.subarray();
    const nBytes = accessHandle.write(buffer, { at: iOffset });
    if (nBytes === buffer.byteLength) return VFS.SQLITE_OK;
    // Any other return means out of space: Chrome returns 0xFFFFFFF8
    // (-8, FILE_ERROR_NO_SPACE); Firefox returns a truthful short write.
    this.lastError = new Error('out of space');
    return VFS.SQLITE_FULL;
  } catch (e) {
    // Once browsers follow the spec, QuotaExceededError arrives here.
    this.lastError = e;
    return e?.name === 'QuotaExceededError'
      ? VFS.SQLITE_FULL
      : VFS.SQLITE_IOERR_WRITE;
  }
}

The same QuotaExceededError → SQLITE_FULL mapping applies to jTruncate() (and any other sync-access-handle write path).

I filed the browser non-throwing behavior upstream (crbug 541725401, bugzilla 2060133), but even once those are fixed the QuotaExceededError mapping is still needed — and until then, OPFSCoopSyncVFS can already give callers an actionable SQLITE_FULL instead of an opaque disk I/O error.

Attached files

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions