Conversation
…current saves
The authoring UI fires a save request on every form change without
debouncing. When a teacher edits two fields in rapid succession, two
POST /api/author/project/save/{id} requests can reach the backend
concurrently, both calling saveProjectContentToDisk() on the same
file.
Each call opens a new FileOutputStream (which truncates the file)
and writes through a BufferedWriter. When two threads race:
1. Thread A opens the file (truncates), begins writing via
BufferedWriter which flushes in 8 KiB chunks
2. Thread B opens the same file (truncates again), writes its
content, and closes
3. Thread A's close() flushes its remaining buffer at Thread A's
file descriptor position, which is now past the end of Thread B's
shorter content
4. The file ends up with Thread B's content plus trailing bytes from
Thread A's final buffer flush
The trailing bytes are always the end of Thread A's JSON (typically
"]}"), producing a file that starts as valid JSON but has extra data
appended. The Angular client's HttpClient then fails to parse the
response, the VLE never initializes, and the author sees a blank
preview page with no error message.
This was observed in production on a 778 KB project.json: two bytes
were appended, causing JSON.parse to throw "Extra data" at position
730,760. The corruption reproduced across multiple save sessions on
the same unit, confirming it is a systemic race rather than a
one-time event.
The fix replaces direct FileOutputStream writes with a
write-to-temp-then-atomic-rename pattern in both
saveProjectContentToDisk() and replaceMetadataInProjectJSONFile():
1. Create a temp file in the same directory (Files.createTempFile)
2. Write the full content to the temp file using try-with-resources
3. Set the temp file readable (0644) so static-file servers can
serve it
4. Atomically rename the temp file to the target path
(Files.move with ATOMIC_MOVE, falling back to REPLACE_EXISTING
on filesystems that do not support atomic moves)
5. Clean up the temp file in a finally block
Because rename() is atomic on POSIX filesystems, readers always see
either the old complete file or the new complete file, never a
partially written or interleaved version.
The try-with-resources conversion also fixes an existing fd leak:
if writer.write() threw an exception in the old code, writer.close()
was never called because there was no finally block.
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.
The authoring UI fires a save request on every form change without debouncing. When a teacher edits two fields in rapid succession, two POST /api/author/project/save/{id} requests can reach the backend concurrently, both calling saveProjectContentToDisk() on the same file.
Each call opens a new FileOutputStream (which truncates the file) and writes through a BufferedWriter. When two threads race:
The trailing bytes are always the end of Thread A's JSON (typically "]}"), producing a file that starts as valid JSON but has extra data appended. The Angular client's HttpClient then fails to parse the response, the VLE never initializes, and the author sees a blank preview page with no error message.
This was observed in production on a 778 KB project.json: two bytes were appended, causing JSON.parse to throw "Extra data" at position 730,760. The corruption reproduced across multiple save sessions on the same unit, confirming it is a systemic race rather than a one-time event.
The fix replaces direct FileOutputStream writes with a write-to-temp-then-atomic-rename pattern in both
saveProjectContentToDisk() and replaceMetadataInProjectJSONFile():
Because rename() is atomic on POSIX filesystems, readers always see either the old complete file or the new complete file, never a partially written or interleaved version.
The try-with-resources conversion also fixes an existing fd leak: if writer.write() threw an exception in the old code, writer.close() was never called because there was no finally block.