From e62f390eef985e5b4f102153949145e15d80c4f7 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 18 Oct 2026 00:09:08 +0300 Subject: [PATCH 1/3] fix(recording): stop hung RTSP opens and surface recorder failures (#90, #91) - ffmpeg recorder/web live/snapshot get an RTSP socket timeout; a camera that accepts TCP but never answers parked ffmpeg in open forever, so no segment or Recordings row ever appeared while REC stayed lit - RecordingService handles a recorder that stops on its own: clears REC, finalizes the last row and raises RecordingFailed; camera page shows why - Name Windows socket errnos (-138 = timed out) and keep the RTSP "Failed reading RTSP data" warning in the error instead of a bare "Invalid data found" --- src/OpenIPC.Viewer.App/Services/Localizer.cs | 4 + .../ViewModels/SingleCameraPageViewModel.cs | 10 + .../Recording/RecordingEvent.cs | 3 +- .../Recording/RecordingService.cs | 51 ++++- .../Pipeline/FfmpegError.cs | 44 +++- .../Pipeline/FfmpegRuntime.cs | 18 +- .../Pipeline/FfmpegVideoSession.cs | 1 + .../Recording/FfmpegRecordingSession.cs | 43 +++- .../Recording/LibavformatRecordingSession.cs | 7 +- src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs | 4 +- src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs | 2 +- src/OpenIPC.Viewer.Web/Api/WebRecorder.cs | 6 +- .../Recording/RecordingServiceTests.cs | 204 ++++++++++++++++++ 13 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Recording/RecordingServiceTests.cs diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 709f040..13bf7ab 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -555,6 +555,8 @@ private static LangCode DetectSystem() ["CameraPage.ApplyDone"] = "Done.", ["CameraPage.ApplyFailedFormat"] = "Failed: {0}", ["CameraPage.RecordingFailedFormat"] = "Recording failed: {0}", + ["CameraPage.RecordingNotStartedFormat"] = "Recording did not start — the camera stream could not be opened: {0}", + ["CameraPage.RecordingInterruptedFormat"] = "Recording stopped — the camera stream was lost: {0}", ["CameraPage.SnapshotFailedFormat"] = "Snapshot failed: {0}", ["CameraPage.Tooltip.Listen"] = "Listen to camera audio", ["CameraPage.Tooltip.Mute"] = "Mute audio", @@ -1211,6 +1213,8 @@ private static LangCode DetectSystem() ["CameraPage.ApplyDone"] = "Готово.", ["CameraPage.ApplyFailedFormat"] = "Ошибка: {0}", ["CameraPage.RecordingFailedFormat"] = "Запись не удалась: {0}", + ["CameraPage.RecordingNotStartedFormat"] = "Запись не началась — не удалось открыть поток камеры: {0}", + ["CameraPage.RecordingInterruptedFormat"] = "Запись остановлена — поток камеры прервался: {0}", ["CameraPage.SnapshotFailedFormat"] = "Снимок не удался: {0}", ["CameraPage.Tooltip.Listen"] = "Слушать звук с камеры", ["CameraPage.Tooltip.Mute"] = "Выключить звук", diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index 8e9c65b..dc18d55 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -304,6 +304,7 @@ public SingleCameraPageViewModel( IsRecording = _recordings.IsRecording(_camera.Id); _recordings.StateChanged += OnRecordingsStateChanged; + _recordings.RecordingFailed += OnRecordingFailed; if (IsRecording) StartRecTimer(); // Telemetry overlay visibility is a user pref — re-raise PropertyChanged @@ -561,6 +562,14 @@ private void OnRecordingsStateChanged(object? sender, CameraId cam) }); } + private void OnRecordingFailed(object? sender, RecordingService.RecordingFailure failure) + { + if (failure.CameraId != _camera.Id) return; + var key = failure.WroteSegment ? "CameraPage.RecordingInterruptedFormat" : "CameraPage.RecordingNotStartedFormat"; + Dispatcher.UIThread.Post(() => ErrorMessage = string.Format(CultureInfo.CurrentCulture, + Localizer.Instance[key], failure.Message)); + } + private void StartRecTimer() { _recTimer ??= new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Background, (_, _) => UpdateRecElapsed()); @@ -1513,6 +1522,7 @@ public async ValueTask DisposeAsync() _detectionsSub?.Dispose(); DetachAnalyticsFallback(); _recordings.StateChanged -= OnRecordingsStateChanged; + _recordings.RecordingFailed -= OnRecordingFailed; _userSettings.Changed -= OnUserSettingsChanged; _coordinator.Invalidated -= OnCoordinatorInvalidated; _audio.Changed -= OnAudioChanged; diff --git a/src/OpenIPC.Viewer.Core/Recording/RecordingEvent.cs b/src/OpenIPC.Viewer.Core/Recording/RecordingEvent.cs index ba7ed89..7087b5f 100644 --- a/src/OpenIPC.Viewer.Core/Recording/RecordingEvent.cs +++ b/src/OpenIPC.Viewer.Core/Recording/RecordingEvent.cs @@ -6,7 +6,8 @@ public abstract record RecordingEvent { public sealed record Started(string FirstSegmentPath, DateTime Time) : RecordingEvent; public sealed record SegmentRotated(string PrevPath, string NewPath, DateTime Time, long? Size) : RecordingEvent; - public sealed record Stopped(DateTime Time, RecordingStopReason Reason) : RecordingEvent; + // Detail: the recorder's own reason for a stop it wasn't asked for. + public sealed record Stopped(DateTime Time, RecordingStopReason Reason, string? Detail = null) : RecordingEvent; public sealed record Error(string Message) : RecordingEvent; } diff --git a/src/OpenIPC.Viewer.Core/Recording/RecordingService.cs b/src/OpenIPC.Viewer.Core/Recording/RecordingService.cs index b71faa3..2eeb093 100644 --- a/src/OpenIPC.Viewer.Core/Recording/RecordingService.cs +++ b/src/OpenIPC.Viewer.Core/Recording/RecordingService.cs @@ -23,6 +23,7 @@ public sealed class RecordingService : IAsyncDisposable private readonly object _gate = new(); private readonly Dictionary _active = new(); + private readonly Dictionary _lastError = new(); public event EventHandler? StateChanged; @@ -30,6 +31,10 @@ public sealed class RecordingService : IAsyncDisposable // attaches logging here — Core stays package-dep free. public event EventHandler? BookkeepingFailed; + // A session that ended without the user stopping it (camera unreachable, + // stream dropped, disk refused the file). The message is the recorder's own. + public event EventHandler? RecordingFailed; + public RecordingService( IRecorder recorder, IRecordingRepository repo, @@ -93,7 +98,7 @@ public async Task StartAsync(CameraId id, CancellationToken ct) var session = await _recorder.StartAsync(options, ct).ConfigureAwait(false); // Adapter exists because Core has no System.Reactive (no package deps); // BCL only exposes IObservable.Subscribe(IObserver). - var sub = session.Events.Subscribe(new CallbackObserver(ev => _ = HandleEventAsync(id, ev))); + var sub = session.Events.Subscribe(new CallbackObserver(ev => _ = HandleEventAsync(id, session, ev))); lock (_gate) _active[id] = new Handle(session, sub); StateChanged?.Invoke(this, id); } @@ -120,7 +125,7 @@ public async Task StopAsync(CameraId id, CancellationToken ct) StateChanged?.Invoke(this, id); } - private async Task HandleEventAsync(CameraId id, RecordingEvent ev) + private async Task HandleEventAsync(CameraId id, IRecordingSession session, RecordingEvent ev) { try { @@ -136,8 +141,13 @@ private async Task HandleEventAsync(CameraId id, RecordingEvent ev) break; case RecordingEvent.Error err: + lock (_gate) _lastError[id] = err.Message; BookkeepingFailed?.Invoke(this, new InvalidOperationException($"Recorder error: {err.Message}")); break; + + case RecordingEvent.Stopped st when st.Reason != RecordingStopReason.User: + await OnSessionEndedAsync(id, session, st).ConfigureAwait(false); + break; } } catch (Exception ex) @@ -146,6 +156,38 @@ private async Task HandleEventAsync(CameraId id, RecordingEvent ev) } } + // The recorder gave up on its own. Without this the camera stayed "recording" + // with a ticking REC timer and no file behind it, the last row never got + // EndedAt, and a fresh Start was refused as "already recording". + private async Task OnSessionEndedAsync(CameraId id, IRecordingSession session, RecordingEvent.Stopped st) + { + // Leave the recorder's OnNext before disposing the session completes + // its event stream. + await Task.Yield(); + + Handle? h; + string? message; + lock (_gate) + { + // StopAsync removes the handle before stopping, so a missing or different + // handle means this session is already being torn down elsewhere. + if (!_active.TryGetValue(id, out h) || !ReferenceEquals(h.Session, session)) return; + _active.Remove(id); + _lastError.TryGetValue(id, out message); + _lastError.Remove(id); + } + + var lastPath = session.CurrentSegmentPath; + if (lastPath is not null) + await FinalizeSegmentAsync(lastPath, st.Time).ConfigureAwait(false); + + h.Subscription.Dispose(); + try { await session.DisposeAsync().ConfigureAwait(false); } catch { /* swallow */ } + StateChanged?.Invoke(this, id); + RecordingFailed?.Invoke(this, new RecordingFailure( + id, message ?? st.Detail ?? st.Reason.ToString(), WroteSegment: lastPath is not null)); + } + private async Task FinalizeSegmentAsync(string path, DateTime endedAt, long? size = null) { try @@ -197,6 +239,11 @@ public async ValueTask DisposeAsync() } } + // WroteSegment is false when the recorder never got as far as opening a + // file — the camera refused or never answered, so nothing reached the + // Recordings tab. + public sealed record RecordingFailure(CameraId CameraId, string Message, bool WroteSegment = true); + private sealed record Handle(IRecordingSession Session, IDisposable Subscription); private sealed class CallbackObserver : IObserver diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegError.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegError.cs index d8c97a8..2856e89 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegError.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegError.cs @@ -1,18 +1,58 @@ using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; using FFmpeg.AutoGen.Abstractions; namespace OpenIPC.Viewer.Video.Pipeline; -internal static class FfmpegError +internal static partial class FfmpegError { + // FFmpeg on Windows maps socket failures to the MSVC CRT's POSIX errno + // values (ETIMEDOUT = 138, ...) or passes WSA codes through, and the CRT's + // strerror knows neither — av_strerror then prints "Error number -138 + // occurred". Name the ones a camera connection actually hits. + private static readonly Dictionary WindowsSocketErrors = new() + { + [100] = "Address already in use", + [106] = "Connection aborted", + [107] = "Connection refused", + [108] = "Connection reset by peer", + [110] = "Host is unreachable", + [116] = "Network is down", + [118] = "Network is unreachable", + [138] = "Connection timed out", + [10053] = "Connection aborted", + [10054] = "Connection reset by peer", + [10060] = "Connection timed out", + [10061] = "Connection refused", + [10051] = "Network is unreachable", + [10065] = "Host is unreachable", + }; + + [GeneratedRegex(@"Error number -(\d+) occurred")] + private static partial Regex UnknownErrnoRegex(); + public static unsafe string Describe(int code) { const int bufSize = 1024; var buffer = stackalloc byte[bufSize]; ffmpeg.av_strerror(code, buffer, (ulong)bufSize); - return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"FFmpeg error {code}"; + var text = System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"FFmpeg error {code}"; + return Humanize(text); } + // Swaps FFmpeg's "Error number -N occurred" for the socket error's name + // when N is one we know; anything else passes through untouched. + public static string Humanize(string text) => + text.Contains("Error number -", StringComparison.Ordinal) + ? UnknownErrnoRegex().Replace(text, m => + int.TryParse(m.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n) + && WindowsSocketErrors.TryGetValue(n, out var name) + ? name + : m.Value) + : text; + public static void ThrowIfError(int code, string operation) { if (code < 0) diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs index 9d381cb..04b903d 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs @@ -20,15 +20,29 @@ internal static class FfmpegRuntime // FfmpegException ctor on that same thread consumes it. [ThreadStatic] private static string? _lastNativeError; + // Fallback when nothing logged at error level: the RTSP demuxer reports a + // stalled handshake only as a warning ("Failed reading RTSP data: ...") + // and then returns a bare "Invalid data found" (#90). + [ThreadStatic] private static string? _lastNativeWarning; + // Held so the GC can't collect the delegate while native code holds it. private static av_log_set_callback_callback? _logCallback; internal static string? TakeLastNativeError() { - var v = _lastNativeError; + var v = _lastNativeError ?? _lastNativeWarning; _lastNativeError = null; + _lastNativeWarning = null; return v; } + + // Drops whatever an earlier call on this thread left behind, so the next + // failure isn't explained by a stale line. + internal static void ClearLastNativeError() + { + _lastNativeError = null; + _lastNativeWarning = null; + } // Serializes the one-time native init. The old design used an Interlocked // CompareExchange flag, but that let the *second* caller return "ready" // the instant the first caller flipped the flag — before its (slow) @@ -135,8 +149,10 @@ private static unsafe void LogCallback(void* avcl, int level, string fmt, byte* ffmpeg.av_log_format_line2(avcl, level, fmt, vl, buf, bufSize, &printPrefix); var msg = Marshal.PtrToStringAnsi((IntPtr)buf)?.Trim(); if (string.IsNullOrEmpty(msg)) return; + msg = FfmpegError.Humanize(msg); if (level <= ffmpeg.AV_LOG_ERROR) _lastNativeError = msg; + else _lastNativeWarning = msg; // A logging sink must never throw back into native FFmpeg. try { NativeLog?.Invoke(level, msg); } diff --git a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs index 29afec8..9450d1d 100644 --- a/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs +++ b/src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs @@ -216,6 +216,7 @@ private unsafe void Run() var url = BuildUrlWithCredentials(_options.RtspUri, _options.Credentials); ArmIoDeadline(ConnectBudgetMs); + FfmpegRuntime.ClearLastNativeError(); var ret = ffmpeg.avformat_open_input(&fmtCtx, url, null, &opts); ThrowIfIoError(ret, "avformat_open_input", ConnectBudgetMs); diff --git a/src/OpenIPC.Viewer.Video/Recording/FfmpegRecordingSession.cs b/src/OpenIPC.Viewer.Video/Recording/FfmpegRecordingSession.cs index 53ce481..368f52c 100644 --- a/src/OpenIPC.Viewer.Video/Recording/FfmpegRecordingSession.cs +++ b/src/OpenIPC.Viewer.Video/Recording/FfmpegRecordingSession.cs @@ -20,7 +20,13 @@ internal sealed partial class FfmpegRecordingSession : IRecordingSession [GeneratedRegex(@"Opening '([^']+)' for writing")] private static partial Regex OpeningRegex(); + // Last stderr line that reads like a failure, kept so an unexpected exit + // can say why ("401 Unauthorized", "Connection timed out", ...). + [GeneratedRegex(@"error|failed|refused|timed out|invalid|unauthorized", RegexOptions.IgnoreCase)] + private static partial Regex FailureRegex(); + private static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(5); + private const string RtspTimeoutMicros = "10000000"; private readonly RecordingOptions _options; private readonly string _ffmpegPath; @@ -28,9 +34,11 @@ internal sealed partial class FfmpegRecordingSession : IRecordingSession private readonly Subject _events = new(); private Process? _proc; + private Task _stderrPump = Task.CompletedTask; private string? _currentSegment; private string? _lastSegment; - private bool _stopRequested; + private volatile bool _stopRequested; + private volatile string? _lastFailureLine; public DateTime StartedAt { get; } = DateTime.UtcNow; public string? CurrentSegmentPath => _currentSegment; @@ -77,6 +85,11 @@ public void Start() // no DB row is written and the recording never shows in the list. psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("verbose"); psi.ArgumentList.Add("-rtsp_transport"); psi.ArgumentList.Add("tcp"); + // RTSP socket I/O timeout (µs; FFmpeg 7 renamed stimeout → timeout). + // Without it a camera that accepts the TCP connect but never answers + // DESCRIBE parks ffmpeg in open forever: no segment is ever opened, so + // no row reaches the Recordings tab while REC keeps "running" (#91). + psi.ArgumentList.Add("-timeout"); psi.ArgumentList.Add(RtspTimeoutMicros); psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(BuildRtspUri(_options.RtspUri, _options.Credentials).ToString()); psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("copy"); psi.ArgumentList.Add("-f"); psi.ArgumentList.Add("segment"); @@ -94,7 +107,7 @@ public void Start() _proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to launch ffmpeg (check PATH)"); - _ = Task.Run(ReadStderrAsync); + _stderrPump = Task.Run(ReadStderrAsync); _ = Task.Run(WatchExitAsync); } @@ -150,9 +163,14 @@ private async Task ReadStderrAsync() { var m = OpeningRegex().Match(line); if (m.Success) + { HandleSegmentOpened(m.Groups[1].Value); + } else + { + if (FailureRegex().IsMatch(line)) _lastFailureLine = Pipeline.FfmpegError.Humanize(line); _logger.LogDebug("ffmpeg[{Cam}]: {Line}", _options.CameraId, line); + } } } catch (Exception ex) @@ -166,8 +184,21 @@ private async Task WatchExitAsync() if (_proc is null) return; try { await _proc.WaitForExitAsync().ConfigureAwait(false); } catch { /* ignore */ } - if (!_stopRequested) - EmitStopped(RecordingStopReason.ProcessExited); + if (_stopRequested) return; + // stderr hits EOF with the process; let the reader take ffmpeg's last + // words so the reason below is the actual one. + try { await _stderrPump.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); } + catch { /* reason stays whatever we have */ } + var code = TryExitCode(); + _logger.LogWarning("ffmpeg recorder for {Cam} exited unexpectedly (code {Code}): {Reason}", + _options.CameraId, code, _lastFailureLine ?? "no error output"); + EmitStopped(RecordingStopReason.ProcessExited, _lastFailureLine); + } + + private int? TryExitCode() + { + try { return _proc?.ExitCode; } + catch { return null; } } private void HandleSegmentOpened(string path) @@ -187,9 +218,9 @@ private void HandleSegmentOpened(string path) } } - private void EmitStopped(RecordingStopReason reason) + private void EmitStopped(RecordingStopReason reason, string? detail = null) { - try { _events.OnNext(new RecordingEvent.Stopped(DateTime.UtcNow, reason)); } + try { _events.OnNext(new RecordingEvent.Stopped(DateTime.UtcNow, reason, detail)); } catch { /* observers may already be unsubscribed */ } } diff --git a/src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs b/src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs index 7fb1b11..4445785 100644 --- a/src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs +++ b/src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs @@ -126,6 +126,7 @@ private unsafe void Run() int[] streamMap = System.Array.Empty(); var ct = _cts.Token; var stopReason = RecordingStopReason.User; + string? stopDetail = null; try { @@ -216,7 +217,8 @@ private unsafe void Run() stopReason = RecordingStopReason.ProcessExited; break; } - _logger.LogWarning("av_read_frame failed: {Err}", FfmpegError.Describe(ret)); + stopDetail = FfmpegError.Describe(ret); + _logger.LogWarning("av_read_frame failed: {Err}", stopDetail); stopReason = RecordingStopReason.Error; break; } @@ -256,6 +258,7 @@ private unsafe void Run() _logger.LogError(ex, "Recording loop failed"); try { _events.OnNext(new RecordingEvent.Error(ex.Message)); } catch { } stopReason = RecordingStopReason.Error; + stopDetail = ex.Message; } finally { @@ -273,7 +276,7 @@ private unsafe void Run() if (inputOpts != null) ffmpeg.av_dict_free(&inputOpts); if (outputOpts != null) ffmpeg.av_dict_free(&outputOpts); - try { _events.OnNext(new RecordingEvent.Stopped(DateTime.UtcNow, stopReason)); } catch { } + try { _events.OnNext(new RecordingEvent.Stopped(DateTime.UtcNow, stopReason, stopDetail)); } catch { } _stoppedTcs.TrySetResult(); } } diff --git a/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs index cdc47bf..78895d0 100644 --- a/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs +++ b/src/OpenIPC.Viewer.Web/Api/LiveFfmpeg.cs @@ -21,7 +21,9 @@ public static Process Start(string rtspUrl, bool transcode) CreateNoWindow = true, }; - var args = new List { "-rtsp_transport", "tcp", "-fflags", "nobuffer", "-i", rtspUrl }; + // -timeout: RTSP socket I/O timeout (µs) so a silent camera ends the + // process instead of holding a connection open forever. + var args = new List { "-rtsp_transport", "tcp", "-timeout", "10000000", "-fflags", "nobuffer", "-i", rtspUrl }; // Audio rides along in the same stream instead of getting its own ffmpeg // session: AAC at 64 kbps is around a percent of the video bitrate, so diff --git a/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs index 17e708c..f7549d9 100644 --- a/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/SnapshotApi.cs @@ -120,7 +120,7 @@ public static void MapSnapshotEndpoints(this WebApplication app) }; foreach (var arg in new[] { - "-rtsp_transport", "tcp", "-i", url, + "-rtsp_transport", "tcp", "-timeout", "10000000", "-i", url, "-frames:v", "1", "-q:v", "3", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", }) diff --git a/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs b/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs index 78febfb..ef57826 100644 --- a/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs +++ b/src/OpenIPC.Viewer.Web/Api/WebRecorder.cs @@ -93,7 +93,11 @@ public WebRecorder(IRecordingRepository repo, IFileSystem fs, ILogger repo.Rows.Count == 1); + + var failed = WaitForFailure(service); + session.Emit(new RecordingEvent.Stopped(DateTime.UtcNow, RecordingStopReason.Error)); + var e = await failed; + + Assert.True(e.WroteSegment); + Assert.False(service.IsRecording(id)); + Assert.NotNull(Assert.Single(repo.Rows).EndedAt); + } + + [Fact] + public async Task UserStop_DoesNotRaiseRecordingFailed() + { + var (service, recorder, _, id) = await CreateAsync(); + await service.StartAsync(id, CancellationToken.None); + + var raised = false; + service.RecordingFailed += (_, _) => raised = true; + await service.StopAsync(id, CancellationToken.None); + await Task.Delay(50); + + Assert.False(raised); + Assert.False(service.IsRecording(id)); + Assert.True(recorder.Last!.Disposed); + } + + private async Task<(RecordingService, FakeRecorder, FakeRecordingRepository, CameraId)> CreateAsync() + { + var cameras = new CameraDirectoryService(new InMemoryCameraRepository(), new InMemoryGroupRepository(), new InMemorySecretsStore()); + var id = await cameras.AddAsync(new NewCameraRequest( + Name: "Yard", + Host: "192.168.1.10", + HttpPort: 80, + OnvifPort: null, + RtspMainUri: new Uri("rtsp://192.168.1.10/"), + RtspSubUri: null, + Credentials: null), CancellationToken.None); + var recorder = new FakeRecorder(); + var repo = new FakeRecordingRepository(); + var service = new RecordingService(recorder, repo, new TempFileSystem(_root), cameras); + return (service, recorder, repo, id); + } + + private static Task WaitForFailure(RecordingService service) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + service.RecordingFailed += (_, e) => tcs.TrySetResult(e); + return tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException(); + await Task.Delay(10); + } + } + + private sealed class TempFileSystem : IFileSystem + { + public TempFileSystem(DirectoryInfo root) => AppDataDir = RecordingsDir = SnapshotsDir = root; + public DirectoryInfo AppDataDir { get; } + public DirectoryInfo RecordingsDir { get; } + public DirectoryInfo SnapshotsDir { get; } + } + + private sealed class FakeRecorder : IRecorder + { + public FakeSession? Last { get; private set; } + + public Task StartAsync(RecordingOptions options, CancellationToken ct) + { + Last = new FakeSession(); + return Task.FromResult(Last); + } + } + + private sealed class FakeSession : IRecordingSession, IObservable + { + private readonly List> _observers = new(); + + public DateTime StartedAt { get; } = DateTime.UtcNow; + public string? CurrentSegmentPath { get; set; } + public IObservable Events => this; + public bool Disposed { get; private set; } + + public void Emit(RecordingEvent ev) + { + foreach (var o in _observers.ToArray()) o.OnNext(ev); + } + + public Task StopAsync(CancellationToken ct) + { + Emit(new RecordingEvent.Stopped(DateTime.UtcNow, RecordingStopReason.User)); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + + public IDisposable Subscribe(IObserver observer) + { + _observers.Add(observer); + return new Unsubscriber(() => _observers.Remove(observer)); + } + + private sealed class Unsubscriber(Action dispose) : IDisposable + { + public void Dispose() => dispose(); + } + } + + private sealed class FakeRecordingRepository : IRecordingRepository + { + private readonly object _gate = new(); + private readonly List _rows = new(); + + public IReadOnlyList Rows + { + get { lock (_gate) return _rows.ToList(); } + } + + public Task> ListAsync(CameraId? cameraId, CancellationToken ct) => + Task.FromResult(Rows); + + public Task AddAsync(Core.Recording.Recording recording, CancellationToken ct) + { + lock (_gate) _rows.Add(recording); + return Task.CompletedTask; + } + + public Task UpdateAsync(Core.Recording.Recording recording, CancellationToken ct) + { + lock (_gate) + { + var i = _rows.FindIndex(r => r.Id == recording.Id); + if (i >= 0) _rows[i] = recording; + } + return Task.CompletedTask; + } + + public Task GetByPathAsync(string filePath, CancellationToken ct) + { + lock (_gate) return Task.FromResult(_rows.FirstOrDefault(r => r.FilePath == filePath)); + } + + public Task RemoveAsync(RecordingId id, CancellationToken ct) + { + lock (_gate) _rows.RemoveAll(r => r.Id == id); + return Task.CompletedTask; + } + } +} From 449a9c48463da91055dc87210a1901126c6f13a9 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 18 Oct 2026 00:12:56 +0300 Subject: [PATCH 2/3] fix(deps): bump System.Security.Cryptography.Xml to 10.0.13 - 10.0.0 pulls System.Security.Cryptography.Pkcs 10.0.0 with GHSA-2fmv-rv2c-59x7 (published 2026-10-13); NuGetAudit + warnings-as-errors fail restore on every head --- Directory.Packages.props | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9b029b6..4fce88a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,8 +36,10 @@ - + hasn't shipped a patched servicing yet, so pin to the 10.0 line. + 10.0.13: 10.0.0–10.0.12 drag in System.Security.Cryptography.Pkcs with + GHSA-2fmv-rv2c-59x7 (CVE-2026-70162), which fails restore. --> +