Skip to content

Commit

Permalink
core: Retry Files.move on AccessDeniedException, resolve #183
Browse files Browse the repository at this point in the history
  • Loading branch information
TheElectronWill committed Aug 20, 2024
1 parent b43a1ec commit d2a7522
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,12 @@
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.*;

import com.electronwill.nightconfig.core.*;
import com.electronwill.nightconfig.core.concurrent.ConcurrentCommentedConfig;
import com.electronwill.nightconfig.core.concurrent.StampedConfig;
import com.electronwill.nightconfig.core.io.ConfigParser;
import com.electronwill.nightconfig.core.io.ConfigWriter;
import com.electronwill.nightconfig.core.io.ParsingMode;
import com.electronwill.nightconfig.core.io.WritingException;
import com.electronwill.nightconfig.core.io.WritingMode;
import com.electronwill.nightconfig.core.io.*;
import com.electronwill.nightconfig.core.utils.ConcurrentCommentedConfigWrapper;

/**
Expand Down Expand Up @@ -126,10 +120,19 @@ private void saveNow() {
// The FileWriter is not kept open in that case, because the temporary file will no longer exist after the
// move.
if (writingMode == WritingMode.REPLACE_ATOMIC) {
Path tmp = nioPath.resolveSibling(nioPath.getFileName() + ".new.tmp");
Path tmp = nioPath.resolveSibling(IoUtils.tempConfigFileName(nioPath));
try (BufferedWriter writer = Files.newBufferedWriter(tmp, charset, WRITE, CREATE, TRUNCATE_EXISTING)) {
configWriter.write(copy, writer);
Files.move(tmp, nioPath, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
String msg = String.format("Failed to write (%s) the config to: %s",
writingMode.toString(), tmp.toString());
throw new WritingException(msg, e);
}
// Flush and close the output before atomically moving the file.
try {
IoUtils.retryIfAccessDenied("move", () -> {
Files.move(tmp, nioPath, StandardCopyOption.ATOMIC_MOVE);
});
} catch (AtomicMoveNotSupportedException e) {
// can fail in some conditions (OS and filesystem-dependent)
String msg = String.format(
Expand All @@ -140,7 +143,7 @@ private void saveNow() {
throw new WritingException(msg, e);
} catch (IOException e) {
// regular IO exception
String msg = String.format("Failed to write (%s) the config to: %s",
String msg = String.format("Failed to atomically write (%s) the config to: %s",
writingMode.toString(), tmp.toString());
throw new WritingException(msg, e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ default void write(UnmodifiableConfig config, OutputStream output) {
}

/**
* Writes a configuration. The content of the file is overwritten. This method is equivalent to
*
* Writes a configuration. The content of the file is overwritten. This method
* is equivalent to
*
* <pre>
* write(config, file, false)
* </pre>
Expand All @@ -86,10 +87,21 @@ default void write(UnmodifiableConfig config, Path file, WritingMode writingMode
default void write(UnmodifiableConfig config, Path file, WritingMode writingMode, Charset charset) {
if (writingMode == WritingMode.REPLACE_ATOMIC) {
// write to another file, then atomically move it
Path tmp = file.resolveSibling(file.getFileName().toString() + ".new.tmp");
String tmpFileName = IoUtils.tempConfigFileName(file);
Path tmp = file.resolveSibling(tmpFileName);
try (OutputStream output = Files.newOutputStream(tmp, WRITE, CREATE, TRUNCATE_EXISTING)) {
write(config, output, charset);
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
// regular IO exception
String msg = String.format("Failed to write (%s) the config to: %s",
writingMode.toString(), file.toString());
throw new WritingException(msg, e);
}
// Flush and close the output before atomically moving the file.
try {
IoUtils.retryIfAccessDenied("move", () -> {
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE);
});
} catch (AtomicMoveNotSupportedException e) {
// can fail in some conditions (OS and filesystem-dependent)
String msg = String.format(
Expand All @@ -100,7 +112,7 @@ default void write(UnmodifiableConfig config, Path file, WritingMode writingMode
throw new WritingException(msg, e);
} catch (IOException e) {
// regular IO exception
String msg = String.format("Failed to write (%s) the config to: %s",
String msg = String.format("Failed to atomically write (%s) the config to: %s",
writingMode.toString(), file.toString());
throw new WritingException(msg, e);
}
Expand All @@ -118,8 +130,9 @@ default void write(UnmodifiableConfig config, Path file, WritingMode writingMode
}

/**
* Writes a configuration. The content of the file is overwritten. This method is equivalent to
*
* Writes a configuration. The content of the file is overwritten. This method
* is equivalent to
*
* <pre>
* write(config, file, false)
* </pre>
Expand Down
129 changes: 129 additions & 0 deletions core/src/main/java/com/electronwill/nightconfig/core/io/IoUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.electronwill.nightconfig.core.io;

import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;

/**
* IO utilities for INTERNAL use only (do not use oustide of night-config).
*/
public final class IoUtils {
/**
* Like {@code Runnable}, but with a throwable {@code IOException}.
*/
@FunctionalInterface
public static interface IoRunnable {
void run() throws IOException;
}

public static final class RetryFailedException extends IOException {
RetryFailedException(String msg, IOException cause) {
super(msg, cause);
}
}

static class OptionHolder {
static final long RETRY_DELAY_MILLIS;
static final int RETRY_MAX_TIMES;

static {
boolean isWindows = System.getProperty("os.name", "?").trim().toLowerCase().startsWith("Windows");
// Default max delay (delay*times) per OS (chosen arbitrarily, knowing that most
// issues happen on Windows):
// - Windows: 1.5s (3 retrys)
// - Others: 0.5s (1 try + 500ms delay + 1 retry)

String delayProps = System.getProperty("nightconfig.accessDeniedRetryDelayMillis", "?");
long delay;
try {
delay = Long.parseLong(delayProps);
} catch (NumberFormatException ex) {
delay = 500;
}

String timesProps = System.getProperty("nightconfig.accessDeniedRetryMaxTimes", "?");
int times;
try {
times = Integer.parseInt(timesProps);
} catch (NumberFormatException ex) {
times = isWindows ? 3 : 1;
}

RETRY_DELAY_MILLIS = delay;
RETRY_MAX_TIMES = times;
}
}

static String[] splitOnce(String s, char c) {
int i = s.lastIndexOf(c);
if (i < 0) {
return new String[] { s };
} else {
return new String[] { s.substring(0, i), s.substring(i + 1, s.length()) };
}
}

/**
* Generates a filename (not path) for a temporary config file, for use with {@link WritingMode#REPLACE_ATOMIC}.
* Tries to keep the extension of the original file, to make it easier to find config files and to add them to
* file scanning whitelist (see the issue related to Windows Defender locking config files).
*
* @param originalFile the original config file
* @return a filename for the temporary file (the file is not created by this method)
*/
public static String tempConfigFileName(Path originalFile) {
String filename = originalFile.toString();
String[] parts = splitOnce(filename, '.');
if (parts.length == 1) {
return filename + ".new.tmp";
} else {
return parts[0] + ".new.tmp." + parts[1];
}
}

/**
* Run an IO operation and retry it (at most {@code maxRetries} retries) if it
* fails with {@code AccessDeniedException}.
* See https://github.com/TheElectronWill/night-config/issues/183.
*
* @param name a name to print in error messages
* @param r the operation to run
* @throws IOException if it fails after retrying too many times, or for an error other than {@code AccessDeniedException}
*/
public static void retryIfAccessDenied(String name, IoRunnable r) throws IOException {
// load the default values from java properties, keep them in memory once loaded
retryIfAccessDenied(
name,
r,
OptionHolder.RETRY_MAX_TIMES,
OptionHolder.RETRY_DELAY_MILLIS,
TimeUnit.MILLISECONDS
);
}

public static void retryIfAccessDenied(String name, IoRunnable r, int maxRetries, long retryDelay,
TimeUnit delayUnit)
throws IOException {
AccessDeniedException lastException = null;
for (int i = 0; i <= maxRetries; i++) {
try {
r.run();
return;
} catch (AccessDeniedException ex) {
// The file may be locked by another application (like an antivirus),
// try again after some time
lastException = ex;
try {
Thread.sleep(delayUnit.toMillis(retryDelay));
} catch (InterruptedException e) {
// ignore
}
} catch (IOException ex) {
throw ex;
}
}
String msg = String.format("IO operation '%s' failed after %s attempts", name, maxRetries);
throw new RetryFailedException(msg, lastException);
}
}

0 comments on commit d2a7522

Please sign in to comment.