[vfs] IJPL-247444: prohibit VirtualFile.rename() for already deleted VirtualFiles

+ `VirtualFile.rename()` should throw an exception if the file is already deleted because of general rule "accessing !valid files is illegal"
+ `VirtualFile.delete()` is unnatural to behave this way, so it just log warning


(cherry picked from commit bcf565ddf9d2014d1e95fe6451c6a9629d7b0ad3)

IJ-CR-211437

GitOrigin-RevId: 529bec2d3da89e0c707bd8e813694c4e95e1979f
This commit is contained in:
Ruslan Cheremin
2026-07-06 19:03:21 +00:00
committed by intellij-monorepo-bot
parent 1efd6eef3f
commit b258289149
6 changed files with 48 additions and 8 deletions
@@ -23,7 +23,7 @@ public class InvalidVirtualFileAccessException extends RuntimeException {
private static @NonNls String composeMessage(@NotNull VirtualFile file) {
String url = file.getUrl();
@NonNls String message = "Accessing invalid virtual file: " + url;
@NonNls String message = "Accessing invalid (=!isValid) virtual file: " + url;
String reason = getInvalidationReason(file);
if (reason != null) {
message += "; reason: " + reason;
@@ -260,10 +260,10 @@ public abstract class VirtualFile extends UserDataHolderBase implements Modifica
/// Checks whether this `VirtualFile` is valid. File can be invalidated either by deleting it or one of its
/// parents with [#delete] method or by an external change.
/// If file is not valid only [#equals], [#hashCode],
/// If the file is not valid only [#equals], [#hashCode],
/// [#getName()], [#getPath()], [#getUrl()], [#getPresentableUrl()] and methods from
/// [UserDataHolder] can be called for it. Using any other methods for an invalid [VirtualFile] instance
/// produce unpredictable results.
/// produces unpredictable results, usually an exception.
///
/// @return `true` if this is a valid file, `false` otherwise
public abstract boolean isValid();
@@ -419,11 +419,14 @@ public abstract class VirtualFile extends UserDataHolderBase implements Modifica
/// @param requestor any object to control who called this method. Note that
/// it is considered to be an external change if `requestor` is `null`.
/// See [VirtualFileEvent#getRequestor]
/// @throws IOException if file failed to be deleted
/// @throws IOException if the file failed to be deleted
@RequiresWriteLock
public void delete(Object requestor) throws IOException {
ApplicationManager.getApplication().assertWriteAccessAllowed();
LOG.assertTrue(isValid(), "Deleting invalid file");
if (!isValid()) {
LOG.warn("Deleting invalid (already deleted?) file: " + this + " -> nothing to delete, skip");
return;
}
getFileSystem().deleteFile(requestor, this);
}
@@ -497,6 +497,28 @@ public abstract class VirtualFileSystemEntry extends NewVirtualFile {
@Override
public void delete(Object requestor) throws IOException {
ApplicationManager.getApplication().assertWriteAccessAllowed();
if (!isValid()) {
//We have a general rule "Accessing !valid VirtualFile is incorrect, except for a limited set of methods needed
// for identifying a VirtualFile, like id/path/toString" -- see VirtualFile.isValid jdocs.
// According to this rule, an attempt to _delete_ VirtualFile which is !valid -- should be an error.
// But it is a bit of unnatural behavior for .delete(): .delete()-like methods are _usually_ idempotent, i.e.,
// safe to be called repeatedly -- and people expect them to behave that way.
// And historically .delete() didn't throw an exception in this case -- and as a result, we have a number of
// usages in our codebase (and, probably, in plugins too) that don't expect .delete() to throw an exception
// if called on an already deleted file.
// So, for the sake of backward compatibility, we keep supporting that legacy behavior here: log warning (which
// should contain a stacktrace of an original deleter), and return unharmed.
// Luckily, the rule in VirtualFile.isValid() does specify that accessing an invalid file is "incorrect", but does
// NOT specify that kind of bad things will follow -- so just "warn" is pretty legit.
Logger.getInstance(VirtualFileSystemEntry.class).warn(
"Deleting invalid (already deleted?) file -> nothing to delete, skip",
new InvalidVirtualFileAccessException(this)
);
return;
}
owningPersistentFS().deleteFile(requestor, this);
}
@@ -504,6 +526,8 @@ public abstract class VirtualFileSystemEntry extends NewVirtualFile {
@Override
public void rename(Object requestor, @NotNull @NonNls String newName) throws IOException {
ApplicationManager.getApplication().assertWriteAccessAllowed();
failIfFileIsInvalid();
if (getName().equals(newName)) return;
validateName(newName);
owningPersistentFS().renameFile(requestor, this, newName);
@@ -512,10 +536,17 @@ public abstract class VirtualFileSystemEntry extends NewVirtualFile {
@RequiresWriteLock
@Override
public @NotNull VirtualFile createChildData(Object requestor, @NotNull String name) throws IOException {
failIfFileIsInvalid();
validateName(name);
return owningPersistentFS().createChildFile(requestor, this, name);
}
private void failIfFileIsInvalid() {
if (!isValid()) {
throw new InvalidVirtualFileAccessException(this);
}
}
@Override
public boolean isWritable() {
return getFlagInt(VfsDataFlags.IS_WRITABLE_FLAG);
@@ -376,7 +376,9 @@ fun TestFixture<PsiDirectory>.virtualFileFixture(
}
initialized(file) {
edtWriteAction {
file.delete(dirFixture)
if (file.isValid) {
file.delete(dirFixture)
}
}
}
}
@@ -130,7 +130,9 @@ public final class VfsTestUtil {
public static void deleteFile(@NotNull VirtualFile file) {
try {
// requestor must be notnull (for GlobalUndoTest)
WriteAction.runAndWait(() -> file.delete(file));
WriteAction.runAndWait(() -> {
if(file.isValid()) file.delete(file);
});
}
catch (Throwable throwable) {
ExceptionUtil.rethrow(throwable);
@@ -140,7 +140,9 @@ public class TempDirTestFixtureImpl extends BaseFixture implements TempDirTestFi
VirtualFile virtualFile = LocalFileSystem.getInstance().findFileByPath(FileUtil.toSystemIndependentName(myTempDir.toString()));
if (virtualFile != null) {
WriteAction.runAndWait(() -> {
virtualFile.delete(this);
if(virtualFile.isValid()) {
virtualFile.delete(this);
}
});
}
}