Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
<!-- Pin: transitive pull (Onvif.Core → System.ServiceModel) lands 9.0.1
which carries GHSA-37gx-xxp4-5rgx + GHSA-w3x6-4m5h-cxqf; 9.0.x line
hasn't shipped a patched servicing yet, so pin to the 10.0.0 release. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.0" />
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. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />

<!-- Split package pair from 7.1.1: Abstractions defines the ffmpeg facade
(no broken cctor — see PR #344), DynamicallyLoaded provides the loader
Expand Down
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {0}",
["CameraPage.RecordingInterruptedFormat"] = "Recording stopped unexpectedly: {0}",
["CameraPage.SnapshotFailedFormat"] = "Snapshot failed: {0}",
["CameraPage.Tooltip.Listen"] = "Listen to camera audio",
["CameraPage.Tooltip.Mute"] = "Mute audio",
Expand Down Expand Up @@ -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"] = "Выключить звук",
Expand Down
10 changes: 10 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}

private void StartRecTimer()
{
_recTimer ??= new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Background, (_, _) => UpdateRecElapsed());
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/OpenIPC.Viewer.Core/Recording/RecordingEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
94 changes: 86 additions & 8 deletions src/OpenIPC.Viewer.Core/Recording/RecordingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public sealed class RecordingService : IAsyncDisposable
// attaches logging here — Core stays package-dep free.
public event EventHandler<Exception>? 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<RecordingFailure>? RecordingFailed;

public RecordingService(
IRecorder recorder,
IRecordingRepository repo,
Expand Down Expand Up @@ -91,10 +95,14 @@ public async Task StartAsync(CameraId id, CancellationToken ct)
SegmentDuration: TimeSpan.FromMinutes(10));

var session = await _recorder.StartAsync(options, ct).ConfigureAwait(false);
// Register before subscribing: a recorder that dies at once (camera
// refused) must find its handle, or the ended session would stay active.
// Sessions replay what they published before this subscription.
var h = new Handle(session);
lock (_gate) _active[id] = h;
// Adapter exists because Core has no System.Reactive (no package deps);
// BCL only exposes IObservable.Subscribe(IObserver<T>).
var sub = session.Events.Subscribe(new CallbackObserver(ev => _ = HandleEventAsync(id, ev)));
lock (_gate) _active[id] = new Handle(session, sub);
h.Subscription = session.Events.Subscribe(new CallbackObserver(ev => h.Enqueue(() => HandleEventAsync(id, h, ev))));
StateChanged?.Invoke(this, id);
}

Expand All @@ -111,33 +119,43 @@ public async Task StopAsync(CameraId id, CancellationToken ct)
try { await h.Session.StopAsync(ct).ConfigureAwait(false); }
catch (Exception ex) { BookkeepingFailed?.Invoke(this, ex); }

// Finalize last segment row with EndedAt + final size.
if (h.Session.CurrentSegmentPath is { } lastPath)
// Let a still-pending Started/Rotated insert land first, then finalize
// the last segment row with EndedAt + final size.
await h.DrainAsync().ConfigureAwait(false);
if (h.LastSegmentPath is { } lastPath)
await FinalizeSegmentAsync(lastPath, DateTime.UtcNow).ConfigureAwait(false);

h.Subscription.Dispose();
h.Subscription?.Dispose();
try { await h.Session.DisposeAsync().ConfigureAwait(false); } catch { /* swallow */ }
StateChanged?.Invoke(this, id);
}

private async Task HandleEventAsync(CameraId id, RecordingEvent ev)
// Runs on the handle's queue: one event at a time, in publication order.
private async Task HandleEventAsync(CameraId id, Handle h, RecordingEvent ev)
{
try
{
switch (ev)
{
case RecordingEvent.Started s:
await _repo.AddAsync(NewRow(id, s.FirstSegmentPath, s.Time), CancellationToken.None).ConfigureAwait(false);
h.LastSegmentPath = s.FirstSegmentPath;
break;

case RecordingEvent.SegmentRotated r:
await FinalizeSegmentAsync(r.PrevPath, r.Time, r.Size).ConfigureAwait(false);
await _repo.AddAsync(NewRow(id, r.NewPath, r.Time), CancellationToken.None).ConfigureAwait(false);
h.LastSegmentPath = r.NewPath;
break;

case RecordingEvent.Error err:
h.LastError = err.Message;
BookkeepingFailed?.Invoke(this, new InvalidOperationException($"Recorder error: {err.Message}"));
break;

case RecordingEvent.Stopped st when st.Reason != RecordingStopReason.User:
await OnSessionEndedAsync(id, h, st).ConfigureAwait(false);
break;
}
}
catch (Exception ex)
Expand All @@ -146,6 +164,32 @@ 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, Handle h, RecordingEvent.Stopped st)
{
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 var current) || !ReferenceEquals(current, h)) return;
_active.Remove(id);
}

// Events run in order on h's queue, so any row this session inserted is
// already in the repository.
var lastPath = h.LastSegmentPath;
if (lastPath is not null)
await FinalizeSegmentAsync(lastPath, st.Time).ConfigureAwait(false);

h.Subscription?.Dispose();
try { await h.Session.DisposeAsync().ConfigureAwait(false); } catch { /* swallow */ }
StateChanged?.Invoke(this, id);
RecordingFailed?.Invoke(this, new RecordingFailure(
id, h.LastError ?? st.Detail ?? st.Reason.ToString(), WroteSegment: lastPath is not null));
}

private async Task FinalizeSegmentAsync(string path, DateTime endedAt, long? size = null)
{
try
Expand Down Expand Up @@ -192,12 +236,46 @@ public async ValueTask DisposeAsync()
foreach (var h in handles)
{
try { await h.Session.StopAsync(CancellationToken.None).ConfigureAwait(false); } catch { }
h.Subscription.Dispose();
h.Subscription?.Dispose();
try { await h.Session.DisposeAsync().ConfigureAwait(false); } catch { }
}
}

private sealed record Handle(IRecordingSession Session, IDisposable Subscription);
// 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);

// Per-session state. The error and the last inserted segment live here,
// never keyed by camera, so an old session can't explain a newer one's failure.
private sealed class Handle
{
private readonly object _queueGate = new();
private Task _queue = Task.CompletedTask;

public Handle(IRecordingSession session) => Session = session;

public IRecordingSession Session { get; }
public IDisposable? Subscription { get; set; }

// Set only once a row was inserted for it: a recorder may name its
// output file before it has even opened the camera.
public string? LastSegmentPath { get; set; }
public string? LastError { get; set; }

// Chains work behind whatever is queued, off the publisher's thread.
public void Enqueue(Func<Task> work)
{
lock (_queueGate)
_queue = _queue.ContinueWith(_ => work(), CancellationToken.None,
TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
}

public Task DrainAsync()
{
lock (_queueGate) return _queue;
}
}

private sealed class CallbackObserver : IObserver<RecordingEvent>
{
Expand Down
50 changes: 47 additions & 3 deletions src/OpenIPC.Viewer.Video/Pipeline/FfmpegError.cs
Original file line number Diff line number Diff line change
@@ -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<int, string> 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)
Expand All @@ -24,6 +64,10 @@ internal sealed class FfmpegException : Exception
{
public int Code { get; }

// The one call whose warnings explain its failure (#90); every caller
// clears native diagnostics right before it.
internal const string OpenInput = "avformat_open_input";

public FfmpegException(string operation, int code)
: base(Compose(operation, code))
{
Expand All @@ -36,7 +80,7 @@ public FfmpegException(string operation, int code)
private static string Compose(string operation, int code)
{
var msg = $"{operation} failed: {FfmpegError.Describe(code)} ({code})";
var native = FfmpegRuntime.TakeLastNativeError();
var native = FfmpegRuntime.TakeLastNativeError(includeWarning: operation == OpenInput);
return string.IsNullOrEmpty(native) ? msg : $"{msg} — {native}";
}
}
1 change: 1 addition & 0 deletions src/OpenIPC.Viewer.Video/Pipeline/FfmpegMediaProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ private unsafe MediaInfo Probe(string filePath)
AVFormatContext* fmtCtx = null;
try
{
FfmpegRuntime.ClearLastNativeError();
var ret = ffmpeg.avformat_open_input(&fmtCtx, filePath, null, null);
FfmpegError.ThrowIfError(ret, "avformat_open_input");

Expand Down
1 change: 1 addition & 0 deletions src/OpenIPC.Viewer.Video/Pipeline/FfmpegPlaybackSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ private unsafe void Run()
try
{
fmtCtx = ffmpeg.avformat_alloc_context();
FfmpegRuntime.ClearLastNativeError();
var ret = ffmpeg.avformat_open_input(&fmtCtx, _options.FilePath, null, null);
FfmpegError.ThrowIfError(ret, "avformat_open_input");

Expand Down
23 changes: 21 additions & 2 deletions src/OpenIPC.Viewer.Video/Pipeline/FfmpegRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,32 @@ 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()
// includeWarning: only for avformat_open_input, which clears the state
// right before the call — anywhere else a warning from an earlier call
// that succeeded would be pinned on an unrelated failure.
internal static string? TakeLastNativeError(bool includeWarning = false)
{
var v = _lastNativeError;
var v = includeWarning ? _lastNativeError ?? _lastNativeWarning : _lastNativeError;
_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)
Expand Down Expand Up @@ -135,8 +152,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); }
Expand Down
1 change: 1 addition & 0 deletions src/OpenIPC.Viewer.Video/Pipeline/FfmpegVideoSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading