Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ await _dialogService.ShowAlert(
}

CancellationTokenSource cancellation = new();
bool finished = false;
string? savedPath = null;
Exception? failure = null;
bool canceled = false;
Expand All @@ -192,22 +191,22 @@ await _dialogService.ShowAlert(
savedPath = await _fileSaveService.SaveStreamingAsync(
suggestedFileName,
fileTypes,
async (stream, token) =>
async (stream, _) =>
{
// Begin only once the picker has returned a destination and the streaming write is
// starting; dismissing the picker never reaches here, so it stays silent. The finished
// flag turns a late Cancel click into a clean no-op.
// Begin only once the picker has returned a destination and the streaming write is starting;
// dismissing the picker never reaches here, so it stays silent. A Cancel click can race export
// teardown (the CTS is disposed in the finally), so guard the narrow disposed-CTS window.
_exportProgress.Begin(
"Exporting events...", () => { if (!Volatile.Read(ref finished)) { cancellation.Cancel(); } });
"Exporting events...",
() => { try { cancellation.Cancel(); } catch (ObjectDisposedException) { /* Teardown disposed the CTS; a late Cancel is a no-op. */ } });

// Cancellation gates the WRITE only: ExportAsync throws per-row on Cancel, so AtomicFileWriter
// discards the temp. SaveStreamingAsync gets CancellationToken.None, so once the write completes
// the atomic commit is unconditional - a late Cancel can no longer discard a fully-written export.
await _eventTableExporter.ExportAsync(
stream, format, events, columns, timeZone, includeDescription: true, token);

// The streaming write is done; stop honoring Cancel before SaveStreamingAsync runs its
// post-write cancel gates, so a late Cancel can't abort an already-written export's commit.
Volatile.Write(ref finished, true);
stream, format, events, columns, timeZone, includeDescription: true, cancellation.Token);
},
cancellation.Token);
CancellationToken.None);
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
Expand All @@ -219,8 +218,6 @@ await _eventTableExporter.ExportAsync(
}
finally
{
Volatile.Write(ref finished, true);

// End() raises StateChanged; isolate it so a throwing subscriber can never skip the CTS
// disposal or the in-flight reset, which would otherwise wedge every later export.
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,26 @@ public async Task WriteAsync_NoDirectoryComponent_ThrowsArgumentException() =>
await Assert.ThrowsAnyAsync<ArgumentException>(
() => AtomicFileWriter.WriteAsync("barefilename.log", WriteText("x"), TestContext.Current.CancellationToken));

[Fact]
public async Task WriteAsync_NoneToken_CommitsCompletedWriteEvenWhenASeparateSourceIsCancelled()
{
// Mirrors ExportEventsAsync: the write is scoped to a caller CTS, but AtomicFileWriter receives None, so a
// Cancel that lands after the content is written cannot abort the atomic commit - the completed file is saved.
var destination = Destination();
using var cancellation = new CancellationTokenSource();

Func<Stream, CancellationToken, Task> writeThenCancelSeparate = async (stream, _) =>
{
await stream.WriteAsync(Encoding.UTF8.GetBytes("exported"), TestContext.Current.CancellationToken);
await cancellation.CancelAsync();
};

await AtomicFileWriter.WriteAsync(destination, writeThenCancelSeparate, CancellationToken.None);

Assert.Equal("exported", await File.ReadAllTextAsync(destination, TestContext.Current.CancellationToken));
Assert.Empty(LeakedTempFiles());
}

[Fact]
public async Task WriteAsync_NullWriteContent_ThrowsArgumentNullException() =>
await Assert.ThrowsAsync<ArgumentNullException>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ public sealed class EventTableExporterTests
];
private static readonly TimeZoneInfo s_utc = TimeZoneInfo.Utc;

[Fact]
public async Task ExportAsync_CancelledToken_ThrowsAndWritesNothing()
{
using var cancellation = new CancellationTokenSource();
await cancellation.CancelAsync();
EventTableExporter exporter = new(new TabularExportWriter());
using MemoryStream stream = new();

// A Cancel during the export must throw so AtomicFileWriter discards the temp; nothing is written.
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => exporter.ExportAsync(
stream, ExportFormat.Csv, BuildView(s_events), s_columns, s_utc, includeDescription: false, cancellation.Token));

Assert.Equal(0, stream.Length);
}

[Fact]
public async Task ExportAsync_Csv_ExcludeDescription_OmitsColumn()
{
Expand Down
Loading