Skip to content

Commit f9c2ffd

Browse files
committed
Save the after-Stop diarizer notes, end the worker when the window closes, and kill helpers on app exit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GUzvd3VYM6n8xTXtBdDt5f
1 parent a356cc7 commit f9c2ffd

5 files changed

Lines changed: 158 additions & 22 deletions

File tree

‎src/PrimeDictate.Desktop/MainWindow.axaml.cs‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ public MainWindow()
110110
this.AddHandler(DragDrop.DropEvent, this.OnDrop);
111111
this.Opened += async (_, _) => await this.InitializeWorkspaceAsync();
112112
this.Closing += (_, _) => this.workspace.DiscardLiveAsync().GetAwaiter().GetResult();
113+
// Closing the window ends the speech worker too; left running it keeps the GPU's memory.
114+
this.Closed += (_, _) => this.workspace.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10));
113115
}
114116

115117
/// <summary>Selects the newest session; used by the smoke screenshot.</summary>
@@ -249,22 +251,13 @@ private void Attach(SessionDocumentHost? newHost)
249251
}
250252

251253
/// <summary>Redraws a finished meeting's speaker timeline from the whole-file diarizer, so overlapping speech shows.</summary>
252-
private async Task RefineTimelineAsync(Guid sessionId)
254+
private async Task RefineTimelineAsync(SessionDocumentHost target)
253255
{
254-
var (overlay, reason) = await this.workspace.BuildOverlayAsync(sessionId, CancellationToken.None);
255-
if (this.host is not { } current || current.Document.SessionId != sessionId)
256+
var overlay = await this.workspace.RefineSpeakerTimelineAsync(target, CancellationToken.None);
257+
if (overlay is not null && ReferenceEquals(this.host, target))
256258
{
257-
return;
258-
}
259-
260-
if (overlay is null)
261-
{
262-
current.AddNote($"Speaker timeline was not redrawn: {reason}");
263-
return;
259+
this.Timeline.SetOverlay(overlay.MapTo(target.Document));
264260
}
265-
266-
this.Timeline.SetOverlay(overlay.MapTo(current.Document));
267-
current.AddNote($"Speaker timeline redrawn from the diarizer: {overlay.SpeakerCount} system speakers, {overlay.OverlapSeconds:0.#} s of overlapping speech.");
268261
}
269262

270263
private void OnDocumentChanged(TranscriptDocument _)
@@ -756,10 +749,10 @@ private async Task StopRecordingAsync()
756749
this.EndJob();
757750
this.Say(target?.Document.Status == TranscriptSessionStatus.Completed ? "Saved." : $"Stopped: {target?.Document.Status}. What was captured is kept.");
758751
await this.ReloadSessionsAsync(target?.Document.SessionId);
759-
if (target is { Document.SourceType: TranscriptSourceType.Meeting } meeting && meeting.Document.Status == TranscriptSessionStatus.Completed)
752+
if (target is { Document.SourceType: TranscriptSourceType.Meeting } meeting)
760753
{
761754
this.Say("Saved. Redrawing the speaker timeline…");
762-
await this.RunSafelyAsync(() => this.RefineTimelineAsync(meeting.Document.SessionId));
755+
await this.RunSafelyAsync(() => this.RefineTimelineAsync(meeting));
763756
this.Say("Saved.");
764757
}
765758
}

‎src/PrimeDictate.Platforms/Nemotron/ChildProcessJob.cs‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,64 @@ namespace PrimeDictate.Platforms.Nemotron;
66
/// <summary>
77
/// Ties helper processes to this app on Windows: they go into a job object that kills them when the app's last handle
88
/// to it closes, which includes a crash or a force-kill (a rebuild, Task Manager). Without it a killed app leaves the
9-
/// GPU-holding <c>nemo-speech</c> worker running. Other platforms have no equivalent here and return false; there the
10-
/// worker still dies when the app disposes it normally.
9+
/// GPU-holding <c>nemo-speech</c> worker running. Every platform also kills the helpers when the app exits normally or
10+
/// after an unhandled error; only a force-kill on Linux or macOS can still leave one behind.
1111
/// </summary>
1212
public static class ChildProcessJob
1313
{
1414
private static readonly object Gate = new();
1515
private static IntPtr job;
16+
private static readonly List<Process> Started = [];
1617

17-
/// <returns>True when the process is now tied to this app.</returns>
18+
static ChildProcessJob() => AppDomain.CurrentDomain.ProcessExit += (_, _) => KillAll();
19+
20+
/// <summary>Kills every helper still running. Runs when the app exits on any platform, including after an unhandled error.</summary>
21+
public static void KillAll()
22+
{
23+
Process[] running;
24+
lock (Gate)
25+
{
26+
running = [.. Started];
27+
Started.Clear();
28+
}
29+
30+
foreach (var process in running)
31+
{
32+
try
33+
{
34+
if (!IsGone(process))
35+
{
36+
process.Kill(entireProcessTree: true);
37+
}
38+
}
39+
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception)
40+
{
41+
}
42+
}
43+
}
44+
45+
/// <summary>True when the process ended or its object was already disposed.</summary>
46+
private static bool IsGone(Process process)
47+
{
48+
try
49+
{
50+
return process.HasExited;
51+
}
52+
catch (InvalidOperationException)
53+
{
54+
return true;
55+
}
56+
}
57+
58+
/// <returns>True when the process is now tied to this app by the operating system (Windows job object).</returns>
1859
public static bool TryAdd(Process process)
1960
{
61+
lock (Gate)
62+
{
63+
Started.RemoveAll(IsGone);
64+
Started.Add(process);
65+
}
66+
2067
if (!OperatingSystem.IsWindows())
2168
{
2269
return false;

‎src/PrimeDictate.Platforms/TranscriptionWorkspaceService.cs‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,51 @@ public async Task StopLiveAsync(CancellationToken cancellationToken)
465465
return (overlay, error);
466466
}
467467

468+
/// <summary>
469+
/// Runs the whole-file diarizer for a finished meeting and always says what happened in the session's notes, saved to
470+
/// the database: that it started, and then that it worked or why it did not. The notes are saved here because nothing
471+
/// else writes the session again after Stop.
472+
/// </summary>
473+
public async Task<DiarizationOverlay?> RefineSpeakerTimelineAsync(SessionDocumentHost host, CancellationToken cancellationToken)
474+
{
475+
async Task NoteAsync(string text)
476+
{
477+
host.AddNote(text);
478+
await host.CheckpointAsync(CancellationToken.None).ConfigureAwait(false);
479+
}
480+
481+
var document = host.Document;
482+
if (document.SourceType != TranscriptSourceType.Meeting)
483+
{
484+
return null;
485+
}
486+
487+
if (document.Status != TranscriptSessionStatus.Completed)
488+
{
489+
await NoteAsync($"Speaker timeline was not redrawn: the session ended as {document.Status}, not Completed.").ConfigureAwait(false);
490+
return null;
491+
}
492+
493+
try
494+
{
495+
await NoteAsync("Redrawing the speaker timeline from the diarizer (running)…").ConfigureAwait(false);
496+
var (overlay, reason) = await this.BuildOverlayAsync(document.SessionId, cancellationToken).ConfigureAwait(false);
497+
if (overlay is null)
498+
{
499+
await NoteAsync($"Speaker timeline was not redrawn: {reason}").ConfigureAwait(false);
500+
return null;
501+
}
502+
503+
await NoteAsync($"Speaker timeline redrawn from the diarizer: {overlay.SpeakerCount} system speakers, {overlay.OverlapSeconds:0.#} s of overlapping speech.").ConfigureAwait(false);
504+
return overlay;
505+
}
506+
catch (Exception ex) when (ex is not OperationCanceledException)
507+
{
508+
await NoteAsync($"Speaker timeline was not redrawn: {ex.Message}").ConfigureAwait(false);
509+
return null;
510+
}
511+
}
512+
468513
public async Task DiscardLiveAsync()
469514
{
470515
var session = Interlocked.Exchange(ref this.live, null);
@@ -501,14 +546,21 @@ public static async Task ExportAsync(TranscriptDocument document, string path, E
501546
public async ValueTask DisposeAsync()
502547
{
503548
await this.DiscardLiveAsync().ConfigureAwait(false);
504-
foreach (var provider in this.providers.Values)
549+
// Stop the worker first: it holds the GPU, and the providers' sockets then close quickly instead of waiting on it.
550+
if (this.nemotronWorker is not null)
505551
{
506-
await provider.DisposeAsync().ConfigureAwait(false);
552+
await this.nemotronWorker.DisposeAsync().ConfigureAwait(false);
507553
}
508554

509-
if (this.nemotronWorker is not null)
555+
foreach (var provider in this.providers.Values)
510556
{
511-
await this.nemotronWorker.DisposeAsync().ConfigureAwait(false);
557+
try
558+
{
559+
await provider.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
560+
}
561+
catch (Exception ex) when (ex is TimeoutException or ObjectDisposedException or InvalidOperationException)
562+
{
563+
}
512564
}
513565

514566
await this.store.DisposeAsync().ConfigureAwait(false);

‎tests/PrimeDictate.Core.Tests/NemotronLogTests.cs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ public void The_debug_variable_that_prints_word_pieces_is_removed_from_a_childs_
4545
Assert.True(info.Environment.ContainsKey("KEEP_ME"));
4646
}
4747

48+
[Fact]
49+
public void Tracking_a_process_that_was_already_disposed_does_not_throw_and_helpers_are_killed_on_request()
50+
{
51+
if (!OperatingSystem.IsLinux())
52+
{
53+
return;
54+
}
55+
56+
var gone = System.Diagnostics.Process.Start("/bin/true")!;
57+
gone.WaitForExit();
58+
gone.Dispose();
59+
var sleeper = System.Diagnostics.Process.Start("/bin/sleep", "60")!;
60+
61+
ChildProcessJob.TryAdd(gone);
62+
ChildProcessJob.TryAdd(sleeper);
63+
ChildProcessJob.KillAll();
64+
65+
Assert.True(sleeper.WaitForExit(5000));
66+
}
67+
4868
[Fact]
4969
public void Long_lines_are_cut_short()
5070
{

‎tests/PrimeDictate.Core.Tests/StorageTests.cs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ private async Task<SqliteTranscriptionSessionStore> OpenStoreAsync()
3030
return store;
3131
}
3232

33+
[Fact]
34+
public async Task Segments_saved_in_finish_order_load_sorted_by_start_and_a_late_note_needs_its_own_checkpoint()
35+
{
36+
await using var store = await this.OpenStoreAsync();
37+
var doc = NewDocument();
38+
doc = TranscriptDocumentReducer.Apply(doc, new SessionStarted(doc.SessionId, Run()), Now);
39+
// The two meeting streams finish in any order, so segments are stored in the order they were finalized.
40+
foreach (var (id, start) in new[] { ("us0.0", 5.0), ("um0.0", 1.0), ("us0.1", 3.0) })
41+
{
42+
doc = TranscriptDocumentReducer.Apply(doc, new SegmentFinalized(doc.SessionId, Segment(id, id, start, start + 1)), Now);
43+
}
44+
45+
var host = new PrimeDictate.Core.Pipeline.SessionDocumentHost(doc, store);
46+
await host.CheckpointAsync();
47+
host.AddNote("added after the session was already saved");
48+
49+
Assert.DoesNotContain("added after the session was already saved", (await store.LoadAsync(doc.SessionId, default))!.Notes);
50+
await host.CheckpointAsync();
51+
var loaded = (await store.LoadAsync(doc.SessionId, default))!;
52+
53+
Assert.Equal(["um0.0", "us0.1", "us0.0"], loaded.Segments.Select(s => s.Id));
54+
Assert.Contains("added after the session was already saved", loaded.Notes);
55+
}
56+
3357
[Fact]
3458
public async Task Checkpoint_round_trips_final_content_but_not_partials()
3559
{

0 commit comments

Comments
 (0)