can1357/oh-my-pi · error · ArchiveError
Invalid ZIP archive: truncated end of central directory
Error message
Invalid ZIP archive: truncated end of central directory
What it means
Thrown by readCentralDirectoryInfo when the reader asks the ByteSource for the tail region (up to EOCD + max ZIP comment length) but receives fewer bytes than requested. This indicates the underlying source shrank or cannot serve its own declared size — the file changed between the size check and the read.
Source
Thrown at packages/utils/src/ar/zip.ts:185
if (info.entries === 0) return info.offset;
const candidates = [info.offset];
const adjacent = info.physicalEnd - info.size;
if (adjacent !== info.offset) candidates.push(adjacent);
for (const offset of candidates) {
if (offset < 0 || offset + 4 > source.size || offset + info.size > source.size) continue;
const signature = await source.read(offset, offset + 4);
if (signature.byteLength === 4 && readUInt32LE(signature, 0) === CENTRAL_HEADER_SIGNATURE) return offset;
}
throw new ArchiveError("Invalid ZIP archive: central directory is out of bounds or malformed");
}
async function readCentralDirectoryInfo(source: ByteSource, limits: ArchiveLimits): Promise<CentralDirectoryInfo> {
if (source.size < EOCD_LENGTH) throw new ArchiveError("Invalid ZIP archive: missing end of central directory");
const tailLength = Math.min(source.size, EOCD_LENGTH + MAX_COMMENT_LENGTH);
const tailStart = source.size - tailLength;
const tail = await source.read(tailStart, source.size);
if (tail.byteLength !== tailLength)
throw new ArchiveError("Invalid ZIP archive: truncated end of central directory");
const eocdIndex = findEocd(tail);
const eocdOffset = tailStart + eocdIndex;
const disk = readUInt16LE(tail, eocdIndex + 4);
const centralDisk = readUInt16LE(tail, eocdIndex + 6);
const entriesOnDisk = readUInt16LE(tail, eocdIndex + 8);
let entries = readUInt16LE(tail, eocdIndex + 10);
let size = readUInt32LE(tail, eocdIndex + 12);
let offset = readUInt32LE(tail, eocdIndex + 16);
if (
disk !== 0 ||
centralDisk !== 0 ||
(entriesOnDisk !== U16_MAX && entries !== U16_MAX && entriesOnDisk !== entries)
) {
throw new ArchiveError("Multi-volume ZIP archives are not supported");
}
const needsZip64 = entriesOnDisk === U16_MAX || entries === U16_MAX || size === U32_MAX || offset === U32_MAX;
const zip64 = await readZip64Info(source, tail, tailStart, eocdOffset);
let physicalEnd = eocdOffset;View on GitHub (pinned to 9690622007)
Solutions
- Retry after confirming the file is no longer being written (check for stable size across a short delay).
- Copy the file to a stable local path first, then open the copy.
- Re-download the archive; this state usually means the file is incomplete.
- If you implemented a custom ByteSource, ensure read(start,end) returns exactly end-start bytes or throws — never a short buffer.
Example fix
// before: reading in place while the writer is active
const zip = readZip(openByteSource(possiblyGrowingPath));
// after: wait for the writer to finish (stable size) or snapshot
let prev = -1;
while (true) {
const size = statSync(path).size;
if (size === prev && size > 0) break;
prev = size;
await Bun.sleep(500);
}
const stable = await Bun.file(path).arrayBuffer();
const zip = readZip(memoryByteSource(new Uint8Array(stable))); Defensive patterns
Strategy: try-catch
Validate before calling
import * as fs from "node:fs/promises";
// Read to a stable in-memory snapshot first so the parser never races a writer.
const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
const statSize = (await fs.stat(path)).size;
if (bytes.byteLength !== statSize) throw new Error("file changed during read; retry"); Try / catch
try {
const zip = readZip(memoryByteSource(snapshotBytes));
} catch (err) {
if (err instanceof ArchiveError && err.message.includes("truncated end of central directory")) {
await Bun.sleep(1000); // producer may still be writing; retry once with a fresh snapshot
return openZipWithSnapshot(path);
}
throw err;
} Prevention
- Snapshot the archive into memory or a temp copy before parsing if any other process may write it.
- If you implement a custom ByteSource, never return short reads — serve exactly the requested range or throw.
- Poll for a stable file size before opening archives produced asynchronously.
- Avoid reading archives from mounts/locations with concurrent cleanup jobs.
When it happens
Trigger: Calling zip info/read on a source whose `size` reports more bytes than a read of [tailStart, size) actually returns: file truncated concurrently by another process, a network/device-backed source that returns short reads, or a custom ByteSource implementation violating its contract.
Common situations: Reading a zip while a download/copy is still in progress; a log-rotation or cleanup job truncating the file mid-read; a buggy custom ByteSource (e.g. streaming reader) that reports optimistic sizes.
Related errors
- Invalid ARJ archive: truncated data
- Invalid XZ stream: truncated integer
- Invalid XZ stream: truncated variable-length integer
- CPIO member '${memberPath}' is truncated
- Invalid CPIO archive: truncated ${what}
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/ff1ec68a76cd6514.
Report an issue: GitHub.