@@ -843,6 +843,11 @@ async function build() {
843
843
}
844
844
845
845
watcher = chokidar . watch ( [ ...contextDependencies , ...extractFileGlobs ( config ) ] , {
846
+ // Force checking for atomic writes in all situations
847
+ // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
848
+ // This only works when watching directories though
849
+ atomic : true ,
850
+
846
851
usePolling : shouldPoll ,
847
852
interval : shouldPoll ? pollInterval : undefined ,
848
853
ignoreInitial : true ,
@@ -855,6 +860,7 @@ async function build() {
855
860
} )
856
861
857
862
let chain = Promise . resolve ( )
863
+ let pendingRebuilds = new Set ( )
858
864
859
865
watcher . on ( 'change' , async ( file ) => {
860
866
if ( contextDependencies . has ( file ) ) {
@@ -885,6 +891,77 @@ async function build() {
885
891
}
886
892
} )
887
893
894
+ /**
895
+ * When rapidly saving files atomically a couple of situations can happen:
896
+ * - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
897
+ * - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
898
+ *
899
+ * To work around this we retry reading the file a handful of times with a delay between each attempt
900
+ *
901
+ * @param {string } path
902
+ * @param {number } tries
903
+ * @returns {string }
904
+ * @throws {Error } If the file is still missing or busy after the specified number of tries
905
+ */
906
+ async function readFileWithRetries ( path , tries = 5 ) {
907
+ for ( let n = 0 ; n < tries ; n ++ ) {
908
+ try {
909
+ return await fs . promises . readFile ( path , 'utf8' )
910
+ } catch ( err ) {
911
+ if ( n < tries ) {
912
+ if ( err . code === 'ENOENT' || err . code === 'EBUSY' ) {
913
+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) )
914
+
915
+ continue
916
+ }
917
+ }
918
+
919
+ throw err
920
+ }
921
+ }
922
+ }
923
+
924
+ // Restore watching any files that are "removed"
925
+ // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
926
+ // TODO: An an optimization we should allow removal when the config changes
927
+ watcher . on ( 'unlink' , ( file ) => watcher . add ( file ) )
928
+
929
+ // Some applications such as Visual Studio (but not VS Code)
930
+ // will only fire a rename event for atomic writes and not a change event
931
+ // This is very likely a chokidar bug but it's one we need to work around
932
+ // We treat this as a change event and rebuild the CSS
933
+ watcher . on ( 'raw' , ( evt , filePath , meta ) => {
934
+ if ( evt !== 'rename' ) {
935
+ return
936
+ }
937
+
938
+ filePath = path . resolve ( meta . watchedPath , filePath )
939
+
940
+ // Skip since we've already queued a rebuild for this file that hasn't happened yet
941
+ if ( pendingRebuilds . has ( filePath ) ) {
942
+ return
943
+ }
944
+
945
+ pendingRebuilds . add ( filePath )
946
+
947
+ chain = chain . then ( async ( ) => {
948
+ let content
949
+
950
+ try {
951
+ content = await readFileWithRetries ( path . resolve ( filePath ) )
952
+ } finally {
953
+ pendingRebuilds . delete ( filePath )
954
+ }
955
+
956
+ changedContent . push ( {
957
+ content,
958
+ extension : path . extname ( filePath ) . slice ( 1 ) ,
959
+ } )
960
+
961
+ await rebuild ( config )
962
+ } )
963
+ } )
964
+
888
965
watcher . on ( 'add' , async ( file ) => {
889
966
chain = chain . then ( async ( ) => {
890
967
changedContent . push ( {
0 commit comments