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

  1. Retry after confirming the file is no longer being written (check for stable size across a short delay).
  2. Copy the file to a stable local path first, then open the copy.
  3. Re-download the archive; this state usually means the file is incomplete.
  4. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/ff1ec68a76cd6514. Report an issue: GitHub.