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:
- 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).
- 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
OPFSCoopSyncVFS reports an opaque "disk I/O error" instead of
SQLITE_FULLwhen OPFS is out of quotaSummary
When an OPFS write in
OPFSCoopSyncVFSruns out of storage quota, the failure surfaces to callers as a generic SQLitedisk I/O error(SQLITE_IOERR_WRITE, internallyError('short write')) instead ofSQLITE_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 apage_sizechange), becauseVACUUMbuilds 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 theVACUUM.Root cause
At the quota boundary, browsers do not throw
QuotaExceededErroras the spec requires:4294967288(0xFFFFFFF8=-8as a uint32, Chromium's internalFILE_ERROR_NO_SPACE) — a value larger than the buffer. (crbug 541725401)0. (bugzilla 2060133)I filed both as browser bugs (crbug 541725401, bugzilla 2060133), but neither throws today, so in both cases
nBytes !== pData.byteLengthand the VFS must handle it.OPFSCoopSyncVFS.jWrite()then does:nBytesis4294967288, so the mismatch branch throwsError('short write')and returnsSQLITE_IOERR_WRITE, which SQLite surfaces asdisk I/O error. The real cause (no space) is lost.Reproduction
Two small attached files,
opfs-vacuum-repro.htmlandopfs-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 RunRun 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:
write()also shows the browser's non-throwing behaviour (Chrome returns4294967288=0xFFFFFFF8; Firefox a short write /0).OPFSCoopSyncVFSand runsVACUUM, 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().quotastill reports multiple GiB, so callers cannot rely onestimate()to avoid this.Environment
channel: 'chrome'); also confirmed in Chrome Incognito (~1 GiB enforced quota vs 3 GiB reported byestimate())0)fb1bdf4(also reproduces on7792195)OPFSCoopSyncVFSSuggested 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 toSQLITE_FULL, and also map a future spec-compliantQuotaExceededErrorthrow toSQLITE_FULL:The same
QuotaExceededError→SQLITE_FULLmapping applies tojTruncate()(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
QuotaExceededErrormapping is still needed — and until then,OPFSCoopSyncVFScan already give callers an actionableSQLITE_FULLinstead of an opaquedisk I/O error.Attached files