|
2 | 2 | using System.Globalization; |
3 | 3 | using System.IO; |
4 | 4 | using System.Reactive.Subjects; |
| 5 | +using System.Runtime.InteropServices; |
5 | 6 | using System.Threading; |
6 | 7 | using System.Threading.Tasks; |
7 | 8 | using FFmpeg.AutoGen.Abstractions; |
@@ -34,6 +35,13 @@ internal sealed class LibavformatRecordingSession : IRecordingSession |
34 | 35 | private string? _outputPath; |
35 | 36 | private volatile bool _stopRequested; |
36 | 37 |
|
| 38 | + // Rooted so the unmanaged function pointer stays valid for the demuxer's |
| 39 | + // life. Lets StopAsync/Dispose actually abort a blocking av_read_frame / |
| 40 | + // avformat_open_input on a stalled camera — without it a hung read leaves |
| 41 | + // the writer thread stuck before av_write_trailer, so the .mp4 is never |
| 42 | + // flushed and lands on disk at 0 bytes (the corrupted-recording report). |
| 43 | + private AVIOInterruptCB_callback? _interruptDelegate; |
| 44 | + |
37 | 45 | public DateTime StartedAt { get; } = DateTime.UtcNow; |
38 | 46 | public string? CurrentSegmentPath => _outputPath; |
39 | 47 | public IObservable<RecordingEvent> Events => _events; |
@@ -126,6 +134,17 @@ private unsafe void Run() |
126 | 134 | // --- Input --- |
127 | 135 | BuildInputOpts(&inputOpts); |
128 | 136 | inputCtx = ffmpeg.avformat_alloc_context(); |
| 137 | + // Wire the interrupt callback before open so a cancelled token |
| 138 | + // aborts even the connect/handshake, not just the read loop. |
| 139 | + _interruptDelegate = OnInterrupt; |
| 140 | + inputCtx->interrupt_callback = new AVIOInterruptCB |
| 141 | + { |
| 142 | + callback = new AVIOInterruptCB_callback_func |
| 143 | + { |
| 144 | + Pointer = Marshal.GetFunctionPointerForDelegate(_interruptDelegate), |
| 145 | + }, |
| 146 | + opaque = null, |
| 147 | + }; |
129 | 148 | var url = BuildRtspUri(_options.RtspUri, _options.Credentials); |
130 | 149 | var ret = ffmpeg.avformat_open_input(&inputCtx, url, null, &inputOpts); |
131 | 150 | FfmpegError.ThrowIfError(ret, "avformat_open_input"); |
@@ -183,6 +202,14 @@ private unsafe void Run() |
183 | 202 | ret = ffmpeg.av_read_frame(inputCtx, packet); |
184 | 203 | if (ret < 0) |
185 | 204 | { |
| 205 | + // Our interrupt callback fired (graceful stop) — not an error. |
| 206 | + // Fall through to av_write_trailer below so the file is still |
| 207 | + // finalized instead of left half-written. |
| 208 | + if (ct.IsCancellationRequested) |
| 209 | + { |
| 210 | + stopReason = RecordingStopReason.User; |
| 211 | + break; |
| 212 | + } |
186 | 213 | if (ret == ffmpeg.AVERROR_EOF) |
187 | 214 | { |
188 | 215 | _logger.LogInformation("Recording input EOF"); |
@@ -251,10 +278,21 @@ private unsafe void Run() |
251 | 278 | } |
252 | 279 | } |
253 | 280 |
|
| 281 | + // Returns 1 to tell libavformat to abort the current blocking call. Bound to |
| 282 | + // the session CTS so StopAsync (which cancels it) unblocks a stalled read or |
| 283 | + // a connect to an unreachable camera. |
| 284 | + private unsafe int OnInterrupt(void* opaque) => _cts.IsCancellationRequested ? 1 : 0; |
| 285 | + |
254 | 286 | private static unsafe void BuildInputOpts(AVDictionary** opts) |
255 | 287 | { |
256 | 288 | ffmpeg.av_dict_set(opts, "rtsp_transport", "tcp", 0); |
| 289 | + // "timeout" is the current RTSP socket-timeout key; "stimeout" was renamed |
| 290 | + // and is ignored on the n7.1 build we bundle. Set both so a dead stream |
| 291 | + // errors out instead of blocking forever (belt-and-braces with the |
| 292 | + // interrupt callback). rw_timeout covers the non-RTSP protocols. |
| 293 | + ffmpeg.av_dict_set(opts, "timeout", "5000000", 0); |
257 | 294 | ffmpeg.av_dict_set(opts, "stimeout", "5000000", 0); |
| 295 | + ffmpeg.av_dict_set(opts, "rw_timeout", "5000000", 0); |
258 | 296 | ffmpeg.av_dict_set(opts, "max_delay", "200000", 0); |
259 | 297 | } |
260 | 298 |
|
|
0 commit comments