Skip to content

fix: use atomic write for project.json to prevent corruption from concurrent saves - #358

Open
Isaries wants to merge 1 commit into
WISE-Community:developfrom
Isaries:fix/atomic-project-save
Open

Isaries wants to merge 1 commit into
WISE-Community:developfrom
Isaries:fix/atomic-project-save

Conversation

@Isaries

@Isaries Isaries commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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.

…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.
@Isaries Isaries changed the title fix: use atomic write for project.json to prevent corruption from con… fix: use atomic write for project.json to prevent corruption from concurrent saves Sep 29, 2026
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