Tencent/tinker · error · IOException
Stream is closed
Error message
Stream is closed
What it means
TinkerZipOutputStream.finish() writes the central directory and EOCD to terminate the archive. It first checks that the underlying stream reference (out) is non-null; close() nulls it, so calling finish() (directly or via close()'s internal path in the wrong order) after the stream is closed throws IOException('Stream is closed'). The source even carries a TODO questioning whether checkOpen should be used; the observable contract is that finish on a closed stream fails with this message rather than being a no-op.
Source
Thrown at third-party/tinker-ziputils/src/main/java/com/tencent/tinker/ziputils/ziputil/TinkerZipOutputStream.java:375
/*public void setLevel(int level) {
if (level < Deflater.DEFAULT_COMPRESSION || level > Deflater.BEST_COMPRESSION) {
throw new IllegalArgumentException("Bad level: " + level);
}
compressionLevel = level;
}*/
/**
* Indicates that all entries have been written to the stream. Any terminal
* information is written to the underlying stream.
*
* @throws IOException
* if an error occurs while terminating the stream.
*/
// @Override
public void finish() throws IOException {
// TODO: is there a bug here? why not checkOpen?
if (out == null) {
throw new IOException("Stream is closed");
}
if (cDir == null) {
return;
}
if (entries.isEmpty()) {
throw new ZipException("No entries");
}
if (currentEntry != null) {
closeEntry();
}
int cdirEntriesSize = cDir.size();
/*if (archiveNeedsZip64EocdRecord) {
Zip64.writeZip64EocdRecordAndLocator(cDir, entries.size(), offset, cdirEntriesSize);
}*/
// Write Central Dir End
writeLongAsUint32(cDir, ENDSIG);
writeIntAsUint16(cDir, 0); // Disk Number
writeIntAsUint16(cDir, 0); // Start DiskView on GitHub (pinned to 1b7ea02c23)
Solutions
- Call finish() exactly once, before close(), and let close() be the only teardown (finish+close or just close — never finish after close).
- If using try-with-resources, drop manual finish()/close() calls entirely.
- In error-recovery paths, guard the salvage finish with a flag so it cannot run after the stream was closed.
Example fix
// before
TinkerZipOutputStream zos = new TinkerZipOutputStream(out);
try {
writeEntries(zos);
} finally {
zos.close();
}
zos.finish(); // IOException: Stream is closed
// after
try (TinkerZipOutputStream zos = new TinkerZipOutputStream(out)) {
writeEntries(zos);
zos.finish();
} Defensive patterns
Strategy: validation
Validate before calling
boolean finished = false;
// ... write entries ...
if (!finished) {
zos.finish();
finished = true;
}
zos.close(); Try / catch
try {
zos.finish();
} catch (IOException e) {
if ("Stream is closed".equals(e.getMessage())) {
// already terminated; nothing to salvage — treat as a lifecycle bug
log.warn("finish() called after close(); fix the call order", e);
} else {
throw e;
}
} Prevention
- Prefer try-with-resources and let close() terminate the archive; add finish() only when you must keep the underlying stream open.
- Never call finish() after close(); track a finished flag if control flow is complex.
- One stream, one lifecycle: do not reuse TinkerZipOutputStream objects across attempts.
When it happens
Trigger: Calling finish() after close(); or double-finishing via a wrapper that also closes (e.g. calling finish() manually inside a try block whose try-with-resources then invokes close() again after an exception path already closed the stream).
Common situations: Manual finish()+close() pairs plus an auto-close scope; finally blocks that attempt finish() to salvage a partial archive after an error; reusing a stream object across write attempts.
Related errors
AI-assisted analysis of Tencent/tinker@1b7ea02c23 (2026-08-14).
Data as JSON: /api/errors/db711de78c2988f5.
Report an issue: GitHub.