From 41679db8c5d05329256d38b193b2f6d7a33ee384 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 15:48:15 -0500 Subject: [PATCH 1/2] fix(inspector): run archive zip IO off UI thread to unblock macOS import --- .../Services/SessionArchive.cs | 85 ++++++++++--------- .../ViewModels/MainWindowViewModel.cs | 43 +++++++--- .../AutomationIdCoverageHeadlessTests.cs | 27 +++--- 3 files changed, 87 insertions(+), 68 deletions(-) diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index db10e0c30..09ec7b3b7 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -52,57 +52,66 @@ public static async Task> ImportHarAsync(string path, Canc public static async Task ExportNativeArchiveAsync(IEnumerable sessions, string zipPath, CancellationToken ct = default) { - await using var fs = new FileStream( - zipPath, - FileMode.Create, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: true)) + // Off the UI sync context: zip I/O must not resume on Avalonia's dispatcher (macOS headless + // can otherwise stall the import that follows an export in the same test). + await Task.Run(async () => { - var index = 0; - foreach (var session in sessions) + await using var fs = new FileStream( + zipPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: true)) { - ct.ThrowIfCancellationRequested(); - var entry = zip.CreateEntry($"session-{index:D5}.json"); - await using var stream = await entry.OpenAsync(ct); - await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); - index++; + var index = 0; + foreach (var session in sessions) + { + ct.ThrowIfCancellationRequested(); + var entry = zip.CreateEntry($"session-{index:D5}.json"); + await using var stream = entry.Open(); + await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct).ConfigureAwait(false); + index++; + } } - } - await fs.FlushAsync(ct); + await fs.FlushAsync(ct).ConfigureAwait(false); + }, ct).ConfigureAwait(false); } public static async Task> ImportNativeArchiveAsync(string zipPath, CancellationToken ct = default) { - var list = new List(); - await using var fs = new FileStream( - zipPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); - foreach (var entry in zip.Entries.OrderBy(e => e.FullName)) + return await Task.Run(async () => { - ct.ThrowIfCancellationRequested(); - if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + var list = new List(); + await using var fs = new FileStream( + zipPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); + foreach (var entry in zip.Entries.OrderBy(e => e.FullName)) { - continue; - } + ct.ThrowIfCancellationRequested(); + if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } - await using var stream = await entry.OpenAsync(ct); - var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); - if (snap is not null) - { - list.Add(snap); + await using var stream = entry.Open(); + var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct) + .ConfigureAwait(false); + if (snap is not null) + { + list.Add(snap); + } } - } - return list; + return list; + }, ct).ConfigureAwait(false); } private static object ToHarEntry(SessionSnapshot s) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 38c652a96..2cfa5994e 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1913,8 +1913,15 @@ private async Task ExportArchiveAsync() return; } - await SessionArchive.ExportNativeArchiveAsync(_all, path); - StatusText = $"Exported {_all.Count} sessions to {path}"; + try + { + await SessionArchive.ExportNativeArchiveAsync(_all, path).ConfigureAwait(false); + await MarshalToUiAsync(() => StatusText = $"Exported {_all.Count} sessions to {path}"); + } + catch (Exception ex) + { + await MarshalToUiAsync(() => StatusText = "Export archive failed: " + Truncate(ex.Message, 160)); + } } private async Task ExportSelectedArchiveAsync() @@ -1933,8 +1940,15 @@ private async Task ExportSelectedArchiveAsync() return; } - await SessionArchive.ExportNativeArchiveAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; + try + { + await SessionArchive.ExportNativeArchiveAsync(sessions, path).ConfigureAwait(false); + await MarshalToUiAsync(() => StatusText = $"Exported {sessions.Count} sessions to {path}"); + } + catch (Exception ex) + { + await MarshalToUiAsync(() => StatusText = "Export archive failed: " + Truncate(ex.Message, 160)); + } } private async Task ImportArchiveAsync() @@ -1948,20 +1962,23 @@ private async Task ImportArchiveAsync() try { - var imported = await SessionArchive.ImportNativeArchiveAsync(path); - foreach (var snap in imported) + 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 */ } } } From e66862c4e45b04fb59cd6c413ecfaa70e89f103e Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 15:52:22 -0500 Subject: [PATCH 2/2] fix(inspector): avoid archive import deadlock with UI WaitUntil pumps --- .../Services/SessionArchive.cs | 85 +++++++++---------- .../ViewModels/MainWindowViewModel.cs | 15 ++-- 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 09ec7b3b7..5fbec2a3b 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -52,66 +52,57 @@ public static async Task> ImportHarAsync(string path, Canc public static async Task ExportNativeArchiveAsync(IEnumerable sessions, string zipPath, CancellationToken ct = default) { - // Off the UI sync context: zip I/O must not resume on Avalonia's dispatcher (macOS headless - // can otherwise stall the import that follows an export in the same test). - await Task.Run(async () => + await using var fs = new FileStream( + zipPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: true)) { - await using var fs = new FileStream( - zipPath, - FileMode.Create, - FileAccess.ReadWrite, - FileShare.None, - bufferSize: 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: true)) + var index = 0; + foreach (var session in sessions) { - var index = 0; - foreach (var session in sessions) - { - ct.ThrowIfCancellationRequested(); - var entry = zip.CreateEntry($"session-{index:D5}.json"); - await using var stream = entry.Open(); - await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct).ConfigureAwait(false); - index++; - } + ct.ThrowIfCancellationRequested(); + var entry = zip.CreateEntry($"session-{index:D5}.json"); + await using var stream = entry.Open(); + await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); + index++; } + } - await fs.FlushAsync(ct).ConfigureAwait(false); - }, ct).ConfigureAwait(false); + await fs.FlushAsync(ct); } public static async Task> ImportNativeArchiveAsync(string zipPath, CancellationToken ct = default) { - return await Task.Run(async () => + var list = new List(); + await using var fs = new FileStream( + zipPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); + foreach (var entry in zip.Entries.OrderBy(e => e.FullName)) { - var list = new List(); - await using var fs = new FileStream( - zipPath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete, - bufferSize: 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); - foreach (var entry in zip.Entries.OrderBy(e => e.FullName)) + ct.ThrowIfCancellationRequested(); + if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { - ct.ThrowIfCancellationRequested(); - if (!entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - { - continue; - } + continue; + } - await using var stream = entry.Open(); - var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct) - .ConfigureAwait(false); - if (snap is not null) - { - list.Add(snap); - } + await using var stream = entry.Open(); + var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); + if (snap is not null) + { + list.Add(snap); } + } - return list; - }, ct).ConfigureAwait(false); + return list; } private static object ToHarEntry(SessionSnapshot s) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 2cfa5994e..e185e63ba 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1915,12 +1915,13 @@ private async Task ExportArchiveAsync() try { - await SessionArchive.ExportNativeArchiveAsync(_all, path).ConfigureAwait(false); - await MarshalToUiAsync(() => StatusText = $"Exported {_all.Count} sessions to {path}"); + // 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) { - await MarshalToUiAsync(() => StatusText = "Export archive failed: " + Truncate(ex.Message, 160)); + StatusText = "Export archive failed: " + Truncate(ex.Message, 160); } } @@ -1942,12 +1943,12 @@ private async Task ExportSelectedArchiveAsync() try { - await SessionArchive.ExportNativeArchiveAsync(sessions, path).ConfigureAwait(false); - await MarshalToUiAsync(() => StatusText = $"Exported {sessions.Count} sessions to {path}"); + await SessionArchive.ExportNativeArchiveAsync(sessions, path); + StatusText = $"Exported {sessions.Count} sessions to {path}"; } catch (Exception ex) { - await MarshalToUiAsync(() => StatusText = "Export archive failed: " + Truncate(ex.Message, 160)); + StatusText = "Export archive failed: " + Truncate(ex.Message, 160); } } @@ -1960,8 +1961,10 @@ private async Task ImportArchiveAsync() return; } + StatusText = "Importing archive…"; try { + // 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(() => {