fix: close library conversion streams deterministically - #32
Merged
Conversation
A conversion opened the source to read it and a temporary to write into, and closed both on the line after the work was done. That line is not reached when the reader or the writer throws, so both handles stayed open. On Windows the temporary file then cannot be deleted, which is how this surfaced: a deliberately refused conversion blocked the cleanup of a test's temporary directory during C9. The eight streams of the four file-source conversion branches are now held by try-with-resources, so they are released whether the conversion succeeds or fails. The two directory-source branches and the two fs output branches own no local stream and are untouched. No behaviour change on the nominal path: the writer still finishes before the stream closes, the destination is still computed after that, and the move still precedes recordProvenance. No new catch, no absorbed exception, no fallback. ConversionStreamLifecycleTest asserts the consequence rather than the mechanism: it makes a conversion fail for a real reason, then deletes the work folder in a single attempt. Verified red against the previous production file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A conversion opens two files: the source, to read it, and a temporary, to write into. Both were closed on the line after the work was done — which is fine until the reader or the writer throws, because then that line is never reached and both handles stay open.
studio-core: readers and writers were left alone.Not hypothetical
This surfaced while writing the C9 confinement tests. A conversion refused on purpose left its temporary file locked, and the temporary directory could not be cleaned up afterwards. C9 worked around it by placing its work folder elsewhere, with a comment, and reported it as out of scope. This is that follow-up.
The leak is identical on every platform. Windows is simply where it is observable, because POSIX unlinks a file that is still open.
What changed
Eight in total: four
FileInputStream, fourFileOutputStream. The two directory-source branches read through a reader that takes aPath, and the two fs-output branches write through a writer that takes aPath— neither owns a local stream, and neither was touched.The shape, applied uniformly:
The result is assigned inside and used outside, so the stream is closed before anything else happens with it — which is what makes the ordering below hold.
Ownership, checked rather than assumed
Before delegating a close to
studio-core, it was measured:ArchiveStoryPackReader.readBinaryStoryPackReader.readArchiveStoryPackWriter.writeBinaryStoryPackWriter.writeNominal path only is precisely the case this PR does not rely on.
web-uiopens these streams, soweb-uicloses them; closing an already-closed stream is a no-op, so the redundancy costs nothing and the guarantee no longer depends onstudio-corebehaviour that this PR does not change.No behaviour change
The nominal order is preserved in all six conversions:
writer finishes → stream closes → destination is computed → move →
recordProvenanceThe writer still completes before the close; the destination is still computed after the temporary is complete; the move still immediately precedes the provenance record.
catch—git diffadds zero.move→recordProvenancepairings are adjacent, unchanged: 246→247, 286→287, 331→332, 363→364, 403→404, 438→439.libraryPath() +remains at zero, 28 occurrences of the confinement authority.new FileInputStream6,new FileOutputStream4, 0 outside try-with-resources, 0 manual.close().Validation
ConversionStreamLifecycleTest— 4 tests — asserts the consequence, not the mechanism. No counting ofclose()calls, no mock stream: it makes a conversion fail for a real reason, then deletes the work folder.One attempt. No
System.gc(), no sleep, no retry — any of those would let the JDK cleaner close a leaked handle and turn the test green while the defect was still there.BinaryStoryPackWriterthrows after the output was openedThe first three are Windows-only, and that is a statement about the platform rather than the code. The nominal case runs everywhere and carries equal weight: a conversion that did nothing would pass every failure test.
RED verified by
git stashof the production file alone: 3 of the 4 failed, each with "Le processus ne peut pas accéder au fichier car ce fichier est utilisé par un autre processus". Production restored withgit stash pop.studio-metadata/core/driverstudio-web-uiBUILD SUCCESS. JavaScript untouched and not re-run: still 57.Scope
Three files, 226 insertions, 25 deletions.
git diff --checkclean. Nostudio-corereader or writer, no frontend, no POM, nodriver, nometadata, no controller, no cleanup, no provenance change, no confinement change.FOLLOW-UP — HTTP status on refusal
/downloadstill answers 500 where a 4xx would be right. Unchanged here, and unrelated: distinguishing "refused" from "absent" needs a richer return type thanOptional<File>, and therefore a public contract change.FOLLOW-UP — Resource handling inside studio-core
ArchiveStoryPackReader,BinaryStoryPackReaderandArchiveStoryPackWriterclose the wrappers they create on the nominal path only, andBinaryStoryPackWritercloses none. This PR makesweb-uiindependent of that, but does not correct it. Any other caller of these readers and writers still is.🤖 Generated with Claude Code