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:
@@ -26,6 +26,10 @@ public partial class App : Application
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
|
||||
|
||||
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
|
||||
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
||||
// the per-build GUID is forensic only and lands here.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
**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,
|
||||
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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -30,9 +30,9 @@ ticket and a trust breach.
|
||||
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`
|
||||
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.
|
||||
3. ☐ **Low-disk guard for recordings** — 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.
|
||||
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 chosen for 8 Mbps ≈ 3.6 GB/h; revisit if tiers change.
|
||||
|
||||
**Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it.
|
||||
|
||||
@@ -16,12 +16,32 @@ public partial class MainViewModel : ViewModelBase
|
||||
private DateTime _recordStartTime;
|
||||
private TimeSpan _recordLength;
|
||||
|
||||
private bool _lowDiskWarned;
|
||||
|
||||
private string? StartRecordFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = _recordFolder ?? DefaultRecordFolder();
|
||||
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 path = Path.Combine(folder, name);
|
||||
_activeRecordPath = path;
|
||||
|
||||
@@ -641,6 +641,16 @@ public partial class MainViewModel : ViewModelBase
|
||||
_recordLength = _liveElapsed;
|
||||
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
|
||||
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>
|
||||
|
||||
@@ -599,6 +599,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
_musicPlayer.Dispose();
|
||||
_youtubeChat.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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user