This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/karaf.git
The following commit(s) were added to refs/heads/main by this push:
new 772fcec2d7 Fix race condition corrupting cfg files when updating an
existing configuration (#2897)
772fcec2d7 is described below
commit 772fcec2d72bcad44328435c804a8e47f45c5f28
Author: JB Onofré <[email protected]>
AuthorDate: Mon Sep 14 15:20:03 2026 +0200
Fix race condition corrupting cfg files when updating an existing
configuration (#2897)
* Fix race condition corrupting cfg files when updating an existing
configuration
FeatureConfigInstaller.updateExistingConfig() wrote directly to the target
cfg file when appending to / overriding an existing configuration. Since
cfg.update() also triggers fileinstall to persist the same configuration
on the CM Event Dispatcher thread, two unsynchronized writers could hit the
same file at once, producing torn/corrupted content. The "new file" path
already avoided this by writing to a temp file and renaming it atomically
into place (KARAF-7389); this applies the same pattern to the
existing-file path.
Fixes #2805
* GH-2805: Use Files.move with REPLACE_EXISTING instead of File.renameTo
File.renameTo() silently fails (returns false, no exception) on Windows
when the destination already exists, leaving the cfg file stale and the
temp file orphaned. This broke updateExistingConfig() since it is only
called when the target file already exists, causing the Windows CI job
to fail (AppendTest).
* Add fallback for atomic file move
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---------
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../internal/service/FeatureConfigInstaller.java | 21 ++++++++--
.../service/FeatureConfigInstallerTest.java | 45 +++++++++++++++++++++-
2 files changed, 62 insertions(+), 4 deletions(-)
diff --git
a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeatureConfigInstaller.java
b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeatureConfigInstaller.java
index cafd76c96a..239abc3b67 100644
---
a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeatureConfigInstaller.java
+++
b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeatureConfigInstaller.java
@@ -21,6 +21,8 @@ import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.regex.Pattern;
@@ -330,7 +332,12 @@ public class FeatureConfigInstaller {
} else {
props.save(tmpCfgFile);
}
- tmpCfgFile.renameTo(cfgFile);
+try {
+ Files.move(tmpCfgFile.toPath(), cfgFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
+ } catch (java.nio.file.AtomicMoveNotSupportedException e) {
+ Files.move(tmpCfgFile.toPath(), cfgFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
+ }
} else {
updateExistingConfig(props, append, cfgFile, jsonFormat);
}
@@ -400,11 +407,19 @@ public class FeatureConfigInstaller {
}
}
storage.mkdirs();
+ // write to a temporary file and rename it to the target file so that
a concurrent writer
+ // (e.g. fileinstall persisting the configuration update on the CM
Event Dispatcher thread)
+ // never observes a partially written / corrupted cfg file
+ File tmpCfgFile = File.createTempFile(cfgFile.getName(), ".tmp",
cfgFile.getParentFile());
if (jsonFormat) {
- Configurations.buildWriter().build(new
FileWriter(cfgFile)).writeConfiguration(new Hashtable(properties));
+ Configurations.buildWriter().build(new
FileWriter(tmpCfgFile)).writeConfiguration(new Hashtable(properties));
} else {
- properties.save(cfgFile);
+ properties.save(tmpCfgFile);
}
+ // File.renameTo() silently fails on Windows when the destination
already exists,
+ // so use Files.move() with REPLACE_EXISTING to get a working atomic
replace on every OS
+ Files.move(tmpCfgFile.toPath(), cfgFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
}
private boolean isInternalKey(String key) {
diff --git
a/features/core/src/test/java/org/apache/karaf/features/internal/service/FeatureConfigInstallerTest.java
b/features/core/src/test/java/org/apache/karaf/features/internal/service/FeatureConfigInstallerTest.java
index c9497fc8af..277bd55a2f 100644
---
a/features/core/src/test/java/org/apache/karaf/features/internal/service/FeatureConfigInstallerTest.java
+++
b/features/core/src/test/java/org/apache/karaf/features/internal/service/FeatureConfigInstallerTest.java
@@ -16,14 +16,21 @@
*/
package org.apache.karaf.features.internal.service;
+import org.apache.felix.utils.properties.TypedProperties;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import java.io.File;
+import java.io.FileWriter;
+import java.io.StringReader;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.util.Objects;
public class FeatureConfigInstallerTest {
-
+
private void substEqual(final String src, final String subst) {
assertEquals(FeatureConfigInstaller.substFinalName(src), subst);
}
@@ -51,4 +58,40 @@ public class FeatureConfigInstallerTest {
substEqual("${foo}${bar}/${bar}${foo}", foo + "/" + foo);
}
+ /**
+ * GH-2805: updating an existing cfg file (append or override) must write
through a
+ * temporary file and rename it into place, so that a concurrent writer
(e.g. fileinstall
+ * persisting the same configuration on the CM Event Dispatcher thread)
can never observe
+ * a partially written / corrupted cfg file, and no leftover temp file
remains behind.
+ */
+ @Test
+ public void testUpdateExistingConfigWritesAtomically() throws Exception {
+ File tmpDir =
Files.createTempDirectory("karaf-feature-config-installer-test").toFile();
+ System.setProperty("karaf.etc", tmpDir.getAbsolutePath());
+
+ File cfgFile = new File(tmpDir, "my.pid.cfg");
+ try (FileWriter writer = new FileWriter(cfgFile)) {
+ writer.write("existing.key=existing.value\n");
+ }
+
+ TypedProperties toAppend = new TypedProperties();
+ toAppend.load(new StringReader("appended.key=appended.value\n"));
+
+ FeatureConfigInstaller installer = new FeatureConfigInstaller(null,
true);
+
+ Method updateExistingConfig =
FeatureConfigInstaller.class.getDeclaredMethod(
+ "updateExistingConfig", TypedProperties.class, boolean.class,
File.class, boolean.class);
+ updateExistingConfig.setAccessible(true);
+ updateExistingConfig.invoke(installer, toAppend, true, cfgFile, false);
+
+ TypedProperties result = new TypedProperties();
+ result.load(cfgFile);
+ assertEquals("existing.value", result.get("existing.key"));
+ assertEquals("appended.value", result.get("appended.key"));
+
+ File[] leftovers = tmpDir.listFiles((dir, name) ->
name.endsWith(".tmp"));
+ assertTrue("no temporary file must be left behind after the atomic
rename",
+ Objects.requireNonNull(leftovers).length == 0);
+ }
+
}