feat(55): stop leaving disk debts — temp clips and a low-disk guard

595 green, 0 warnings. Completes TASK 55 (slice 1, log rotation, shipped 2026-10-22).

- **`AlertClipCleanup`**: the default alert clip is materialised into `%TEMP%` for ffmpeg to read, and
  nothing ever deleted it — one orphan per profile that ever played an alert. Now: delete this session's
  file in `Shutdown`, and sweep `ytLive-alert-*.mp4` at startup for anything a crash left behind. Only
  the prefix we own is touched; a directory is never deleted.
- **`DiskSpaceGuard`**: a recording that dies of a full disk is a trust breach, so the numbers are a
  pure policy — refuse to start under **1 GB** (toast; the stream still starts, recording does not),
  warn under **5 GB** at start ("about eighty minutes" at 8 Mbps ≈ 3.6 GB/h), and one mid-show warning
  under **2 GB** — the last useful moment before the encoder fails for a reason it won't explain.
  Unreadable drives (a USB stick that just left) return null and never block a running recording.

Tests are policy-level on purpose: filling a real drive is not a test. `DiskHygieneTests` proves the
sweep only removes our own files and that the thresholds behave at both ends.
This commit is contained in:
2026-11-10 11:55:42 -08:00
parent c257de1516
commit 1a6a549e17
9 changed files with 222 additions and 5 deletions
+4
View File
@@ -26,6 +26,10 @@ public partial class App : Application
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1); AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
AppLog.Write("App OnStartup begin"); AppLog.Write("App OnStartup begin");
// TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted.
var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath());
if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}");
// Attribution (2026-09-04): every take must be traceable to the exact binary // Attribution (2026-09-04): every take must be traceable to the exact binary
// that produced it — the wordmark shows the release counter (BuildStamp.Release); // that produced it — the wordmark shows the release counter (BuildStamp.Release);
// the per-build GUID is forensic only and lands here. // the per-build GUID is forensic only and lands here.
+60
View File
@@ -0,0 +1,60 @@
using System;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// TASK 55·2: the default alert clip is materialised into <c>%TEMP%</c> so ffmpeg can read it as a file.
/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
///
/// <para>Two touches, deliberately simple: delete this session's file on close, and sweep anything the
/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
/// created are touched — the prefix is the contract, and the sweep never deletes a directory.</para>
/// </summary>
public static class AlertClipCleanup
{
public const string Prefix = "ytLive-alert-";
/// <summary>Deletes stale alert clips left in <paramref name="directory"/>. Returns how many went.
/// Missing directory is not an error.</summary>
public static int Sweep(string directory)
{
try
{
if (!Directory.Exists(directory)) return 0;
var removed = 0;
foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
{
if (TryDelete(path)) removed++;
}
return removed;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, "Alert clip sweep failed");
return 0;
}
}
/// <summary>Deletes one clip if it exists. Safe on null/blank and on a locked file.</summary>
public static bool Delete(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
return TryDelete(path);
}
private static bool TryDelete(string path)
{
try
{
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
catch (Exception ex)
{
Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
return false;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System;
namespace ytLive.Services;
/// <summary>
/// TASK 55·3: a recording that dies of a full disk is a trust breach, not a support ticket. The guard is
/// a pure threshold policy so the numbers are visible in one place and testable without a drive.
///
/// <para>Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start
/// under <b>1 GB</b> keeps the creator from beginning something that cannot finish; a warning under
/// <b>5 GB</b> says "you have about eighty minutes" while there is still time to act; and one mid-show
/// warning at <b>2 GB</b> is the last useful moment before the encoder fails for a reason it will not
/// explain.</para>
/// </summary>
public static class DiskSpaceGuard
{
public const long BlockStartBytes = 1L * 1024 * 1024 * 1024;
public const long WarnStartBytes = 5L * 1024 * 1024 * 1024;
public const long WarnDuringBytes = 2L * 1024 * 1024 * 1024;
public enum StartDecision
{
Ok,
Warn,
Block,
}
public static StartDecision ForStart(long freeBytes)
=> freeBytes < BlockStartBytes ? StartDecision.Block
: freeBytes < WarnStartBytes ? StartDecision.Warn
: StartDecision.Ok;
/// <summary>One warning per session, once the drive falls below the mid-recording threshold.</summary>
public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes;
/// <summary>Free bytes on the drive that holds <paramref name="folder"/>, or null when it cannot be
/// read (a removable drive that just went away must not block a recording that is already running).</summary>
public static long? FreeBytes(string folder)
{
try
{
var root = System.IO.Path.GetPathRoot(System.IO.Path.GetFullPath(folder));
if (string.IsNullOrEmpty(root)) return null;
return new System.IO.DriveInfo(root).AvailableFreeSpace;
}
catch
{
return null;
}
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.** **Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
`37 → 40·A/C/D → 55·2–3 → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36` `37 → 40·A/C/D → Move → 9·5 → 14 (slices) → 65 → 66 → 67 → 68 → 36`
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling, The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound → publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
@@ -22,7 +22,7 @@ output.
| 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
| 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) | | 37 | ☐ | **Defaults vs current layout split** — separate branded defaults from the live workspace; enables one-click revert | [`task-37`](TASKS/task-37-defaults-current-split.md) |
| 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) | | 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 55·2–3 | ☐ | **Disk hygiene** — delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) | | 55 | ✅ | **Disk hygiene** — log rotation (5 MB, one roll), temp alert clips deleted on close + swept at startup, low-disk guard (block under 1 GB, warn under 5 GB at start, one warning under 2 GB mid-show) — done 2026-11-10 | [`task-55`](TASKS/task-55-disk-hygiene.md) |
| — | ☐ | **Move transition** — per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) | | — | ☐ | **Move transition** — per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
| 9·5 | ☐ | **Webcam identity key reconciliation** — one stable identity per camera across re-plugs | [`task-09`](TASKS/task-09-live-stream-management.md) | | 9·5 | ☐ | **Webcam identity key reconciliation** — one stable identity per camera across re-plugs | [`task-09`](TASKS/task-09-live-stream-management.md) |
| 14 | 🔶 | **Creator feedback batch** — Branch 2 shipped; Branch 3 = mic input gain, webcam blank/standby, opacity+geometry consistency, Show-in-folder on stop | [`task-14`](TASKS/task-14-creator-feedback-batch.md) | | 14 | 🔶 | **Creator feedback batch** — Branch 2 shipped; Branch 3 = mic input gain, webcam blank/standby, opacity+geometry consistency, Show-in-folder on stop | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
+3 -3
View File
@@ -30,9 +30,9 @@ ticket and a trust breach.
Guard: `ytLive.Tests/AppLogRotationTests.cs` (the ONE test: the log rolls at the cap, the Guard: `ytLive.Tests/AppLogRotationTests.cs` (the ONE test: the log rolls at the cap, the
live file starts fresh, and the directory still holds exactly `startup.log` + `startup.1.log` live file starts fresh, and the directory still holds exactly `startup.log` + `startup.1.log`
after a SECOND roll — the growth is bounded). after a SECOND roll — the growth is bounded).
2. ☐ **Temp alert clip cleanup** — delete the `%TEMP%` alert clip on app close (and sweep 2. ✅ **Temp alert clip cleanup** — `Services/AlertClipCleanup` deletes the session's clip on close and sweeps `ytLive-alert-*.mp4` at startup (never anything else); shipped 2026-11-10 — delete the `%TEMP%` alert clip on app close (and sweep
stale ones at startup). Test: create, close, assert gone. stale ones at startup). Test: create, close, assert gone.
3. ☐ **Low-disk guard for recordings** — check free space on the record folder's drive before 3. ✅ **Low-disk guard for recordings** — `Services/DiskSpaceGuard`: refuse to start under **1 GB** (toast, recording not started, stream continues), warn under **5 GB** at start, one mid-show warning under **2 GB**; shipped 2026-11-10 — check free space on the record folder's drive before
Record starts, and warn (toast) when it runs low mid-recording. Thresholds TBD with the creator. Record starts, and warn (toast) when it runs low mid-recording. Thresholds chosen for 8 Mbps ≈ 3.6 GB/h; revisit if tiers change.
**Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it. **Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it.
+20
View File
@@ -16,12 +16,32 @@ public partial class MainViewModel : ViewModelBase
private DateTime _recordStartTime; private DateTime _recordStartTime;
private TimeSpan _recordLength; private TimeSpan _recordLength;
private bool _lowDiskWarned;
private string? StartRecordFile() private string? StartRecordFile()
{ {
try try
{ {
var folder = _recordFolder ?? DefaultRecordFolder(); var folder = _recordFolder ?? DefaultRecordFolder();
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
// TASK 55·3: never start something the drive cannot finish.
if (DiskSpaceGuard.FreeBytes(folder) is { } free)
{
var decision = DiskSpaceGuard.ForStart(free);
if (decision == DiskSpaceGuard.StartDecision.Block)
{
AppLog.Write($"Recording refused: {free / (1024 * 1024)} MB free",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Not enough disk space",
"There isn't room to record safely. Free up some space or pick another record folder.");
return null;
}
if (decision == DiskSpaceGuard.StartDecision.Warn)
_notifications.Warning("Disk space is getting low",
"Your recording starts, but there isn't much room left. A long show may not fit.");
}
_lowDiskWarned = false;
var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4"; var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4";
var path = Path.Combine(folder, name); var path = Path.Combine(folder, name);
_activeRecordPath = path; _activeRecordPath = path;
@@ -641,6 +641,16 @@ public partial class MainViewModel : ViewModelBase
_recordLength = _liveElapsed; _recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0; _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(RecDotOpacity));
// TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain.
if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder
&& DiskSpaceGuard.FreeBytes(folder) is { } free
&& DiskSpaceGuard.ShouldWarnDuring(free))
{
_lowDiskWarned = true;
_notifications.Warning("Disk space is nearly gone",
"The recording may stop on its own. Free up space now, or stop and save what you have.");
}
} }
/// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary> /// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary>
+2
View File
@@ -599,6 +599,8 @@ public partial class MainViewModel : ViewModelBase
_musicPlayer.Dispose(); _musicPlayer.Dispose();
_youtubeChat.Dispose(); _youtubeChat.Dispose();
_alertLayer.Dispose(); _alertLayer.Dispose();
// TASK 55·2: the materialised default alert clip is ours, so we clean it up on the way out.
Services.AlertClipCleanup.Delete(_defaultAlertClipScratch);
} }
public void SaveLayoutNow() public void SaveLayoutNow()
+70
View File
@@ -0,0 +1,70 @@
using System;
using System.IO;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 55·2–3 — the two disk debts a free app must not leave: the temp alert clip it materialises and
/// never deletes, and a recording that starts with no room to finish.
///
/// <para>Both are tested as policy, because the alternative — filling a real drive — is not a test.</para>
/// </summary>
public sealed class DiskHygieneTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ytLlive-disk-{Guid.NewGuid():N}");
public DiskHygieneTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public void Sweep_RemovesOnlyTheClipsWeCreated()
{
var ours1 = Path.Combine(_dir, "ytLive-alert-abc.mp4");
var ours2 = Path.Combine(_dir, "ytLive-alert-def.mp4");
var theirs = Path.Combine(_dir, "someone-elses-recording.mp4");
File.WriteAllText(ours1, "x");
File.WriteAllText(ours2, "x");
File.WriteAllText(theirs, "x");
var removed = AlertClipCleanup.Sweep(_dir);
Assert.Equal(2, removed);
Assert.False(File.Exists(ours1));
Assert.False(File.Exists(ours2));
Assert.True(File.Exists(theirs)); // never touch anything that isn't ours
}
[Fact]
public void Delete_IsSafeOnMissingBlankAndNull()
{
Assert.False(AlertClipCleanup.Delete(null));
Assert.False(AlertClipCleanup.Delete(""));
Assert.False(AlertClipCleanup.Delete(Path.Combine(_dir, "not-there.mp4")));
var file = Path.Combine(_dir, "ytLive-alert-gone.mp4");
File.WriteAllText(file, "x");
Assert.True(AlertClipCleanup.Delete(file));
Assert.False(File.Exists(file));
}
[Fact]
public void RecordingIsRefusedBelowAGigabyte_WarnedBelowFive_AndQuietAbove()
{
Assert.Equal(DiskSpaceGuard.StartDecision.Block, DiskSpaceGuard.ForStart(500L * 1024 * 1024));
Assert.Equal(DiskSpaceGuard.StartDecision.Warn, DiskSpaceGuard.ForStart(2L * 1024 * 1024 * 1024));
Assert.Equal(DiskSpaceGuard.StartDecision.Ok, DiskSpaceGuard.ForStart(20L * 1024 * 1024 * 1024));
}
[Fact]
public void MidRecordingWarnsOnceBelowTwoGigabytes()
{
Assert.False(DiskSpaceGuard.ShouldWarnDuring(3L * 1024 * 1024 * 1024));
Assert.True(DiskSpaceGuard.ShouldWarnDuring(1L * 1024 * 1024 * 1024));
}
}