Allow SnapshotService operations to be cancellable
Restart BuildSnapshot on debounced file change
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABinaryReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6759c9e86085d2e4796e66824d1e6bcde85215244243079466ada44a6cf0_003FBinaryReader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABinaryReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F6759c9e86085d2e4796e66824d1e6bcde85215244243079466ada44a6cf0_003FBinaryReader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationToken_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2565b9d99fdde488bc7801b84387b2cc864959cfb63212e1ff576fc9c6bb7e_003FCancellationToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationToken_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2565b9d99fdde488bc7801b84387b2cc864959cfb63212e1ff576fc9c6bb7e_003FCancellationToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@@ -30,11 +31,13 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9c93a119672fff4fc4a8405c22a67b8153942a238a226de1266ccc65c652d936_003FFileSystemInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9c93a119672fff4fc4a8405c22a67b8153942a238a226de1266ccc65c652d936_003FFileSystemInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASeekableZipHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc5f3e4d3cedb261911afb1e191b36ed580a783fd7ee73cba3f33d8c6feeb_003FSeekableZipHeaderFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASeekableZipHeaderFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fc5f3e4d3cedb261911afb1e191b36ed580a783fd7ee73cba3f33d8c6feeb_003FSeekableZipHeaderFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASemaphoreSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003F9769f0874b89d9a174c39e6c977aef6d979daefa49c7f3c239ef268493ea12_003FSemaphoreSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASha256PartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F0a_003Fcb570a3a_003FSha256PartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASha256PartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F0a_003Fcb570a3a_003FSha256PartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ public sealed class SnapshotOptions : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region FileSystemWatcher options
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many times to retry when the snapshot file is locked.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRetryCount { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base debounce timeout (ms) used by the file‑watcher.
|
||||||
|
/// The retry interval will be this value multiplied by <see cref="RetryMultiplier"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int DebounceTimeoutMs { get; set; } = 500; // existing value – keep it
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multiplier used to compute the retry wait time: `wait = DebounceTimeoutMs * RetryMultiplier`.
|
||||||
|
/// </summary>
|
||||||
|
public double RetryMultiplier { get; set; } = 1.5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional custom path to the snapshot file; the service will try to write there.
|
||||||
|
/// </summary>
|
||||||
|
public string SnapshotFilePath { get; set; } = "snapshots/latest.snapshot";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private void OnPropertyChanged(string propertyName) =>
|
private void OnPropertyChanged(string propertyName) =>
|
||||||
|
|||||||
@@ -126,16 +126,20 @@ public sealed class ArchiveHandler : IArchiveHandler
|
|||||||
var archiveEntryName = archiveEntry.Name;
|
var archiveEntryName = archiveEntry.Name;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var rewindableWrapper = new RewindableStream(archiveEntry.Stream, () =>
|
Stream? wrapper = null;
|
||||||
|
|
||||||
|
wrapper = new RewindableStream(archiveEntry.Stream, () =>
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName);
|
_logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName);
|
||||||
return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream;
|
return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream;
|
||||||
},10*1024*1024, archiveEntry.Stream.Length);
|
},10*1024*1024, archiveEntry.Stream.Length);
|
||||||
var title = _nspExtractor.ExtractFromStream(rewindableWrapper);
|
//wrapper = new SeekableBufferedStream(archiveEntry.Stream, archiveEntry.Stream.Length, 10*1024*1024, true);
|
||||||
|
var title = _nspExtractor.ExtractFromStream(wrapper);
|
||||||
if (title != null)
|
if (title != null)
|
||||||
{
|
{
|
||||||
titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title));
|
titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title));
|
||||||
}
|
}
|
||||||
|
wrapper?.Dispose();
|
||||||
}
|
}
|
||||||
catch (IncompleteArchiveException incompleteArchiveException)
|
catch (IncompleteArchiveException incompleteArchiveException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,16 +21,21 @@ public interface ISnapshotService
|
|||||||
{
|
{
|
||||||
event EventHandler SnapshotRebuilt; // event raised after a rebuild
|
event EventHandler SnapshotRebuilt; // event raised after a rebuild
|
||||||
void RebuildSnapshot();
|
void RebuildSnapshot();
|
||||||
|
Task RebuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
SnapshotService.ROMSnapshot GetSnapshot();
|
SnapshotService.ROMSnapshot GetSnapshot();
|
||||||
|
|
||||||
Task AddToSnapshotAsync(FileEntry entry);
|
Task AddToSnapshotAsync(FileEntry entry);
|
||||||
Task BuildSnapshotAsync();
|
Task BuildSnapshotAsync(CancellationToken cancellationToken = default);
|
||||||
void GetArchiveName(string titleId);
|
void GetArchiveName(string titleId);
|
||||||
char GetArchivePathSeparator();
|
char GetArchivePathSeparator();
|
||||||
|
void Start();
|
||||||
|
void Stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
/// Watches a folder for changes and rebuilds a snapshot when the first change after a debounce window occurs.
|
||||||
|
/// While a rebuild is in progress, subsequent file changes are ignored (they will be processed once the current
|
||||||
|
/// rebuild finishes and a new debounce window starts).
|
||||||
/// only re‑processes a file if its hash changed.
|
/// only re‑processes a file if its hash changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
|
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
|
||||||
@@ -40,7 +45,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
/* ==============================================================
|
/* ==============================================================
|
||||||
* 1️⃣ FileSystemWatcher
|
* 1️⃣ FileSystemWatcher
|
||||||
* ============================================================== */
|
* ============================================================== */
|
||||||
private readonly List<FileSystemWatcher> _watchers = new();
|
private readonly List<FileSystemWatcher> _watchers = [];
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -69,8 +74,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
public event EventHandler? SnapshotRebuilding;
|
public event EventHandler? SnapshotRebuilding;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1);
|
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1);
|
||||||
|
|
||||||
private const char ArchivePathSeparator = '|';
|
private const char ArchivePathSeparator = '|';
|
||||||
|
|
||||||
|
// Cache key used to keep the debounce flag
|
||||||
|
private const string DebounceKey = "SnapshotService.IsDebouncing";
|
||||||
|
private const string BuildKey = "SnapshotService.IsBuilding";
|
||||||
|
private CancellationTokenSource _cancellation = new();
|
||||||
|
private Task? _currentBuildTask;
|
||||||
|
|
||||||
public char GetArchivePathSeparator() => ArchivePathSeparator;
|
public char GetArchivePathSeparator() => ArchivePathSeparator;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -128,7 +140,26 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
AddWatchDirectory(path);
|
AddWatchDirectory(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
public void Start() => _watchers.ForEach(watcher => watcher.EnableRaisingEvents = true);
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
foreach (var fileSystemWatcher in _watchers)
|
||||||
|
{
|
||||||
|
fileSystemWatcher.EnableRaisingEvents = false;
|
||||||
|
}
|
||||||
|
_cancellation.Cancel();
|
||||||
|
try { _currentBuildTask?.Wait(); }
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
// --------- Private helpers ---------
|
// --------- Private helpers ---------
|
||||||
private void OnOptionsChanged(string? propertyName)
|
private void OnOptionsChanged(string? propertyName)
|
||||||
{
|
{
|
||||||
@@ -151,12 +182,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
AddWatchDirectory(newWatchedDirectory);
|
AddWatchDirectory(newWatchedDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
BuildSnapshotAsync(); // rebuild everything
|
_ = BuildSnapshotAsync(_cancellation.Token); // rebuild everything
|
||||||
PersistSnapshotAsync();
|
PersistSnapshotAsync(_cancellation.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#region FileSystemWatcher
|
#region FileSystemWatcher helpers
|
||||||
|
|
||||||
/* ==============================================================
|
/* ==============================================================
|
||||||
* 5️⃣ FileSystemWatcher helpers
|
* 5️⃣ FileSystemWatcher helpers
|
||||||
@@ -193,18 +224,38 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
||||||
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuild the snapshot, if rebuild in process, cancel it and restart
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileSystemEventArgs"></param>
|
||||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||||
{
|
{
|
||||||
|
// If a rebuild is already underway, ignore the event
|
||||||
|
if (_currentBuildTask is { IsCompleted: false })
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"File system event {ChangeType} on {Path} triggered, but build is in progress, skipping snapshot update",
|
||||||
|
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a rebuild only if we’re not already debouncing
|
||||||
|
if (_debouncerCache.TryGetValue(DebounceKey, out bool isDebouncing) && isDebouncing)
|
||||||
|
return;
|
||||||
|
|
||||||
// If a rebuild is in progress, ignore the event immediately
|
// If a rebuild is in progress, ignore the event immediately
|
||||||
if (_buildLock.CurrentCount == 0) // lock held by a rebuild
|
if (_buildLock.CurrentCount == 0) // lock held by a rebuild
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"File system event {ChangeType} on {Path} ignored because a rebuild is already in progress",
|
"File system event {ChangeType} on {Path} triggered, restart Build Task on next completed entry",
|
||||||
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||||
return;
|
_cancellation.Cancel();
|
||||||
|
_buildLock.Wait();
|
||||||
|
_buildLock.Release();
|
||||||
|
_cancellation.Dispose();
|
||||||
|
_cancellation = new CancellationTokenSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
|
||||||
CancellationTokenSource cts = new();
|
CancellationTokenSource cts = new();
|
||||||
|
|
||||||
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
||||||
@@ -217,62 +268,27 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
new PostEvictionCallbackRegistration
|
new PostEvictionCallbackRegistration
|
||||||
{
|
{
|
||||||
EvictionCallback =
|
EvictionCallback =
|
||||||
(_, value, reason, _) =>
|
(_, _, reason, _) =>
|
||||||
{
|
{
|
||||||
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) return;
|
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) return;
|
||||||
|
|
||||||
if (value is FileSystemEventArgs args)
|
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
||||||
{
|
// Kick off the rebuild asynchronously
|
||||||
if (IsFileLocked(args.FullPath))
|
_currentBuildTask = RebuildSnapshotAsync(_cancellation.Token);
|
||||||
{
|
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
|
|
||||||
using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
|
|
||||||
.AddExpirationToken(new CancellationChangeToken(cts.Token))
|
|
||||||
.SetValue(args);
|
|
||||||
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RebuildSnapshot();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
|
||||||
|
|
||||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
||||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
|
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsFileLocked(string filePath)
|
|
||||||
{
|
|
||||||
FileStream? stream = null;
|
|
||||||
var file = new FileInfo(filePath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stream?.Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private const int DebounceMs = 400;
|
private const int DebounceMs = 400;
|
||||||
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
|
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
|
||||||
private int SnapshotFileLockTimeout { get; } = 1000;
|
private int SnapshotFileLockTimeout { get; } = 1000;
|
||||||
|
|
||||||
private void DebounceElapsed()
|
|
||||||
{
|
|
||||||
UpdateSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Snapshot logic
|
#region Snapshot logic
|
||||||
@@ -280,20 +296,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
public Task AddToSnapshotAsync(FileEntry entry)
|
public Task AddToSnapshotAsync(FileEntry entry)
|
||||||
{
|
{
|
||||||
// Update lookup tables
|
// Update lookup tables
|
||||||
if (entry.Hash != null)
|
if (entry.Hash == null)
|
||||||
{
|
|
||||||
var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path);
|
|
||||||
|
|
||||||
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
|
|
||||||
_hashCache[entry.Hash] = entry.Path;
|
|
||||||
_sizeLookup[entry.Hash] = entry.Size;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path);
|
||||||
|
|
||||||
|
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
|
||||||
|
_hashCache[entry.Hash] = entry.Path;
|
||||||
|
_sizeLookup[entry.Hash] = entry.Size;
|
||||||
|
|
||||||
if (entry.Path.Contains(ArchivePathSeparator))
|
if (entry.Path.Contains(ArchivePathSeparator))
|
||||||
{
|
{
|
||||||
var filename = entry.Path.Split(ArchivePathSeparator)[0];
|
var filename = entry.Path.Split(ArchivePathSeparator)[0];
|
||||||
@@ -322,13 +336,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
* 6️⃣ Snapshot build / persistence helpers
|
* 6️⃣ Snapshot build / persistence helpers
|
||||||
* ============================================================== */
|
* ============================================================== */
|
||||||
/// Builds _cache and _hashCache based on directory configuration
|
/// Builds _cache and _hashCache based on directory configuration
|
||||||
public Task BuildSnapshotAsync()
|
/// <param name="cancellationToken"></param>
|
||||||
|
public async Task BuildSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Acquire the rebuild lock – if we cannot, skip this build.
|
// Acquire the rebuild lock – if we cannot, skip this build.
|
||||||
if (!_buildLock.Wait(0))
|
if (!await _buildLock.WaitAsync(0, cancellationToken))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("BuildSnapshotAsync called while rebuild in progress, ignoring.");
|
_logger.LogInformation("BuildSnapshotAsync called while rebuild in progress, ignoring.");
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -337,7 +352,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var index = LoadSnapshotIndex();
|
var index = LoadSnapshotIndex();
|
||||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
||||||
var fileInfo = new FileInfo(_snapshotPath);
|
var fileInfo = new FileInfo(_snapshotPath);
|
||||||
bool snapshotVerified = fileInfo.Exists;
|
var snapshotVerified = fileInfo.Exists;
|
||||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
||||||
{
|
{
|
||||||
if (index.Count != 0)
|
if (index.Count != 0)
|
||||||
@@ -345,11 +360,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
foreach (var dir in _options.RootDirectories)
|
foreach (var dir in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
// Snapshot is older than the latest modified file in the directory
|
// Snapshot is older than the latest modified file in the directory
|
||||||
var lastOrDefault = BuildSnapshot(dir).LastOrDefault();
|
try
|
||||||
if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _))
|
|
||||||
{
|
{
|
||||||
snapshotVerified = false;
|
var lastOrDefault = BuildSnapshot(dir, cancellationToken).LastOrDefault();
|
||||||
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _))
|
||||||
|
{
|
||||||
|
snapshotVerified = false;
|
||||||
|
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException operationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Build Cancelled while building snapshot from directory {Directory}: {Message}", dir, operationCanceledException.Message);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,7 +384,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var entries = new List<FileEntry>();
|
var entries = new List<FileEntry>();
|
||||||
foreach (var dir in _options.RootDirectories)
|
foreach (var dir in _options.RootDirectories)
|
||||||
{
|
{
|
||||||
foreach (var entry in BuildSnapshot(dir))
|
foreach (var entry in BuildSnapshot(dir, cancellationToken))
|
||||||
{
|
{
|
||||||
if (entry != null) entries.Add(entry);
|
if (entry != null) entries.Add(entry);
|
||||||
}
|
}
|
||||||
@@ -372,13 +395,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistSnapshotAsync();
|
await PersistSnapshotAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_buildLock.Release();
|
_buildLock.Release();
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void GetArchiveName(string titleId)
|
public void GetArchiveName(string titleId)
|
||||||
@@ -388,7 +410,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
// Returns List of FileEntry that do not have a hash in the cache
|
// Returns List of FileEntry that do not have a hash in the cache
|
||||||
// Each entry that has not been added to the lookup table is added to the cache
|
// Each entry that has not been added to the lookup table is added to the cache
|
||||||
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
private IEnumerable<FileEntry?> BuildSnapshot(string dir, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var processedFiles = new HashSet<string>();
|
var processedFiles = new HashSet<string>();
|
||||||
|
|
||||||
@@ -399,7 +421,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
return fileInfo.LastWriteTimeUtc;
|
return fileInfo.LastWriteTimeUtc;
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
var hash = string.Empty;
|
string hash;
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
|
||||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||||
@@ -416,6 +438,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
|
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
|
||||||
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
|
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
|
||||||
AddToSnapshotAsync(fileEntryFromFileName);
|
AddToSnapshotAsync(fileEntryFromFileName);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return fileEntryFromFileName;
|
yield return fileEntryFromFileName;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -433,10 +456,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
var title = _nspExtractor.ExtractFromStream(nspStream);
|
var title = _nspExtractor.ExtractFromStream(nspStream);
|
||||||
if (title != null)
|
if (title != null)
|
||||||
{
|
{
|
||||||
var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
var romEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
||||||
AddToSnapshotAsync(archiveEntry);
|
AddToSnapshotAsync(romEntry);
|
||||||
titles.Add((title.TitleId, nspStreamLength, title));
|
titles.Add((title.TitleId, nspStreamLength, title));
|
||||||
yield return archiveEntry;
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return romEntry;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,11 +472,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
if (processedFiles.Contains(file)) continue;
|
if (processedFiles.Contains(file)) continue;
|
||||||
_logger.LogDebug("Extracting hash for {File}", file);
|
_logger.LogDebug("Extracting hash for {File}", file);
|
||||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
hash = ComputeFirstStreamHash(file);
|
hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result;
|
||||||
stopwatch.Stop();
|
stopwatch.Stop();
|
||||||
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
|
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
|
||||||
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return null;
|
yield return null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -460,10 +485,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Stopwatch stopwatch2 = Stopwatch.StartNew();
|
titlesEnumerable = TryExtractTitleInfosWithRetryAsync(file, cancellationToken).Result;
|
||||||
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
|
|
||||||
stopwatch2.Stop();
|
|
||||||
_logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds);
|
|
||||||
// if it was multipart, add multiparts to processedFiles
|
// if it was multipart, add multiparts to processedFiles
|
||||||
var directoryName = Path.GetDirectoryName(file);
|
var directoryName = Path.GetDirectoryName(file);
|
||||||
if (directoryName != null)
|
if (directoryName != null)
|
||||||
@@ -488,17 +510,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
{
|
{
|
||||||
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
||||||
AddToSnapshotAsync(archiveEntry);
|
AddToSnapshotAsync(archiveEntry);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return archiveEntry;
|
yield return archiveEntry;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
|
|
||||||
AddToSnapshotAsync(fileEntry);
|
|
||||||
yield return fileEntry;*/
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (titles.Count == 0)
|
if (titles.Count == 0)
|
||||||
@@ -508,11 +525,36 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
|
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
yield return new FileEntry(file, titles.Select((tuple, _) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, _) => tuple.Item3).ToList());
|
yield return new FileEntry(file, titles.Select((tuple, _) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, _) => tuple.Item3).ToList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IEnumerable<(string, long, NcaMetadataWithHash)>?> TryExtractTitleInfosWithRetryAsync(string file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stopwatch2 = Stopwatch.StartNew();
|
||||||
|
var titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
|
||||||
|
stopwatch2.Stop();
|
||||||
|
_logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds);
|
||||||
|
return titlesEnumerable;
|
||||||
|
}
|
||||||
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
|
{
|
||||||
|
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
|
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
||||||
|
attempt + 1, file, delay);
|
||||||
|
await Task.Delay(delay, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
|
private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
@@ -520,15 +562,13 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream);
|
private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream);
|
||||||
|
|
||||||
private void UpdateSnapshot() => BuildSnapshotAsync();
|
|
||||||
|
|
||||||
private IEnumerable<FileEntry> GetEntries()
|
private IEnumerable<FileEntry> GetEntries()
|
||||||
{
|
{
|
||||||
foreach (var kv in _cache.OrderByDescending(pair => pair.Value.LastModified))
|
foreach (var kv in _cache.OrderByDescending(pair => pair.Value.LastModified))
|
||||||
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
|
yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task PersistSnapshotAsync()
|
private Task PersistSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
if (_debouncerCache.TryGetValue(_jsonPath, out _))
|
||||||
{
|
{
|
||||||
@@ -561,7 +601,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsFileLocked(filePath))
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
||||||
}
|
}
|
||||||
@@ -589,16 +629,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
{
|
{
|
||||||
using var sha = SHA256.Create();
|
using var sha = SHA256.Create();
|
||||||
using var stream = File.OpenRead(filePath);
|
using var stream = File.OpenRead(filePath);
|
||||||
var hash = sha.ComputeHash(stream);
|
var hash = SHA256.HashData(stream);
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
return Convert.ToHexStringLower(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(entries);
|
var json = JsonSerializer.Serialize(entries);
|
||||||
using var sha = SHA256.Create();
|
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json));
|
||||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
|
return Convert.ToHexStringLower(hash);
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -693,13 +732,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void RebuildSnapshot()
|
public void RebuildSnapshot()
|
||||||
|
{
|
||||||
|
RebuildSnapshotAsync().Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RebuildSnapshotAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Fast path: if we already have the lock, just log and exit.
|
// Fast path: if we already have the lock, just log and exit.
|
||||||
if (!_buildLock.Wait(0))
|
if (!await _buildLock.WaitAsync(0, cancellationToken))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("RebuildSnapshot called while a rebuild is already in progress, ignoring.");
|
_logger.LogInformation("RebuildSnapshot called while a rebuild is already in progress, ignoring.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1️⃣ Flush the old in‑memory snapshot
|
// 1️⃣ Flush the old in‑memory snapshot
|
||||||
@@ -710,8 +755,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
//_failedAttempts.Clear(); // if you keep per‑user counters
|
//_failedAttempts.Clear(); // if you keep per‑user counters
|
||||||
|
|
||||||
// 2️⃣ Re‑build from disk again
|
// 2️⃣ Re‑build from disk again
|
||||||
BuildSnapshotAsync().Wait(); // synchronous – we already own the lock
|
_buildLock.Release();
|
||||||
PersistSnapshotAsync().Wait(); // same
|
await BuildSnapshotAsync(cancellationToken);
|
||||||
|
await PersistSnapshotAsync(cancellationToken);
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -756,14 +802,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
return new ROMSnapshot();
|
return new ROMSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region IDisposable
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
foreach (var watcher in _watchers)
|
Stop();
|
||||||
|
foreach (var fileSystemWatcher in _watchers)
|
||||||
{
|
{
|
||||||
watcher.Dispose();
|
fileSystemWatcher.Dispose();
|
||||||
}
|
}
|
||||||
|
_cancellation.Dispose();
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a single ROM/archive entry in the snapshot cache.
|
/// Represents a single ROM/archive entry in the snapshot cache.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -771,44 +821,62 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
|
|
||||||
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(string filePath)
|
private async Task<string> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
|
||||||
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
|
||||||
{
|
{
|
||||||
// Open the NSP/XCI with LibHac and read the first stream.
|
|
||||||
// The first stream is the first entry returned by GetContentInfos().
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = new RomArchiveReader(filePath);
|
if (FileLockHelper.IsFileLocked(filePath))
|
||||||
|
|
||||||
var first = reader.GetEntries().FirstOrDefault();
|
|
||||||
if (first == null) return ComputeFullHash(filePath);
|
|
||||||
|
|
||||||
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
|
|
||||||
using var rewindableWrapper = new RewindableStream(first.Stream, () =>
|
|
||||||
{
|
{
|
||||||
return reader.GetEntries().FirstOrDefault().Stream;
|
throw new IOException("File is locked");
|
||||||
}, 10*1024*1024, first.Stream.Length);
|
}
|
||||||
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
|
|
||||||
return hash;
|
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
||||||
|
{
|
||||||
|
// Open the NSP/XCI with LibHac and read the first stream.
|
||||||
|
// The first stream is the first entry returned by GetContentInfos().
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new RomArchiveReader(filePath);
|
||||||
|
|
||||||
|
var first = reader.GetEntries().FirstOrDefault();
|
||||||
|
if (first == null) return ComputeFullHash(filePath);
|
||||||
|
|
||||||
|
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true);
|
||||||
|
await using var rewindableWrapper = new RewindableStream(first.Stream, () => { return reader.GetEntries().FirstOrDefault().Stream; }, 10 * 1024 * 1024, first.Stream.Length);
|
||||||
|
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// On error, fall back to the full file hash
|
||||||
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||||
|
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
||||||
|
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
|
||||||
{
|
{
|
||||||
// On error, fall back to the full file hash
|
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.",
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
attempt + 1, filePath, delay);
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
await Task.Delay(delay, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
throw new IOException($"Failed to compute hash for {filePath} after {_options.MaxRetryCount} attempts");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeFullHash(string filePath)
|
private static string ComputeFullHash(string filePath)
|
||||||
@@ -831,8 +899,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
|
|||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await ValidateSnapshotAsync(cancellationToken);
|
await ValidateSnapshotAsync(cancellationToken);
|
||||||
await BuildSnapshotAsync();
|
_currentBuildTask = BuildSnapshotAsync(_cancellation.Token);
|
||||||
await PersistSnapshotAsync();
|
await _currentBuildTask.WaitAsync(_cancellation.Token);
|
||||||
|
await PersistSnapshotAsync(_cancellation.Token);
|
||||||
}, cancellationToken); // initial scan
|
}, cancellationToken); // initial scan
|
||||||
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
|
/*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace TinfoilVibeServer.Utilities;
|
||||||
|
|
||||||
|
public static class FileLockHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a file is locked (open by another process).
|
||||||
|
/// Works on Windows and Unix (Linux / macOS).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsFileLocked(string filePath, ILogger? logger = null)
|
||||||
|
{
|
||||||
|
// Quick sanity check: the file must exist
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.Open(filePath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None);
|
||||||
|
// If we get here, the file is not locked.
|
||||||
|
stream.Close();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (IOException ioEx)
|
||||||
|
{
|
||||||
|
// On Windows, error code 32 (sharing violation) or 33 (lock violation)
|
||||||
|
// On Unix, EINVAL or EACCES
|
||||||
|
// The .NET exception already hides the native error, so we just log it.
|
||||||
|
logger?.LogDebug(ioEx, "File '{Path}' is locked.", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException uaEx)
|
||||||
|
{
|
||||||
|
logger?.LogDebug(uaEx, "File '{Path}' access denied (likely locked).", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Unexpected: we conservatively say it’s locked
|
||||||
|
logger?.LogError(ex, "Unexpected error while checking lock on '{Path}'. Assuming locked.", filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user