Commit 8ccf7ee4 authored by Phillip Webb's avatar Phillip Webb

Make file detection more resilient across restarts

Retain file snapshot state across devtools restarts to help prevent
detection failures.

Closes gh-19543
parent cc5f2537
...@@ -31,6 +31,7 @@ import org.springframework.boot.devtools.classpath.ClassPathRestartStrategy; ...@@ -31,6 +31,7 @@ import org.springframework.boot.devtools.classpath.ClassPathRestartStrategy;
import org.springframework.boot.devtools.classpath.PatternClassPathRestartStrategy; import org.springframework.boot.devtools.classpath.PatternClassPathRestartStrategy;
import org.springframework.boot.devtools.filewatch.FileSystemWatcher; import org.springframework.boot.devtools.filewatch.FileSystemWatcher;
import org.springframework.boot.devtools.filewatch.FileSystemWatcherFactory; import org.springframework.boot.devtools.filewatch.FileSystemWatcherFactory;
import org.springframework.boot.devtools.filewatch.SnapshotStateRepository;
import org.springframework.boot.devtools.livereload.LiveReloadServer; import org.springframework.boot.devtools.livereload.LiveReloadServer;
import org.springframework.boot.devtools.restart.ConditionalOnInitializedRestarter; import org.springframework.boot.devtools.restart.ConditionalOnInitializedRestarter;
import org.springframework.boot.devtools.restart.RestartScope; import org.springframework.boot.devtools.restart.RestartScope;
...@@ -141,7 +142,7 @@ public class LocalDevToolsAutoConfiguration { ...@@ -141,7 +142,7 @@ public class LocalDevToolsAutoConfiguration {
private FileSystemWatcher newFileSystemWatcher() { private FileSystemWatcher newFileSystemWatcher() {
Restart restartProperties = this.properties.getRestart(); Restart restartProperties = this.properties.getRestart();
FileSystemWatcher watcher = new FileSystemWatcher(true, restartProperties.getPollInterval(), FileSystemWatcher watcher = new FileSystemWatcher(true, restartProperties.getPollInterval(),
restartProperties.getQuietPeriod()); restartProperties.getQuietPeriod(), SnapshotStateRepository.STATIC);
String triggerFile = restartProperties.getTriggerFile(); String triggerFile = restartProperties.getTriggerFile();
if (StringUtils.hasLength(triggerFile)) { if (StringUtils.hasLength(triggerFile)) {
watcher.setTriggerFilter(new TriggerFileFilter(triggerFile)); watcher.setTriggerFilter(new TriggerFileFilter(triggerFile));
......
...@@ -54,6 +54,8 @@ public class FileSystemWatcher { ...@@ -54,6 +54,8 @@ public class FileSystemWatcher {
private final long quietPeriod; private final long quietPeriod;
private final SnapshotStateRepository snapshotStateRepository;
private final AtomicInteger remainingScans = new AtomicInteger(-1); private final AtomicInteger remainingScans = new AtomicInteger(-1);
private final Map<File, DirectorySnapshot> directories = new HashMap<>(); private final Map<File, DirectorySnapshot> directories = new HashMap<>();
...@@ -79,6 +81,20 @@ public class FileSystemWatcher { ...@@ -79,6 +81,20 @@ public class FileSystemWatcher {
* ensure that updates have completed * ensure that updates have completed
*/ */
public FileSystemWatcher(boolean daemon, Duration pollInterval, Duration quietPeriod) { public FileSystemWatcher(boolean daemon, Duration pollInterval, Duration quietPeriod) {
this(daemon, pollInterval, quietPeriod, null);
}
/**
* Create a new {@link FileSystemWatcher} instance.
* @param daemon if a daemon thread used to monitor changes
* @param pollInterval the amount of time to wait between checking for changes
* @param quietPeriod the amount of time required after a change has been detected to
* ensure that updates have completed
* @param snapshotStateRepository the snapshot state repository
* @since 2.4.0
*/
public FileSystemWatcher(boolean daemon, Duration pollInterval, Duration quietPeriod,
SnapshotStateRepository snapshotStateRepository) {
Assert.notNull(pollInterval, "PollInterval must not be null"); Assert.notNull(pollInterval, "PollInterval must not be null");
Assert.notNull(quietPeriod, "QuietPeriod must not be null"); Assert.notNull(quietPeriod, "QuietPeriod must not be null");
Assert.isTrue(pollInterval.toMillis() > 0, "PollInterval must be positive"); Assert.isTrue(pollInterval.toMillis() > 0, "PollInterval must be positive");
...@@ -88,6 +104,8 @@ public class FileSystemWatcher { ...@@ -88,6 +104,8 @@ public class FileSystemWatcher {
this.daemon = daemon; this.daemon = daemon;
this.pollInterval = pollInterval.toMillis(); this.pollInterval = pollInterval.toMillis();
this.quietPeriod = quietPeriod.toMillis(); this.quietPeriod = quietPeriod.toMillis();
this.snapshotStateRepository = (snapshotStateRepository != null) ? snapshotStateRepository
: SnapshotStateRepository.NONE;
} }
/** /**
...@@ -150,11 +168,12 @@ public class FileSystemWatcher { ...@@ -150,11 +168,12 @@ public class FileSystemWatcher {
*/ */
public void start() { public void start() {
synchronized (this.monitor) { synchronized (this.monitor) {
saveInitialSnapshots(); createOrRestoreInitialSnapshots();
if (this.watchThread == null) { if (this.watchThread == null) {
Map<File, DirectorySnapshot> localDirectories = new HashMap<>(this.directories); Map<File, DirectorySnapshot> localDirectories = new HashMap<>(this.directories);
this.watchThread = new Thread(new Watcher(this.remainingScans, new ArrayList<>(this.listeners), Watcher watcher = new Watcher(this.remainingScans, new ArrayList<>(this.listeners), this.triggerFilter,
this.triggerFilter, this.pollInterval, this.quietPeriod, localDirectories)); this.pollInterval, this.quietPeriod, localDirectories, this.snapshotStateRepository);
this.watchThread = new Thread(watcher);
this.watchThread.setName("File Watcher"); this.watchThread.setName("File Watcher");
this.watchThread.setDaemon(this.daemon); this.watchThread.setDaemon(this.daemon);
this.watchThread.start(); this.watchThread.start();
...@@ -162,8 +181,13 @@ public class FileSystemWatcher { ...@@ -162,8 +181,13 @@ public class FileSystemWatcher {
} }
} }
private void saveInitialSnapshots() { @SuppressWarnings("unchecked")
this.directories.replaceAll((f, v) -> new DirectorySnapshot(f)); private void createOrRestoreInitialSnapshots() {
Map<File, DirectorySnapshot> restored = (Map<File, DirectorySnapshot>) this.snapshotStateRepository.restore();
this.directories.replaceAll((f, v) -> {
DirectorySnapshot restoredSnapshot = (restored != null) ? restored.get(f) : null;
return (restoredSnapshot != null) ? restoredSnapshot : new DirectorySnapshot(f);
});
} }
/** /**
...@@ -213,14 +237,19 @@ public class FileSystemWatcher { ...@@ -213,14 +237,19 @@ public class FileSystemWatcher {
private Map<File, DirectorySnapshot> directories; private Map<File, DirectorySnapshot> directories;
private SnapshotStateRepository snapshotStateRepository;
private Watcher(AtomicInteger remainingScans, List<FileChangeListener> listeners, FileFilter triggerFilter, private Watcher(AtomicInteger remainingScans, List<FileChangeListener> listeners, FileFilter triggerFilter,
long pollInterval, long quietPeriod, Map<File, DirectorySnapshot> directories) { long pollInterval, long quietPeriod, Map<File, DirectorySnapshot> directories,
SnapshotStateRepository snapshotStateRepository) {
this.remainingScans = remainingScans; this.remainingScans = remainingScans;
this.listeners = listeners; this.listeners = listeners;
this.triggerFilter = triggerFilter; this.triggerFilter = triggerFilter;
this.pollInterval = pollInterval; this.pollInterval = pollInterval;
this.quietPeriod = quietPeriod; this.quietPeriod = quietPeriod;
this.directories = directories; this.directories = directories;
this.snapshotStateRepository = snapshotStateRepository;
} }
@Override @Override
...@@ -288,10 +317,11 @@ public class FileSystemWatcher { ...@@ -288,10 +317,11 @@ public class FileSystemWatcher {
changeSet.add(changedFiles); changeSet.add(changedFiles);
} }
} }
this.directories = updated;
this.snapshotStateRepository.save(updated);
if (!changeSet.isEmpty()) { if (!changeSet.isEmpty()) {
fireListeners(Collections.unmodifiableSet(changeSet)); fireListeners(Collections.unmodifiableSet(changeSet));
} }
this.directories = updated;
} }
private void fireListeners(Set<ChangedFiles> changeSet) { private void fireListeners(Set<ChangedFiles> changeSet) {
......
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.devtools.filewatch;
/**
* Repository used by {@link FileSystemWatcher} to save file/directory snapshots across
* restarts.
*
* @author Phillip Webb
* @since 2.4.0
*/
public interface SnapshotStateRepository {
/**
* A No-op {@link SnapshotStateRepository} that does not save state.
*/
SnapshotStateRepository NONE = new SnapshotStateRepository() {
@Override
public void save(Object state) {
}
@Override
public Object restore() {
return null;
}
};
/**
* A {@link SnapshotStateRepository} that uses a static instance to keep state across
* restarts.
*/
SnapshotStateRepository STATIC = StaticSnapshotStateRepository.INSTANCE;
/**
* Save the given state in the repository.
* @param state the state to save
*/
void save(Object state);
/**
* Restore any previously saved state.
* @return the previously saved state or {@code null}
*/
Object restore();
}
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.devtools.filewatch;
/**
* {@link SnapshotStateRepository} that uses a single static instance.
*
* @author Phillip Webb
*/
class StaticSnapshotStateRepository implements SnapshotStateRepository {
static final StaticSnapshotStateRepository INSTANCE = new StaticSnapshotStateRepository();
private volatile Object state;
@Override
public void save(Object state) {
this.state = state;
}
@Override
public Object restore() {
return this.state;
}
}
...@@ -273,8 +273,37 @@ class FileSystemWatcherTests { ...@@ -273,8 +273,37 @@ class FileSystemWatcherTests {
assertThat(actual).isEqualTo(expected); assertThat(actual).isEqualTo(expected);
} }
@Test
void withSnapshotRepository() throws Exception {
SnapshotStateRepository repository = new TestSnapshotStateRepository();
setupWatcher(20, 10, repository);
File directory = new File(this.tempDir, UUID.randomUUID().toString());
directory.mkdir();
File file = touch(new File(directory, "file.txt"));
this.watcher.addSourceDirectory(directory);
this.watcher.start();
file.delete();
this.watcher.stopAfter(1);
this.changes.clear();
File recreate = touch(new File(directory, "file.txt"));
setupWatcher(20, 10, repository);
this.watcher.addSourceDirectory(directory);
this.watcher.start();
this.watcher.stopAfter(1);
ChangedFiles changedFiles = getSingleChangedFiles();
Set<ChangedFile> actual = changedFiles.getFiles();
Set<ChangedFile> expected = new HashSet<>();
expected.add(new ChangedFile(directory, recreate, Type.ADD));
assertThat(actual).isEqualTo(expected);
}
private void setupWatcher(long pollingInterval, long quietPeriod) { private void setupWatcher(long pollingInterval, long quietPeriod) {
this.watcher = new FileSystemWatcher(false, Duration.ofMillis(pollingInterval), Duration.ofMillis(quietPeriod)); setupWatcher(pollingInterval, quietPeriod, null);
}
private void setupWatcher(long pollingInterval, long quietPeriod, SnapshotStateRepository snapshotStateRepository) {
this.watcher = new FileSystemWatcher(false, Duration.ofMillis(pollingInterval), Duration.ofMillis(quietPeriod),
snapshotStateRepository);
this.watcher.addListener((changeSet) -> FileSystemWatcherTests.this.changes.add(changeSet)); this.watcher.addListener((changeSet) -> FileSystemWatcherTests.this.changes.add(changeSet));
} }
...@@ -304,4 +333,20 @@ class FileSystemWatcherTests { ...@@ -304,4 +333,20 @@ class FileSystemWatcherTests {
return file; return file;
} }
private static class TestSnapshotStateRepository implements SnapshotStateRepository {
private Object state;
@Override
public void save(Object state) {
this.state = state;
}
@Override
public Object restore() {
return this.state;
}
}
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment