Skip to content

[local] Serve a file from one open handle so Content-Length matches the body - #35

Merged
JornC merged 1 commit into
aerius:mainfrom
JornC:local-consistent-read
Sep 21, 2026
Merged

JornC merged 1 commit into
aerius:mainfrom
JornC:local-consistent-read

Conversation

@JornC

@JornC JornC commented Sep 18, 2026

Copy link
Copy Markdown
Member

This makes local file server reads report the precise content length of the file it is serving. Spring Boot serves the FileUrlResource non-atomically: first it sets the content length, then some microseconds later it writes the file. If the file is replaced during this window, the content length and the file do not match, and we get a 500 error. This happens very rarely, but it is a consistent flake. Only occurs for the local file server.

See gist for full history, cause, and reproduction: https://gist.github.com/JornC/fca15a1644f766b5942c065b2fb266af

See also item AER-4676 for a proper architectural approach to avoid this type of thing being able to happen altogether (i.e. not overwriting files).


LLM-generated technical summary

LocalFileController.getFile returned a lazy FileUrlResource. Spring's ResourceHttpMessageConverter calls contentLength() in addDefaultHeaders to set Content-Length, and only afterwards opens the file in writeContent. When the file is atomically renamed over between those two calls, Tomcat's IdentityOutputFilter writes the new file's bytes cut to the old length and drops the rest, so the caller receives 200 OK with a truncated body. Register hits this on every upload: the API writes a 27-byte pending validation.json, the frontend polls it at once, and the validation thread overwrites it with the roughly 400-byte real result. The register API then fails to parse the 27-byte prefix and answers a bare 500 that nothing logs. The atomic-write fix in #30 removed the window in which the file was missing (the 404s) but not this one.

The controller now opens the file once with Files.newByteChannel, takes the length from that channel, streams from the same channel through an InputStreamResource, and sets Content-Length explicitly. A handle opened before the rename keeps the old file, so the header and the body always describe the same version. Spring does not compute a length for an InputStreamResource and leaves an explicit one alone, and it closes the stream after copying. A missing file still throws an IOException and still becomes a 404.

LocalFileControllerTest.testGetFileWhileOverwritten replaces the file with alternating 27-byte and 4 KB versions from a writer thread while performing 2000 GETs, and asserts that Content-Length equals the served body and that the body is one complete version. Against the old controller it fails on the first iteration. In a local end-to-end run (one writer, eight readers, 20 seconds) the current main served 37 truncated bodies out of 12,400 reads; with this change, 0 out of 151,400.

@JornC
JornC requested a review from Hilbrand September 18, 2026 13:07

@Hilbrand Hilbrand left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If tested in actual use, and it still worked I approve.

@JornC
JornC merged commit 8bec9f4 into aerius:main Sep 21, 2026
1 check passed
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.

2 participants