Запись: startService Jibri ждёт до 60 с; запись, которую Jibri уже пишет для этого звонка (каталог /recordings/<id>), подхватывается, даже если startService не ответил
Случай 2026-10-15 (звонок c058d2a24157f99bc4f19): startService ответил позже 5 с, Jibri звонок записал, а монитор закрыл его как skipped — запись осталась на диске без обработки.
This commit is contained in:
@@ -433,4 +433,63 @@ public sealed class MonitorTests : IDisposable
|
|||||||
Assert.Contains(ended.Participants, p => p.Name == "Анна" && !p.Guest);
|
Assert.Contains(ended.Participants, p => p.Name == "Анна" && !p.Guest);
|
||||||
Assert.True(gate.CanEnter("ws-other"));
|
Assert.True(gate.CanEnter("ws-other"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Случай 2026-10-15: startService ответил позже таймаута клиента, а Jibri запрос принял и писал звонок.
|
||||||
|
/// Раньше монитор считал, что записи нет, и закрывал звонок как skipped — запись оставалась на диске.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)] // каталог уже есть к моменту ошибки startService — запись подхватывается сразу
|
||||||
|
[InlineData(false)] // каталог появляется позже — подхватывается на следующем тике, хотя Jibri BUSY
|
||||||
|
public async Task StartTimesOut_ButJibriRecordsThisCall_RecordingAdopted(bool dirBeforeTimeout)
|
||||||
|
{
|
||||||
|
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Storage:DataDir"] = _dir, ["Jitsi:AppSecret"] = StoreTests.Secret,
|
||||||
|
["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789",
|
||||||
|
}).Build();
|
||||||
|
var db = new Db(cfg); db.Init();
|
||||||
|
var recDir = Path.Combine(_dir, "recordings");
|
||||||
|
var opt = new RecordingOptions { EmptyGrace = TimeSpan.Zero, RecorderPassword = "rp", RecordingsDir = recDir };
|
||||||
|
var gate = new CallGate(opt);
|
||||||
|
var store = new Store(db, new JitsiToken(cfg), cfg, gate: gate);
|
||||||
|
var calls = new CallsRepo(db);
|
||||||
|
var http = new FakeHttp((req, body) =>
|
||||||
|
{
|
||||||
|
var p = req.RequestUri!.AbsolutePath;
|
||||||
|
if (p == "/room") return FakeHttp.Json(Occupants.Select(n => new { jid = "x", email = "", display_name = n }).ToArray());
|
||||||
|
if (p.EndsWith("/health")) return FakeHttp.Json(new { status = new { busyStatus = JibriState } });
|
||||||
|
if (p.EndsWith("/startService"))
|
||||||
|
{
|
||||||
|
JibriState = "BUSY";
|
||||||
|
var session = JsonDocument.Parse(body).RootElement.GetProperty("sessionId").GetString()!;
|
||||||
|
if (dirBeforeTimeout) Directory.CreateDirectory(Path.Combine(recDir, session));
|
||||||
|
throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout");
|
||||||
|
}
|
||||||
|
if (p.EndsWith("/stopService")) { JibriState = "IDLE"; return FakeHttp.Json("{}"); }
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||||
|
});
|
||||||
|
var mon = new CallMonitor(new ProsodyRooms(http, opt), new JibriClient(http, opt), gate, calls, store, opt, NullLogger<CallMonitor>.Instance);
|
||||||
|
|
||||||
|
var link = store.GetGuestLink("wsteam0000001", new CrmUser("u1", "a@t", "Анна", 0));
|
||||||
|
var gs = store.JoinAsGuest(link.Token, "Іван")!;
|
||||||
|
store.IssuePass(gs.Token);
|
||||||
|
|
||||||
|
Occupants = ["Іван", "Анна"];
|
||||||
|
await mon.TickAsync(default);
|
||||||
|
var call = Assert.Single(calls.AllOpen());
|
||||||
|
if (!dirBeforeTimeout)
|
||||||
|
{
|
||||||
|
Assert.Equal("active", call.Status);
|
||||||
|
Directory.CreateDirectory(Path.Combine(recDir, call.Id)); // Jibri запустил Chrome и создал каталог
|
||||||
|
await mon.TickAsync(default);
|
||||||
|
}
|
||||||
|
Assert.Equal("recording", calls.Get(call.Id)!.Status);
|
||||||
|
Assert.Single(http.Log, l => l.Url.EndsWith("/startService")); // второй запуск не просили
|
||||||
|
|
||||||
|
Occupants = [];
|
||||||
|
await mon.TickAsync(default);
|
||||||
|
Assert.Contains(http.Log, l => l.Url.EndsWith("/stopService"));
|
||||||
|
Assert.Equal("ended", calls.Get(call.Id)!.Status); // уходит в обработку, а не skipped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ builder.Services.AddSingleton<CrmSession>();
|
|||||||
builder.Services.AddSingleton<AudioStorage>();
|
builder.Services.AddSingleton<AudioStorage>();
|
||||||
builder.Services.AddSingleton<WikiPublisher>();
|
builder.Services.AddSingleton<WikiPublisher>();
|
||||||
builder.Services.AddHttpClient("jitsi", c => c.Timeout = TimeSpan.FromSeconds(5));
|
builder.Services.AddHttpClient("jitsi", c => c.Timeout = TimeSpan.FromSeconds(5));
|
||||||
builder.Services.AddHttpClient("jibri-stop", c => c.Timeout = TimeSpan.FromSeconds(60));
|
// startService/stopService ждут, пока Jibri запустит или закроет Chrome и ffmpeg — дольше обычного таймаута.
|
||||||
|
builder.Services.AddHttpClient("jibri", c => c.Timeout = JibriClient.ServiceTimeout);
|
||||||
builder.Services.AddHttpClient("openai", c => c.Timeout = TimeSpan.FromMinutes(10));
|
builder.Services.AddHttpClient("openai", c => c.Timeout = TimeSpan.FromMinutes(10));
|
||||||
builder.Services.AddHttpClient("storage", c => c.Timeout = TimeSpan.FromMinutes(5));
|
builder.Services.AddHttpClient("storage", c => c.Timeout = TimeSpan.FromMinutes(5));
|
||||||
builder.Services.AddHttpClient("wiki", c => c.Timeout = TimeSpan.FromSeconds(30));
|
builder.Services.AddHttpClient("wiki", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
|
|||||||
@@ -82,18 +82,27 @@ public sealed class CallMonitor(
|
|||||||
|
|
||||||
private async Task TryStartAsync(CallRow call, CancellationToken ct)
|
private async Task TryStartAsync(CallRow call, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var session = call.Id;
|
||||||
|
// Прошлый startService не дождался ответа, но Jibri запрос принял и пишет этот звонок.
|
||||||
|
if (jibri.HasSession(session)) { Started(call, session, "Jibri вже пише цей дзвінок"); return; }
|
||||||
var st = await jibri.StatusAsync(ct);
|
var st = await jibri.StatusAsync(ct);
|
||||||
if (st != JibriClient.Busy.Idle)
|
if (st != JibriClient.Busy.Idle)
|
||||||
{
|
{
|
||||||
log.LogWarning("Дзвінок {C}: Jibri {S}, запис поки не почато", call.Id, st);
|
log.LogWarning("Дзвінок {C}: Jibri {S}, запис поки не почато", call.Id, st);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var session = call.Id;
|
|
||||||
var (ok, detail) = await jibri.StartAsync(session, call.Room, ct);
|
var (ok, detail) = await jibri.StartAsync(session, call.Room, ct);
|
||||||
if (!ok) { log.LogError("Дзвінок {C}: Jibri не почав запис: {D}", call.Id, detail); return; }
|
if (ok) { Started(call, session, "запис почато"); return; }
|
||||||
|
if (jibri.HasSession(session)) { Started(call, session, $"Jibri пише цей дзвінок, хоча startService не відповів ({detail})"); return; }
|
||||||
|
// Каталога ещё может не быть (Jibri запускает Chrome) — следующий тик увидит его и подхватит запись.
|
||||||
|
log.LogError("Дзвінок {C}: Jibri не почав запис: {D}", call.Id, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Started(CallRow call, string session, string what)
|
||||||
|
{
|
||||||
calls.RecordingStarted(call.Id, session);
|
calls.RecordingStarted(call.Id, session);
|
||||||
_jibriIdleTicks = 0;
|
_jibriIdleTicks = 0;
|
||||||
log.LogInformation("Дзвінок {C}: запис почато (Jibri, сесія {S}); ресурси: {H}", call.Id, session, _host.Sample());
|
log.LogInformation("Дзвінок {C}: {W} (Jibri, сесія {S}); ресурси: {H}", call.Id, what, session, _host.Sample());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Jibri сам перестал писать (сбой) при живом звонке: закрываем этот кусок, следующий тик начнёт новый.</summary>
|
/// <summary>Jibri сам перестал писать (сбой) при живом звонке: закрываем этот кусок, следующий тик начнёт новый.</summary>
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
|
|||||||
{
|
{
|
||||||
public enum Busy { Idle, Busy, Unknown }
|
public enum Busy { Idle, Busy, Unknown }
|
||||||
|
|
||||||
|
/// <summary>startService отвечает только после запуска Chrome (под нагрузкой — дольше 5 с), stopService — после закрытия ffmpeg.</summary>
|
||||||
|
public static readonly TimeSpan ServiceTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jibri принял запись этой сессии: он создаёт каталог RECORDINGS_DIR/<sessionId> (общий каталог хоста).
|
||||||
|
/// Так видно, что Jibri пишет именно этот звонок, даже если ответ на startService не дошёл.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasSession(string sessionId) => Directory.Exists(Path.Combine(opt.RecordingsDir, sessionId));
|
||||||
|
|
||||||
public async Task<Busy> StatusAsync(CancellationToken ct)
|
public async Task<Busy> StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -67,7 +76,9 @@ public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
|
|||||||
};
|
};
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var r = await http.CreateClient("jitsi").PostAsJsonAsync($"{opt.JibriUrl}/jibri/api/v1.0/startService", body, ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(ServiceTimeout);
|
||||||
|
using var r = await http.CreateClient("jibri").PostAsJsonAsync($"{opt.JibriUrl}/jibri/api/v1.0/startService", body, cts.Token);
|
||||||
return (r.IsSuccessStatusCode, $"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync(ct)}".Trim());
|
return (r.IsSuccessStatusCode, $"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync(ct)}".Trim());
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (false, e.Message); }
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (false, e.Message); }
|
||||||
@@ -79,8 +90,8 @@ public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
|
|||||||
{
|
{
|
||||||
// stopService ждёт, пока Jibri закроет ffmpeg и Chrome — это дольше обычного таймаута клиента.
|
// stopService ждёт, пока Jibri закроет ffmpeg и Chrome — это дольше обычного таймаута клиента.
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(60));
|
cts.CancelAfter(ServiceTimeout);
|
||||||
using var r = await http.CreateClient("jibri-stop").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, cts.Token);
|
using var r = await http.CreateClient("jibri").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, cts.Token);
|
||||||
return r.IsSuccessStatusCode;
|
return r.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }
|
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }
|
||||||
|
|||||||
Reference in New Issue
Block a user