Skip to content

fix: close library conversion streams deterministically - #32

Merged
antoinevalentinHA merged 1 commit into
masterfrom
fix/c8c-library-conversion-streams
Aug 20, 2026
Merged

fix: close library conversion streams deterministically#32
antoinevalentinHA merged 1 commit into
masterfrom
fix/c8c-library-conversion-streams

Conversation

@antoinevalentinHA

Copy link
Copy Markdown
Owner

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.

  • Eight streams, in the four file-source conversion branches, now held by try-with-resources.
  • Released whether the conversion succeeds or fails.
  • No change to studio-core: readers and writers were left alone.
  • No business change: same nominal behaviour, same order, same results.
  • C7-2 provenance and C9 confinement verified intact.
  • No frontend change.

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

Branch Source Output Streams
archive → raw file file 2
archive → fs file 1
raw → archive file file 2
raw → fs file 1
fs → archive directory file 1
fs → raw directory file 1

Eight in total: four FileInputStream, four FileOutputStream. The two directory-source branches read through a reader that takes a Path, and the two fs-output branches write through a writer that takes a Path — neither owns a local stream, and neither was touched.

The shape, applied uniformly:

StoryPack storyPack;
try (FileInputStream fis = new FileInputStream(requireLibraryEntry(packPath).toFile())) {
    storyPack = packReader.read(fis);
}

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:

Component Closes its wrapper?
ArchiveStoryPackReader.read yes — nominal path only
BinaryStoryPackReader.read yes — nominal path only
ArchiveStoryPackWriter.write yes — nominal path only
BinaryStoryPackWriter.write no

Nominal path only is precisely the case this PR does not rely on. web-ui opens these streams, so web-ui closes them; closing an already-closed stream is a no-op, so the redundancy costs nothing and the guarantee no longer depends on studio-core behaviour 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 → recordProvenance

The writer still completes before the close; the destination is still computed after the temporary is complete; the move still immediately precedes the provenance record.

  • No new catchgit diff adds zero.
  • No absorbed exception, no fallback, no retry. A conversion that failed before still fails, with the same exception.
  • The six moverecordProvenance pairings are adjacent, unchanged: 246→247, 286→287, 331→332, 363→364, 403→404, 438→439.
  • C9 confinement untouched: libraryPath() + remains at zero, 28 occurrences of the confinement authority.
  • Static check: new FileInputStream 6, new FileOutputStream 4, 0 outside try-with-resources, 0 manual .close().

Validation

ConversionStreamLifecycleTest — 4 tests — asserts the consequence, not the mechanism. No counting of close() 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.

Test Fails because
writer throws the identifier is not a UUID, so BinaryStoryPackWriter throws after the output was opened
reader throws the archive is truncated at half its length
confinement refuses the identifier escapes the library
nominal nothing — it must still convert, install the artefact, and hold nothing

The 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 stash of 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 with git stash pop.

Module Before After
studio-metadata / core / driver 9 / 4 / 188 unchanged
studio-web-ui 77 81, 2 skipped
Java standard 278 282, 14 skipped

BUILD SUCCESS. JavaScript untouched and not re-run: still 57.

Scope

Three files, 226 insertions, 25 deletions. git diff --check clean. No studio-core reader or writer, no frontend, no POM, no driver, no metadata, no controller, no cleanup, no provenance change, no confinement change.


FOLLOW-UP — HTTP status on refusal

/download still answers 500 where a 4xx would be right. Unchanged here, and unrelated: distinguishing "refused" from "absent" needs a richer return type than Optional<File>, and therefore a public contract change.

FOLLOW-UP — Resource handling inside studio-core

ArchiveStoryPackReader, BinaryStoryPackReader and ArchiveStoryPackWriter close the wrappers they create on the nominal path only, and BinaryStoryPackWriter closes none. This PR makes web-ui independent of that, but does not correct it. Any other caller of these readers and writers still is.

🤖 Generated with Claude Code

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>
@antoinevalentinHA
antoinevalentinHA merged commit 4d53e90 into master Aug 20, 2026
3 checks passed
@antoinevalentinHA
antoinevalentinHA deleted the fix/c8c-library-conversion-streams branch August 20, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant