Skip to content

Commit b41df04

Browse files
committed
zlib: let a ZipFile read finish before close()
An entry read runs on a file descriptor shared with its ZipFile, but reads did not take part in close()'s lifecycle: close() marked the handle closed and released the fd while a read was still in flight, so the read landed on a closed - or worse, an OS-reused - descriptor (surfacing as EBADF, or a cross-file read once the number was reclaimed), despite the class comment promising otherwise. Track in-flight reads on the shared handle. close() now marks the handle closing (rejecting new reads at once), waits for the in-flight reads to finish on the still-open fd, and only then closes it; closeSync(), which cannot wait, refuses while an asynchronous read is outstanding. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent bee84c6 commit b41df04

2 files changed

Lines changed: 89 additions & 30 deletions

File tree

lib/internal/zip/entry.js

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ function createEntryMeta(filename, options) {
175175
};
176176
}
177177

178+
// Release one in-flight read on a shared ZipFile descriptor handle. When the
179+
// last read settles, wake a close() that is waiting to release the fd (see
180+
// ZipFile.close() in file.js), so a read never lands on a closed/reused fd.
181+
function endHandleRead(handle) {
182+
if (--handle.reads === 0 && handle.drain !== null) {
183+
const drain = handle.drain;
184+
handle.drain = null;
185+
drain();
186+
}
187+
}
188+
178189
/**
179190
* A single file or directory inside a ZIP archive: reading, writing, and
180191
* (de)serializing one archive member.
@@ -490,17 +501,30 @@ class ZipEntry {
490501
// The entry's raw compressed bytes as a bounded-memory chunk stream, read
491502
// straight from disk (file-backed entries only). Nothing is retained.
492503
async *#rawChunks() {
493-
this.#liveDescriptor();
494-
let pos = await this.#resolveContentOffset();
495-
let remaining = this.compressedSize;
496-
while (remaining > 0) {
497-
const take = MathMin(READ_CHUNK_SIZE, remaining);
498-
const chunk = Buffer.allocUnsafe(take);
499-
// Re-check per chunk: the ZipFile may be closed mid-stream.
500-
await readFdFully(this.#liveDescriptor(), chunk, pos);
501-
pos += take;
502-
remaining -= take;
503-
yield chunk;
504+
// Count the whole stream as one in-flight read so a concurrent close()
505+
// waits for it (the finally runs when the consumer stops iterating); a
506+
// stream begun after close() was requested is rejected up front.
507+
const handle = this.#fd;
508+
if (handle.closing) {
509+
throw new ERR_INVALID_STATE(
510+
'cannot read a ZipEntry after its backing ZipFile has been closed');
511+
}
512+
handle.reads++;
513+
try {
514+
this.#liveDescriptor();
515+
let pos = await this.#resolveContentOffset();
516+
let remaining = this.compressedSize;
517+
while (remaining > 0) {
518+
const take = MathMin(READ_CHUNK_SIZE, remaining);
519+
const chunk = Buffer.allocUnsafe(take);
520+
// Re-check per chunk: the ZipFile may be closed mid-stream.
521+
await readFdFully(this.#liveDescriptor(), chunk, pos);
522+
pos += take;
523+
remaining -= take;
524+
yield chunk;
525+
}
526+
} finally {
527+
endHandleRead(handle);
504528
}
505529
}
506530
// Sync counterpart of #rawChunks().
@@ -533,18 +557,33 @@ class ZipEntry {
533557
`entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
534558
`exceeding the ${maxSize} byte limit`);
535559
}
536-
const compressed = await this.#compressedBytes();
537-
const data = await decodeMemberAsync(compressed, {
538-
name: this.name,
539-
flags: this.flags,
540-
method: this.method,
541-
crc32: this.crc32,
542-
uncompressedSize: declared,
543-
}, { verify: options?.verify, maxSize });
544-
// `data === compressed` only on the store path; copy the in-memory case
545-
// (the entry's retained buffer, see #compressedBytes()) so the result is
546-
// caller-owned on every path.
547-
return data === compressed && this.#fd === null ? Buffer.from(data) : data;
560+
// Count this read on the shared handle so a concurrent close() waits for it
561+
// rather than releasing the fd mid-read; a read begun after close() was
562+
// requested is rejected up front (in-memory entries have no fd).
563+
const handle = this.#fd;
564+
if (handle !== null) {
565+
if (handle.closing) {
566+
throw new ERR_INVALID_STATE(
567+
'cannot read a ZipEntry after its backing ZipFile has been closed');
568+
}
569+
handle.reads++;
570+
}
571+
try {
572+
const compressed = await this.#compressedBytes();
573+
const data = await decodeMemberAsync(compressed, {
574+
name: this.name,
575+
flags: this.flags,
576+
method: this.method,
577+
crc32: this.crc32,
578+
uncompressedSize: declared,
579+
}, { verify: options?.verify, maxSize });
580+
// `data === compressed` only on the store path; copy the in-memory case
581+
// (the entry's retained buffer, see #compressedBytes()) so the result is
582+
// caller-owned on every path.
583+
return data === compressed && this.#fd === null ? Buffer.from(data) : data;
584+
} finally {
585+
if (handle !== null) endHandleRead(handle);
586+
}
548587
}
549588

550589
/**

lib/internal/zip/file.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
MapPrototypeKeys,
2020
MapPrototypeSet,
2121
MathMin,
22+
Promise,
2223
PromisePrototypeThen,
2324
PromiseResolve,
2425
SymbolAsyncDispose,
@@ -218,12 +219,14 @@ function checkMemberOverlap(members, centralDirectoryOffset) {
218219
* corrupt the archive.
219220
*/
220221
class ZipFile {
221-
// Shared descriptor handle ({ fd, closed }) handed to every ZipEntry this
222-
// archive produces, so close() can invalidate them all at once. `#closing`
223-
// is set synchronously the moment close()/closeSync() is called, gating any
224-
// further public call; `handle.closed` is set once the fd is actually gone,
225-
// gating reads through already-handed-out entries. Neither read ever falls
226-
// through to a bare (possibly OS-reused) descriptor number.
222+
// Shared descriptor handle ({ fd, closed, reads, drain }) handed to every
223+
// ZipEntry this archive produces, so close() can invalidate them all at once.
224+
// `#closing` is set synchronously the moment close()/closeSync() is called,
225+
// gating any further public call; `handle.closed` then stops new reads, and
226+
// `handle.reads` counts in-flight reads through already-handed-out entries so
227+
// close() can wait for them to finish before releasing the fd. Neither a new
228+
// nor an in-flight read ever falls through to a closed (possibly OS-reused)
229+
// descriptor number.
227230
#handle;
228231
#closing = false;
229232
#closePromise = null;
@@ -240,7 +243,7 @@ class ZipFile {
240243
* @private
241244
*/
242245
constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) {
243-
this.#handle = { fd, closed: false };
246+
this.#handle = { fd, closed: false, closing: false, reads: 0, drain: null };
244247
this.#writable = writable;
245248
this.#comment = comment;
246249
this.#centralDirectoryOffset = centralDirectoryOffset;
@@ -655,7 +658,16 @@ class ZipFile {
655658
close() {
656659
if (this.#closing) return this.#closePromise ?? PromiseResolve();
657660
this.#closing = true;
661+
// Block *new* reads immediately; reads already in flight keep the still-open
662+
// fd until they finish (handle.closed, set below, is what stops mid-read).
663+
this.#handle.closing = true;
658664
this.#closePromise = this.#enqueue(async () => {
665+
// Let reads already in flight through handed-out entries finish on the
666+
// open fd before releasing it; endHandleRead() (entry.js) resolves this
667+
// once the last one settles.
668+
if (this.#handle.reads > 0) {
669+
await new Promise((resolve) => { this.#handle.drain = resolve; });
670+
}
659671
this.#handle.closed = true;
660672
MapPrototypeClear(this.#entries);
661673
await fsCloseAsync(this.#handle.fd);
@@ -669,7 +681,15 @@ class ZipFile {
669681
closeSync() {
670682
if (this.#closing) return;
671683
this.#assertNotBusy();
684+
// A synchronous close cannot wait for an async read the way close() does,
685+
// so refuse rather than pull the fd out from under one still in flight.
686+
if (this.#handle.reads > 0) {
687+
throw new ERR_INVALID_STATE(
688+
'cannot synchronously close a ZipFile while an asynchronous read is ' +
689+
'still in flight');
690+
}
672691
this.#closing = true;
692+
this.#handle.closing = true;
673693
this.#handle.closed = true;
674694
MapPrototypeClear(this.#entries);
675695
fs.closeSync(this.#handle.fd);

0 commit comments

Comments
 (0)