Make MemoryRandomAccessFile handle the file being moved from under it (#157)

* Make rename tests verify the path of the returned FileSystemEntity

* Add explicit types to fix analysis lints

* Make MemoryRandomAccessFile handle the file being moved from under it

Fix `MemoryRandomAccessFile` to use only its `FileNode` and never the
corresponding `MemoryFile`.  While the `MemoryRandomAccessFile` (the
equivalent of a file handle) is open, its `FileNode` should never
change, but the original `MemoryFile` might become backed by a
different node if the file is moved, removed, or replaced.

POSIX systems typically allow this; Windows typically doesn't.  For
now, make `MemoryFileSystem` follow the typical POSIX behavior. (In
the long-term, `MemoryFileSystem` perhaps should have configurable
behavior.)

* Make MemoryFile.openWrite better handle a file being moved from under it

Make `MemoryFile.openWrite`'s `IOSink` resolve the `FileNode`
immediately instead of adding it to an async queue.  Otherwise node
resolution could return a different `FileNode` than what was
originally intended.

Eagerly resolving the `FileNode` has the side-effect of potentially
throwing exceptions earlier than expected.  I therefore added a
mechanism to defer throwing.
diff --git a/packages/file/CHANGELOG.md b/packages/file/CHANGELOG.md
index a30c55b..22c292c 100644
--- a/packages/file/CHANGELOG.md
+++ b/packages/file/CHANGELOG.md
@@ -1,4 +1,7 @@
 #### 5.2.2-dev
+
+* Made `MemoryRandomAccessFile` and `MemoryFile.openWrite` handle the file
+  being removed or renamed while open.
 * Fixed incorrect formatting in `NoMatchingInvocationError.toString()`.
 * Fixed more test flakiness.
 * Enabled more tests.
diff --git a/packages/file/lib/src/backends/memory/memory_file.dart b/packages/file/lib/src/backends/memory/memory_file.dart
index 8df0a0c..bda9ae9 100644
--- a/packages/file/lib/src/backends/memory/memory_file.dart
+++ b/packages/file/lib/src/backends/memory/memory_file.dart
@@ -181,7 +181,7 @@
       createSync();
     }
 
-    return MemoryRandomAccessFile(this, resolvedBacking as FileNode, mode);
+    return MemoryRandomAccessFile(path, resolvedBacking as FileNode, mode);
   }
 
   @override
@@ -314,12 +314,28 @@
     io.FileMode mode,
     Encoding encoding,
   ) {
-    Future<FileNode> node = Future<FileNode>.microtask(() {
-      FileNode node = file._resolvedBackingOrCreate;
+    FileNode node;
+    Exception deferredException;
+
+    // Resolve the backing immediately to ensure that the [FileNode] we write
+    // to is the same as when [openWrite] was called.  This can matter if the
+    // file is moved or removed while open.
+    try {
+      node = file._resolvedBackingOrCreate;
+    } on Exception catch (e) {
+      // For behavioral consistency with [LocalFile], do not report failures
+      // immediately.
+      deferredException = e;
+    }
+
+    Future<FileNode> future = Future<FileNode>.microtask(() {
+      if (deferredException != null) {
+        throw deferredException;
+      }
       file._truncateIfNecessary(node, mode);
       return node;
     });
-    return _FileSink._(node, encoding);
+    return _FileSink._(future, encoding);
   }
 
   _FileSink._(this._node, this.encoding) {
diff --git a/packages/file/lib/src/backends/memory/memory_random_access_file.dart b/packages/file/lib/src/backends/memory/memory_random_access_file.dart
index 568195b..b30389f 100644
--- a/packages/file/lib/src/backends/memory/memory_random_access_file.dart
+++ b/packages/file/lib/src/backends/memory/memory_random_access_file.dart
@@ -18,7 +18,7 @@
   /// Constructs a [MemoryRandomAccessFile].
   ///
   /// This should be used only by [MemoryFile.open] or [MemoryFile.openSync].
-  MemoryRandomAccessFile(this._memoryFile, this._node, this._mode) {
+  MemoryRandomAccessFile(this.path, this._node, this._mode) {
     switch (_mode) {
       case io.FileMode.read:
         break;
@@ -36,7 +36,9 @@
     }
   }
 
-  final MemoryFile _memoryFile;
+  @override
+  final String path;
+
   final FileNode _node;
   final io.FileMode _mode;
 
@@ -137,9 +139,6 @@
   }
 
   @override
-  String get path => _memoryFile.path;
-
-  @override
   Future<void> close() async => _asyncWrapper(closeSync);
 
   @override
@@ -167,7 +166,7 @@
   int lengthSync() {
     _checkOpen();
     _checkAsync();
-    return _memoryFile.lengthSync();
+    return _node.size;
   }
 
   @override
diff --git a/packages/file/lib/src/backends/record_replay/common.dart b/packages/file/lib/src/backends/record_replay/common.dart
index ce5b2ef..be610bf 100644
--- a/packages/file/lib/src/backends/record_replay/common.dart
+++ b/packages/file/lib/src/backends/record_replay/common.dart
@@ -171,7 +171,8 @@
       buffer.write(Error.safeToString(encode(arg)));
       printedCount += 1;
     }
-    for (final nameValue in invocation.namedArguments.entries) {
+    for (final MapEntry<Symbol, dynamic> nameValue
+        in invocation.namedArguments.entries) {
       final Symbol name = nameValue.key;
       final dynamic value = nameValue.value;
       if (printedCount > 0) {
diff --git a/packages/file/test/chroot_test.dart b/packages/file/test/chroot_test.dart
index a320511..997411c 100644
--- a/packages/file/test/chroot_test.dart
+++ b/packages/file/test/chroot_test.dart
@@ -22,8 +22,15 @@
       return ChrootFileSystem(fs, '/tmp');
     }
 
+    // TODO(jamesderlin): Make ChrootFile.openSync return a delegating
+    // RandomAccessFile that uses the chroot'd path.
+    List<String> skipCommon = <String>[
+      'File > open > .* > RandomAccessFile > read > openReadHandleDoesNotChange',
+      'File > open > .* > RandomAccessFile > openWriteHandleDoesNotChange',
+    ];
+
     group('memoryBacked', () {
-      runCommonTests(createMemoryBackedChrootFileSystem);
+      runCommonTests(createMemoryBackedChrootFileSystem, skip: skipCommon);
     });
 
     group('localBacked', () {
@@ -48,6 +55,8 @@
 
           // https://github.com/dart-lang/sdk/issues/28277
           'Link > rename > throwsIfDestinationExistsAsFile',
+
+          ...skipCommon,
         ],
       );
     }, skip: io.Platform.isWindows);
diff --git a/packages/file/test/common_tests.dart b/packages/file/test/common_tests.dart
index 3b875d4..702a441 100644
--- a/packages/file/test/common_tests.dart
+++ b/packages/file/test/common_tests.dart
@@ -1273,10 +1273,11 @@
         });
 
         test('succeedsIfDestinationDoesntExistAtTail', () {
-          File f = fs.file(ns('/foo'))..createSync();
-          f.renameSync(ns('/bar'));
+          File src = fs.file(ns('/foo'))..createSync();
+          File dest = src.renameSync(ns('/bar'));
           expect(fs.file(ns('/foo')), isNot(exists));
           expect(fs.file(ns('/bar')), exists);
+          expect(dest.path, ns('/bar'));
         });
 
         test('throwsIfDestinationDoesntExistViaTraversal', () {
@@ -1855,6 +1856,21 @@
                   expect(numRead, 3);
                   expect(utf8.decode(buffer.sublist(2, 5)), 'pre');
                 });
+
+                test('openReadHandleDoesNotChange', () {
+                  final String initial = utf8.decode(raf.readSync(4));
+                  expect(initial, 'pre-');
+                  final File newFile = f.renameSync(ns('/bar'));
+                  String rest = utf8.decode(raf.readSync(1024));
+                  expect(rest, 'existing content\n');
+
+                  assert(newFile.path != f.path);
+                  expect(f, isNot(exists));
+                  expect(newFile, exists);
+
+                  // [RandomAccessFile.path] always returns the original path.
+                  expect(raf.path, f.path);
+                });
               });
             }
 
@@ -1942,6 +1958,26 @@
                       'pre-existing content\nHello world');
                 }
               });
+
+              test('openWriteHandleDoesNotChange', () {
+                raf.writeStringSync('Hello ');
+                final File newFile = f.renameSync(ns('/bar'));
+                raf.writeStringSync('world');
+
+                final String contents = newFile.readAsStringSync();
+                if (mode == FileMode.write || mode == FileMode.writeOnly) {
+                  expect(contents, 'Hello world');
+                } else {
+                  expect(contents, 'pre-existing content\nHello world');
+                }
+
+                assert(newFile.path != f.path);
+                expect(f, isNot(exists));
+                expect(newFile, exists);
+
+                // [RandomAccessFile.path] always returns the original path.
+                expect(raf.path, f.path);
+              });
             }
 
             if (mode == FileMode.append || mode == FileMode.writeOnlyAppend) {
@@ -2147,6 +2183,48 @@
           expect(stream.isBroadcast, isFalse);
           await stream.drain<void>();
         });
+
+        test('openReadHandleDoesNotChange', () async {
+          // Ideally, `data` should be large enough so that its contents are
+          // split across multiple chunks in the [Stream].  However, there
+          // doesn't seem to be a good way to determine the chunk size used by
+          // [io.File].
+          final List<int> data = List<int>.generate(
+            1024 * 256,
+            (int index) => index & 0xFF,
+            growable: false,
+          );
+
+          final File f = fs.file(ns('/foo'))..createSync();
+
+          f.writeAsBytesSync(data, flush: true);
+          final Stream<List<int>> stream = f.openRead();
+
+          File newFile;
+          List<int> initialChunk;
+          final List<int> remainingChunks = <int>[];
+
+          await for (List<int> chunk in stream) {
+            if (initialChunk == null) {
+              initialChunk = chunk;
+              assert(initialChunk.isNotEmpty);
+              expect(initialChunk, data.getRange(0, initialChunk.length));
+
+              newFile = f.renameSync(ns('/bar'));
+            } else {
+              remainingChunks.addAll(chunk);
+            }
+          }
+
+          expect(
+            remainingChunks,
+            data.getRange(initialChunk.length, data.length),
+          );
+
+          assert(newFile.path != f.path);
+          expect(f, isNot(exists));
+          expect(newFile, exists);
+        });
       });
 
       group('openWrite', () {
@@ -2212,6 +2290,24 @@
           expect(fs.file(ns('/foo')).readAsStringSync(), 'HelloGoodbye');
         });
 
+        test('openWriteHandleDoesNotChange', () async {
+          File f = fs.file(ns('/foo'))..createSync();
+          IOSink sink = f.openWrite();
+          sink.write('Hello');
+          await sink.flush();
+
+          final File newFile = f.renameSync(ns('/bar'));
+          sink.write('Goodbye');
+          await sink.flush();
+          await sink.close();
+
+          expect(newFile.readAsStringSync(), 'HelloGoodbye');
+
+          assert(newFile.path != f.path);
+          expect(f, isNot(exists));
+          expect(newFile, exists);
+        });
+
         group('ioSink', () {
           File f;
           IOSink sink;
@@ -3254,7 +3350,8 @@
         test('succeedsIfSourceIsLinkToFile', () {
           Link l = fs.link(ns('/foo'))..createSync(ns('/bar'));
           fs.file(ns('/bar')).createSync();
-          l.renameSync(ns('/baz'));
+          Link renamed = l.renameSync(ns('/baz'));
+          expect(renamed.path, ns('/baz'));
           expect(fs.typeSync(ns('/foo'), followLinks: false),
               FileSystemEntityType.notFound);
           expect(fs.typeSync(ns('/bar'), followLinks: false),
@@ -3266,7 +3363,8 @@
 
         test('succeedsIfSourceIsLinkToNotFound', () {
           Link l = fs.link(ns('/foo'))..createSync(ns('/bar'));
-          l.renameSync(ns('/baz'));
+          Link renamed = l.renameSync(ns('/baz'));
+          expect(renamed.path, ns('/baz'));
           expect(fs.typeSync(ns('/foo'), followLinks: false),
               FileSystemEntityType.notFound);
           expect(fs.typeSync(ns('/baz'), followLinks: false),
@@ -3277,7 +3375,8 @@
         test('succeedsIfSourceIsLinkToDirectory', () {
           Link l = fs.link(ns('/foo'))..createSync(ns('/bar'));
           fs.directory(ns('/bar')).createSync();
-          l.renameSync(ns('/baz'));
+          Link renamed = l.renameSync(ns('/baz'));
+          expect(renamed.path, ns('/baz'));
           expect(fs.typeSync(ns('/foo'), followLinks: false),
               FileSystemEntityType.notFound);
           expect(fs.typeSync(ns('/bar'), followLinks: false),
@@ -3290,7 +3389,8 @@
         test('succeedsIfSourceIsLinkLoop', () {
           Link l = fs.link(ns('/foo'))..createSync(ns('/bar'));
           fs.link(ns('/bar')).createSync(ns('/foo'));
-          l.renameSync(ns('/baz'));
+          Link renamed = l.renameSync(ns('/baz'));
+          expect(renamed.path, ns('/baz'));
           expect(fs.typeSync(ns('/foo'), followLinks: false),
               FileSystemEntityType.notFound);
           expect(fs.typeSync(ns('/bar'), followLinks: false),
@@ -3302,7 +3402,8 @@
 
         test('succeedsIfDestinationDoesntExistAtTail', () {
           Link l = fs.link(ns('/foo'))..createSync(ns('/bar'));
-          l.renameSync(ns('/baz'));
+          Link renamed = l.renameSync(ns('/baz'));
+          expect(renamed.path, ns('/baz'));
           expect(fs.link(ns('/foo')), isNot(exists));
           expect(fs.link(ns('/baz')), exists);
         });
diff --git a/packages/file/test/local_test.dart b/packages/file/test/local_test.dart
index bd1da4a..ef2ffdd 100644
--- a/packages/file/test/local_test.dart
+++ b/packages/file/test/local_test.dart
@@ -91,6 +91,10 @@
         'File > open > APPEND > RandomAccessFile > truncate > throwsIfSetToNegativeNumber',
         'File > open > WRITE_ONLY > RandomAccessFile > truncate > throwsIfSetToNegativeNumber',
         'File > open > WRITE_ONLY_APPEND > RandomAccessFile > truncate > throwsIfSetToNegativeNumber',
+
+        // Windows does not allow removing or renaming open files.
+        '.* > openReadHandleDoesNotChange',
+        '.* > openWriteHandleDoesNotChange',
       ],
     };
 
diff --git a/packages/file/test/replay_test.dart b/packages/file/test/replay_test.dart
index 36edbd6..192d161 100644
--- a/packages/file/test/replay_test.dart
+++ b/packages/file/test/replay_test.dart
@@ -212,22 +212,25 @@
   group('describeInvocation', () {
     test('methodWithNoArguments', () {
       expect(
-        describeInvocation(Invocation.method(#foo, [])),
+        describeInvocation(Invocation.method(#foo, <Object>[])),
         'foo()',
       );
     });
 
     test('methodWithOnlyPositionalArguments', () {
       expect(
-        describeInvocation(Invocation.method(#foo, [1, 'bar', null])),
+        describeInvocation(Invocation.method(#foo, <Object>[1, 'bar', null])),
         'foo(1, "bar", null)',
       );
     });
 
     test('methodWithOnlyNamedArguments', () {
       expect(
-        describeInvocation(
-            Invocation.method(#foo, [], {#x: 2, #y: 'baz', #z: null})),
+        describeInvocation(Invocation.method(
+          #foo,
+          <Object>[],
+          <Symbol, Object>{#x: 2, #y: 'baz', #z: null},
+        )),
         'foo(x: 2, y: "baz", z: null)',
       );
     });
@@ -236,8 +239,8 @@
       expect(
         describeInvocation(Invocation.method(
           #foo,
-          [1, 'bar', null],
-          {#x: 2, #y: 'baz', #z: null},
+          <Object>[1, 'bar', null],
+          <Symbol, Object>{#x: 2, #y: 'baz', #z: null},
         )),
         'foo(1, "bar", null, x: 2, y: "baz", z: null)',
       );