feat(stream): probe the ingest before going live — no more ghost broadcasts (TASK 32·5)
"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.
- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
(rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.
Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
This commit is contained in:
+3
-1
@@ -17,7 +17,9 @@
|
||||
status poll (4 s cadence while recovering) with **measured** elapsed/attempt only — the countdown was
|
||||
dropped after the creator pointed out YouTube's cutoff depends on variables we cannot see. The
|
||||
`chatEndedEvent` in the chat feed now prompts an immediate status check.
|
||||
- Next: 32·5 (ingest pre-flight),
|
||||
- **32·5 shipped**: ingest pre-flight (TCP, 2 s) aborts go-live before any API call, so a dead
|
||||
network leaves no ghost broadcast.
|
||||
- Next:
|
||||
then 32·2/32·3 (countdown + Back on air) which need YOUR measurement take: pull the network on a
|
||||
private stream and time how long YouTube holds the broadcast.
|
||||
- Open loop: `32·4` is proven by construction only — kill ffmpeg mid-recording and play the file.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·5: can we actually reach YouTube's ingest right now? One TCP connect, nothing else.
|
||||
/// <para>This is prevention, not recovery: "I forgot the VPN" used to produce a broadcast that YouTube
|
||||
/// created and nobody ever saw — the app looked live while the push had nowhere to go. Checking the
|
||||
/// socket before the API call means the failure is a message, not a ghost broadcast.</para>
|
||||
/// </summary>
|
||||
public interface IIngestProbe
|
||||
{
|
||||
/// <summary>True when the ingest host accepted a TCP connection. False on any failure — the
|
||||
/// point is a fast, unambiguous answer, not a diagnosis.</summary>
|
||||
Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Default probe: parse the RTMP URL, connect to the host on its port (1935 for rtmp) with a
|
||||
/// short timeout. Failures are logged and reported as "not reachable".</summary>
|
||||
public sealed class TcpIngestProbe : IIngestProbe
|
||||
{
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
public TcpIngestProbe(TimeSpan? timeout = null) => _timeout = timeout ?? DefaultTimeout;
|
||||
|
||||
public async Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParse(rtmpUrl, out var host, out var port))
|
||||
{
|
||||
AppLog.Write($"Ingest probe: '{rtmpUrl}' is not a usable RTMP URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, timeoutCts.Token).ConfigureAwait(false);
|
||||
return client.Connected;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write($"Ingest probe: {host}:{port} did not answer within {_timeout.TotalSeconds:0.#} s");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Ingest probe: {host}:{port} unreachable: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>rtmp://a.rtmp.youtube.com/live2/key → (a.rtmp.youtube.com, 1935).
|
||||
/// rtmps uses 443, which is what YouTube's secure ingest listens on.</summary>
|
||||
internal static bool TryParse(string? rtmpUrl, out string host, out int port)
|
||||
{
|
||||
host = string.Empty;
|
||||
port = 1935;
|
||||
if (string.IsNullOrWhiteSpace(rtmpUrl)) return false;
|
||||
if (!Uri.TryCreate(rtmpUrl, UriKind.Absolute, out var uri)) return false;
|
||||
if (string.IsNullOrWhiteSpace(uri.Host)) return false;
|
||||
|
||||
host = uri.Host;
|
||||
port = uri.IsDefaultPort
|
||||
? (uri.Scheme.Equals("rtmps", StringComparison.OrdinalIgnoreCase) ? 443 : 1935)
|
||||
: uri.Port;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
|
||||
| 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) |
|
||||
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
||||
| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||
| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) |
|
||||
| 32 | 🔶 | Stream resilience: **fragmented MP4 + blip retry + poll-driven RECONNECTING + ingest pre-flight done 2026-11-05** (no countdown — YouTube's cutoff is not a constant); remaining — one-click Back on air | [`task-32`](TASKS/task-32-stream-resilience.md) |
|
||||
| 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
|
||||
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
|
||||
| 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
|
||||
|
||||
@@ -49,9 +49,15 @@ the API — creator ruling 2026-09-01): no lying in the copy.
|
||||
editor tolerance, cite the research). Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` — the flags are on the record block and
|
||||
absent from the streaming block. Verified by construction only: the file still needs one real
|
||||
interrupted-take check on Windows (kill ffmpeg mid-recording, play the result).
|
||||
5. ☐ **Pre-flight** — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout).
|
||||
5. ✅ **Pre-flight** (shipped 2026-11-05) — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout).
|
||||
Unreachable → Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the
|
||||
VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call.
|
||||
**Shipped:** `Services/IngestProbe.cs` (`IIngestProbe` + `TcpIngestProbe`, 2 s timeout, rtmp→1935 /
|
||||
rtmps→443) and `MainViewModel.IngestPreflightAsync`, called BEFORE the first API call whenever a
|
||||
cached ingest URL exists (first run probes after resolving the stream, still before any broadcast is
|
||||
created). Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, and the probe is
|
||||
exercised against real sockets (a listening port answers true, the same port closed answers false
|
||||
quickly).
|
||||
|
||||
### Design decisions
|
||||
|
||||
|
||||
@@ -147,6 +147,22 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
|
||||
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
|
||||
|
||||
/// <summary>TASK 32·5 — is YouTube's ingest reachable right now? A false answer aborts go-live
|
||||
/// before anything is created on YouTube: "I forgot the VPN" used to produce a broadcast that
|
||||
/// nobody ever saw, and the app looked live while the push had nowhere to go.</summary>
|
||||
internal async Task<bool> IngestPreflightAsync(string rtmpUrl)
|
||||
{
|
||||
var probe = IngestProbeOverride ?? new TcpIngestProbe();
|
||||
if (await probe.CanReachAsync(rtmpUrl)) return true;
|
||||
|
||||
AppLog.Write("Ingest unreachable — go-live aborted before creating a broadcast",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
||||
_notifications.Error("Can't reach YouTube's ingest",
|
||||
"Nothing was created on YouTube. Check your network or VPN, then try again.");
|
||||
StopStream(); // rollback: no ghost broadcast, no half-lit session
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
|
||||
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
|
||||
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
|
||||
@@ -156,6 +172,13 @@ public partial class MainViewModel : ViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
// TASK 32·5 — prevention. With a cached ingest URL we probe BEFORE the first API call, so
|
||||
// a dead network costs nothing and leaves nothing behind on YouTube. First run (no cached
|
||||
// URL yet) probes once the stream is resolved, still before any broadcast is created.
|
||||
var cachedIngest = _reusableStreamUrl;
|
||||
if (!string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(cachedIngest))
|
||||
return;
|
||||
|
||||
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
|
||||
if (stream == null)
|
||||
{
|
||||
@@ -170,6 +193,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
_reusableStreamUrl = stream.RtmpUrl;
|
||||
_layoutStore.SaveReusableStream(stream);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(stream.RtmpUrl))
|
||||
return;
|
||||
|
||||
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
|
||||
StreamTitle, StreamDescription, DateTime.UtcNow);
|
||||
_broadcastSeenLive = false;
|
||||
|
||||
@@ -505,6 +505,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// a clip's backdrop.</summary>
|
||||
internal static IChromaProbe? ChromaProbeOverride { get; set; }
|
||||
|
||||
/// <summary>Test seam (TASK 32·5): a fake ingest probe so tests never open a socket.</summary>
|
||||
internal static IIngestProbe? IngestProbeOverride { get; set; }
|
||||
|
||||
private void LoadLayout()
|
||||
{
|
||||
_isLoading = true;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·5 Good Dog: "I forgot the VPN" used to create a broadcast on YouTube that nothing ever
|
||||
/// pushed to. Go-live now probes the ingest with one TCP connect first; a dead network costs two
|
||||
/// seconds and leaves nothing behind on YouTube.
|
||||
/// <para>The gate is asserted through the view-model's own entry point, and the probe is asserted
|
||||
/// against real sockets — a listening port and a closed one — so neither half is a mock talking to a
|
||||
/// mock.</para>
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class IngestPreflightTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public IngestPreflightTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public async Task UnreachableIngest_AbortsGoLive()
|
||||
{
|
||||
await _app.RunAsync(async () =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainViewModel.IngestProbeOverride = new FakeProbe(false);
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
Assert.False(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key"),
|
||||
"an unreachable ingest must abort go-live before anything is created on YouTube");
|
||||
Assert.False(vm.IsLive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.IngestProbeOverride = null;
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReachableIngest_LetsGoLiveContinue()
|
||||
{
|
||||
await _app.RunAsync(async () =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainViewModel.IngestProbeOverride = new FakeProbe(true);
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
Assert.True(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.IngestProbeOverride = null;
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheProbe_ParsesTheIngestUrl_AndDefaultsToTheRtmpPort()
|
||||
{
|
||||
Assert.True(TcpIngestProbe.TryParse("rtmp://a.rtmp.youtube.com/live2/key", out var host, out var port));
|
||||
Assert.Equal("a.rtmp.youtube.com", host);
|
||||
Assert.Equal(1935, port);
|
||||
|
||||
Assert.True(TcpIngestProbe.TryParse("rtmps://a.rtmps.youtube.com/live2/key", out _, out var securePort));
|
||||
Assert.Equal(443, securePort);
|
||||
|
||||
Assert.False(TcpIngestProbe.TryParse("not a url", out _, out _));
|
||||
Assert.False(TcpIngestProbe.TryParse("", out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheProbe_AnswersFromRealSockets()
|
||||
{
|
||||
// A listener on loopback: reachable.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
try
|
||||
{
|
||||
var probe = new TcpIngestProbe(TimeSpan.FromSeconds(2));
|
||||
Assert.True(await probe.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
// The same port, now closed: unreachable, and it must answer quickly rather than hang.
|
||||
var closed = new TcpIngestProbe(TimeSpan.FromMilliseconds(400));
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
Assert.False(await closed.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key"));
|
||||
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3),
|
||||
$"the probe must fail fast, took {clock.Elapsed.TotalSeconds:0.0}s");
|
||||
}
|
||||
|
||||
private sealed class FakeProbe : IIngestProbe
|
||||
{
|
||||
private readonly bool _reachable;
|
||||
public FakeProbe(bool reachable) => _reachable = reachable;
|
||||
public Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_reachable);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user