Запись: startService Jibri ждёт до 60 с; запись, которую Jibri уже пишет для этого звонка (каталог /recordings/<id>), подхватывается, даже если startService не ответил

Случай 2026-10-15 (звонок c058d2a24157f99bc4f19): startService ответил позже 5 с, Jibri звонок записал,
а монитор закрыл его как skipped — запись осталась на диске без обработки.
This commit is contained in:
crm-calls agent
2026-10-15 09:03:49 +00:00
parent 49fba12653
commit e49f5daabf
4 changed files with 87 additions and 7 deletions

View File

@@ -433,4 +433,63 @@ public sealed class MonitorTests : IDisposable
Assert.Contains(ended.Participants, p => p.Name == "Анна" && !p.Guest);
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
}
}

View File

@@ -20,7 +20,8 @@ builder.Services.AddSingleton<CrmSession>();
builder.Services.AddSingleton<AudioStorage>();
builder.Services.AddSingleton<WikiPublisher>();
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("storage", c => c.Timeout = TimeSpan.FromMinutes(5));
builder.Services.AddHttpClient("wiki", c => c.Timeout = TimeSpan.FromSeconds(30));

View File

@@ -82,18 +82,27 @@ public sealed class CallMonitor(
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);
if (st != JibriClient.Busy.Idle)
{
log.LogWarning("Дзвінок {C}: Jibri {S}, запис поки не почато", call.Id, st);
return;
}
var session = call.Id;
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);
_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>

View File

@@ -41,6 +41,15 @@ public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
{
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/&lt;sessionId&gt; (общий каталог хоста).
/// Так видно, что Jibri пишет именно этот звонок, даже если ответ на startService не дошёл.
/// </summary>
public bool HasSession(string sessionId) => Directory.Exists(Path.Combine(opt.RecordingsDir, sessionId));
public async Task<Busy> StatusAsync(CancellationToken ct)
{
try
@@ -67,7 +76,9 @@ public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
};
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());
}
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 — это дольше обычного таймаута клиента.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(60));
using var r = await http.CreateClient("jibri-stop").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, cts.Token);
cts.CancelAfter(ServiceTimeout);
using var r = await http.CreateClient("jibri").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, cts.Token);
return r.IsSuccessStatusCode;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }