Skip to content

FELIX-6515 Fix race condition when rewriting config files in ConfigInstaller - #233

Open
FelixMarxIBM wants to merge 5 commits into
apache:masterfrom
instana:FELIX-6515
Open

FelixMarxIBM wants to merge 5 commits into
apache:masterfrom
instana:FELIX-6515

Conversation

@FelixMarxIBM

@FelixMarxIBM FelixMarxIBM commented Aug 30, 2023 •

Copy link
Copy Markdown
Contributor

Summary

This pull request addresses FELIX-6515, a race condition bug where rewriting a config file could result in it being temporarily truncated (empty) before being filled with new content. A DirectoryWatcher holding a read lock could observe this empty intermediate state and subsequently write it back to disk, corrupting the configuration.

The fix introduces two improvements:

  1. Write lock protection: Config file writes are now guarded by a global write lock (fileInstall.lock.writeLock()) to prevent DirectoryWatcher from observing an empty/partial file during the write operation.
  2. Skip unnecessary writes: Before writing to disk, the new content is compared against the existing file content. If they are identical, the file is left untouched (preserving its lastModified timestamp and avoiding unnecessary checksum updates).

Files Changed

📄 fileinstall/src/main/java/org/apache/felix/fileinstall/internal/ConfigInstaller.java

Refactors the config file write-back logic in the configuration event handler:

  • Serializes the updated configuration into a ByteArrayOutputStream first, rather than writing directly to the file, allowing content comparison before any disk I/O.
  • Compares the new serialized content with the existing file content using Arrays.equals and Files.readAllBytes; skips the write entirely if the content is unchanged.
  • Wraps the file write and checksum update in a writeLock().lockInterruptibly() / unlock() block to prevent DirectoryWatcher from observing a partially written (empty) file during the operation.
  • Adds imports for ByteArrayOutputStream, OutputStream, Files, and Arrays.

📄 fileinstall/src/test/java/org/apache/felix/fileinstall/internal/ConfigInstallerTest.java

Adds two new test cases to validate the FELIX-6515 fix:

  • testDoConfigurationEventRewritesFileUnderWriteLock: Verifies that when a config file is rewritten with changed content, the write operation is performed while holding the write lock, and the lock is properly released afterward. Uses a custom FileInstall subclass to capture whether the write lock is held at the time updateChecksum is called.

  • testDoConfigurationEventDoesNotRewriteUnchangedFile: Verifies that when the configuration content has not changed, the file's lastModified timestamp is preserved (file is not touched) and updateChecksum is not called. Uses a known lastModified value set via file.setLastModified() to assert immutability.

* Writing a file is a 2 step process where the file is created empty and then filled
* There is a race-condition, where the DirectoryWatcher might pick up the creation of the
* empty file. This empty file will be written back to disk on a follow-up CM_UPDATED event.
* Using the global write lock will block the DirectoryWatcher to wait for the write to finish
* and therefore never to read an empty file.
@FelixMarxIBM

Copy link
Copy Markdown
Contributor Author

I'll try to create a test for this race-condition but doing that might be hard to accomplish.

FelixMarxIBM and others added 4 commits November 24, 2025 10:56
…ite lock

ConfigInstaller truncates a .cfg/.config file before filling it when writing a
configuration back to disk. The fix for FELIX-6515 guards that rewrite, and the
following checksum update, with the global FileInstall write lock so that a
DirectoryWatcher, which processes files under the read lock, can never pick up
the empty file and write it back on a follow-up CM_UPDATED event.

Add ConfigInstallerTest.testDoConfigurationEventRewritesFileUnderWriteLock to
cover that. The test is deterministic and does not rely on threads or sleeping:
it runs doConfigurationEvent on the test thread and records whether the current
thread holds the write lock when the encoding is looked up for loading the file
(not held) and for writing it (held), and when the checksum is updated (held).
It also checks the rewritten content and that the lock is released afterwards.
Without the fix it fails with expected:<[false, true]> but was:<[false, false]>.
…te lock

When ConfigInstaller writes a configuration back to its .cfg/.config file
after a CM_UPDATED event, it used to truncate and rewrite the file every time,
even if the content produced from ConfigAdmin was identical to what is already
on disk. Besides needless I/O, this bumps lastModified, which forces a
checksum update and gives the DirectoryWatcher a reason to look at the file
again.

Changes:
- Serialize the properties into a byte array (using the same configured
  encoding() as is used for loading the file) before taking the lock, and
  compare it with the current file content. Only if the bytes differ the file
  is written and the checksum updated. If nothing would change, the file is not
  touched, so lastModified stays the same and no checksum update is needed, as
  the scanner's checksum (name, lastModified, length, writeable flag) remains
  valid.
- Restrict the FileInstall write lock to the part that needs it: comparing,
  writing the file and updating the checksum. Building the byte array does not
  touch the file and is done outside the lock. The lock still guarantees that
  a DirectoryWatcher, which processes files under the read lock, cannot
  observe the truncated, empty file.

Notes:
- The comparison is byte exact. A file that does not round-trip through
  TypedProperties load/save (e.g. CRLF line endings, missing trailing newline)
  is rewritten once and is stable afterwards. Files that cannot be decoded with
  the configured encoding are rewritten as before.
- If the file disappears before it is compared, reading it now fails and is
  logged as "Unable to save configuration" instead of silently recreating it.

Tests:
- Add testDoConfigurationEventDoesNotRewriteUnchangedFile, which verifies that
  lastModified is unchanged and updateChecksum is not called for an unchanged
  file.
- Simplify testDoConfigurationEventRewritesFileUnderWriteLock: the encoding is
  now read outside of the lock, so drop the encoding lookup assertion and keep
  the checks that the checksum update happens under the write lock and that the
  lock is released afterwards.
@FelixMarxIBM FelixMarxIBM changed the title FELIX-6515: Add global write lock to write back cfg files FELIX-6515 Fix race condition when rewriting config files in ConfigInstaller Nov 12, 2026
@paulrutter

Copy link
Copy Markdown
Contributor

Thanks for the fix, the approach (serialize first, compare, write under the write lock) looks right and the two tests cover the new behaviour well. A few findings, roughly by importance:

  1. Read-modify-write is still partly outside the lock. file is read and merged into props (the props.load(r) / "removed properties" logic) before writeLock().lockInterruptibly(). If DirectoryWatcher processes a user edit of the file between that read and the lock acquisition, the content computed from the stale read is compared against and then written over the newer file. Consider taking the write lock before loading the file, so load, merge, compare and write are one critical section (the serialization is cheap).

  2. Possible self-deadlock if the event is delivered on a watcher thread. DirectoryWatcher holds lock.readLock() while it installs/updates configs, and a ReentrantReadWriteLock cannot upgrade read to write. Felix ConfigAdmin delivers ConfigurationEvents asynchronously so this is fine there, but a ConfigAdmin implementation that dispatches synchronously on the calling thread would hang here instead of just logging. Worth a comment, or tryLock with a timeout.

  3. Interrupt is swallowed. lockInterruptibly() throws InterruptedException, which is caught by the generic catch (Exception e) and logged as "Unable to save configuration" without restoring the interrupt flag. Please add Thread.currentThread().interrupt() for that case.

  4. Files.readAllBytes can now fail where the old code did not. If the file is deleted between the file.isFile() check and the read, this throws NoSuchFileException (logged as an error), whereas the old code would have recreated it via FileOutputStream. Treating a missing file as "changed" would keep the old behaviour.

  5. The truncate-then-write window still exists for readers outside fileInstall.lock (other processes, editors, backup tools). Writing to a temp file in the same directory and Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) would remove the empty-file state entirely, and would make the lock less critical. Not a blocker for this PR.

  6. Nit: the test file adds two trailing blank lines before testSetConfiguration.

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