diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index db10e0c30..5fbec2a3b 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -66,7 +66,7 @@ public static async Task ExportNativeArchiveAsync(IEnumerable s { ct.ThrowIfCancellationRequested(); var entry = zip.CreateEntry($"session-{index:D5}.json"); - await using var stream = await entry.OpenAsync(ct); + await using var stream = entry.Open(); await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); index++; } @@ -82,7 +82,7 @@ public static async Task> ImportNativeArchiveAsync(string zipPath, FileMode.Open, FileAccess.Read, - FileShare.Read, + FileShare.ReadWrite | FileShare.Delete, bufferSize: 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); @@ -94,7 +94,7 @@ public static async Task> ImportNativeArchiveAsync(string continue; } - await using var stream = await entry.OpenAsync(ct); + await using var stream = entry.Open(); var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); if (snap is not null) { diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 38c652a96..e185e63ba 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1913,8 +1913,16 @@ private async Task ExportArchiveAsync() return; } - await SessionArchive.ExportNativeArchiveAsync(_all, path); - StatusText = $"Exported {_all.Count} sessions to {path}"; + try + { + // SessionArchive runs zip IO on the thread pool; resume here on the UI sync context. + await SessionArchive.ExportNativeArchiveAsync(_all, path); + StatusText = $"Exported {_all.Count} sessions to {path}"; + } + catch (Exception ex) + { + StatusText = "Export archive failed: " + Truncate(ex.Message, 160); + } } private async Task ExportSelectedArchiveAsync() @@ -1933,8 +1941,15 @@ private async Task ExportSelectedArchiveAsync() return; } - await SessionArchive.ExportNativeArchiveAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; + try + { + await SessionArchive.ExportNativeArchiveAsync(sessions, path); + StatusText = $"Exported {sessions.Count} sessions to {path}"; + } + catch (Exception ex) + { + StatusText = "Export archive failed: " + Truncate(ex.Message, 160); + } } private async Task ImportArchiveAsync() @@ -1946,22 +1961,27 @@ private async Task ImportArchiveAsync() return; } + StatusText = "Importing archive…"; try { - var imported = await SessionArchive.ImportNativeArchiveAsync(path); - foreach (var snap in imported) + // Off the UI sync context for zip IO so headless WaitUntil pumps cannot deadlock the import. + var imported = await SessionArchive.ImportNativeArchiveAsync(path).ConfigureAwait(false); + await MarshalToUiAsync(() => { - _registry.Add(snap); - _all.Add(snap); - } + foreach (var snap in imported) + { + _registry.Add(snap); + _all.Add(snap); + } - ApplyFilter(); - RefreshSessionCountText(); - StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; + ApplyFilter(); + RefreshSessionCountText(); + StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; + }); } catch (Exception ex) { - StatusText = "Import archive failed: " + Truncate(ex.Message, 160); + await MarshalToUiAsync(() => StatusText = "Import archive failed: " + Truncate(ex.Message, 160)); } } diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index 8d944d22f..61338b6cb 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -275,21 +275,10 @@ await fx.DispatchAsync(() => StringAssert.Contains(fx.ViewModel.StatusText, "Exported 1 sessions"); }); - // macOS runners can briefly keep the zip handle; wait until a shared read succeeds. - fx.PathPicker.OpenPath = zip; - var readableDeadline = DateTime.UtcNow.AddSeconds(10); - while (DateTime.UtcNow < readableDeadline) - { - try - { - await using var probe = new FileStream(zip, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - break; - } - catch (IOException) - { - await Task.Delay(50); - } - } + // Import from a copy so any lingering exclusive handle on the export path cannot block macOS. + var importZip = Path.Combine(Path.GetTempPath(), "twp-arch-in-" + Guid.NewGuid().ToString("N") + ".zip"); + File.Copy(zip, importZip, overwrite: true); + fx.PathPicker.OpenPath = importZip; await fx.DispatchAsync(() => fx.Robot.Click("MenuImportArchive")); @@ -300,9 +289,13 @@ await fx.WaitUntilAsync( await fx.DispatchAsync(() => { - Assert.IsTrue(fx.PathPicker.OpenCalls >= 1); - StringAssert.Contains(fx.ViewModel.StatusText, "Appended"); + Assert.IsTrue(fx.PathPicker.OpenCalls >= 1, "Import path picker was not invoked"); + StringAssert.Contains( + fx.ViewModel.StatusText, + "Appended", + "StatusText after import: " + fx.ViewModel.StatusText); }); try { File.Delete(zip); } catch { /* ignore */ } + try { File.Delete(importZip); } catch { /* ignore */ } } }