Repository navigation
v3.0.0 — post-modernize-tuning
[3.0.0] — 2026-05-24
Друга велика хвиля модернізації — фокус на correctness/observability/agent-friendly-API. Містить breaking changes, перерахованих нижче.
Реалізація триває; цей розділ оновлюється у міру викочування кластерів. Див. openspec/changes/post-modernize-tuning/.
⚠️ Breaking
FinishLinkResponse.number→FinishLinkResponse.Number(PascalCase property; JSON wire-name збережено через[JsonPropertyName("number")]).SubscribeReceiveResponse.id→SubscribeReceiveResponse.Id(так само PascalCase).BaseSignalEventArgs.Accountтеперstring(non-nullable). Те ж саме поширено на всі 10*EventArgs-records. Раніше булоstring?, що змушувало кожного підписника null-чекати гарантовано-присутнє значення.Config.EnvironmentVariablesіSignalCliOptions.EnvironmentVariablesтеперIReadOnlyDictionary<string,string>на читання. Для мутації —Config.WithEnvironment(IDictionary<string,string>)(defensive copy + fluent return). Раніше можна було.Add(key, value)на shared посилання після DI-capture.JsonRpcException(string, Exception?)ctor з нестандартним кодом -32000 видалено. Замість нього — три CA1032-стандартні ctors:(),(string),(string, Exception)— усі з канонічним JSON-RPC 2.0 кодом -32603 ("Internal error"). Консумери, що каталі legacy-конструктор, мають мігрувати на CA1032-ctors або передавати власнийJsonRpcError.SignalEventService.SubscribeAsync(account)тепер ідемпотентний — повторні виклики для того самого облікового запису повертають той самийsubscriptionIdбез RPC. Раніше другий виклик кидавInvalidOperationException.catch (InvalidOperationException) when (msg.Contains("вже підписаний"))більше не зловить — операція тепер успіх.JsonRequiredна always-present поляхEnvelope.cs:JsonRemoteDelete.RemoteDeleteId,Offer.Type/Offer.Opaque,Answer.Opaque,IceUpdate.Opaque,Hangup.Type. Якщо signal-cli колись поверне ці поля зnull— десеріалізація фолтиться зJsonExceptionзамість тихо пропуститиnullу non-nullable property.UserRecipient/GroupRecipientctor: null →ArgumentNullException, empty →ArgumentException. Раніше empty теж кидавArgumentNullException(порушення контракту обох типів).SignalCliHostedServiceтеперsealed— інхеріт не підтримується.- Стандартний шлях
dotnet publish /p:PublishAot=trueще не enable'нений (deferred — потребує redesign наJsonTypeInfo<T>overloads), але всі предумови (drop Nito.AsyncEx, drop.ValidateDataAnnotations(), source-gen JSON fast-path) на місці. - (round 9 §4.7)
CancellationToken-property +WithCancellationToken-Builder-method видалено зTextMessageOptions/AttachmentMessageOptions/StickerMessageOptions. Єдиний шлях скасування — параметрSend*Async(options, cancellationToken).[Obsolete]-shim після одного major-релізу (як обіцяно в CLAUDE.md "Backward compatibility convention"). Migration:.WithCancellationToken(ct).Build(); → .Build();+ передатиctдругим аргументом. - (round 9 §4.23/4.24)
ISignalMessage.{SendText,SendAttachment,SendSticker}MessageAsyncповертаютьTask<SendMessageResponse>(single response), а неTask<List<SendMessageResponse>>— все одно завжди було[response]-wrap. Migration:(await SendTextMessageAsync(opts))[0] → await SendTextMessageAsync(opts). - (round 9 §4.27) Generic-параметри
InvokeMethodAsyncпоміняли порядок:<TResponse, TRequest>→<TRequest, TResponse>наISignalCliClient,IJsonRpcSender, обох impls + ~22 callsites. Узгоджено зJsonSerializer.Deserialize<TValue>-конвенцією. Shim неможливий — C# не розрізняє overload'и за порядком typeparam'ів (same runtime signature). Migration: розверни<X, Y>→<Y, X>на кожному виклику.
✨ Додано
Observability (capability observability)
- Єдиний
internal static readonly ActivitySource SignalCliDiagnostics.ActivitySource = new("SignalCli.NET", AssemblyVersion)— спаниrpc.<method>,signalcli.process.start,signalcli.healthcheck.ping,signalcli.subscribe. Теги: method name, status enum, integer id, exception type name — без PII. - Єдиний
internal static readonly Meter SignalCliDiagnostics.Meter = new("SignalCli.NET", AssemblyVersion):Counter<long> signalcli.rpc.requests(тегиmethod,status∈ {ok,timeout,error})Histogram<double> signalcli.rpc.duration(мс, тегmethod)Counter<long> signalcli.process.restarts(тегtrigger∈ {force,crash,health})Counter<long> signalcli.events.dropped(тегevent_type∈ 10 значень) — замінює приватний_droppedCount.ObservableGauge<int> signalcli.subscriptions.active.
- Документація:
docs/cloud-development.mdмає нову секцію Observability з drop-in OTel-snippet.
RPC robustness
SignalCliOptions.NotificationChannelCapacity(default 1024). Між stdout-парсером і fan-out-споживачем — bounded Channel; повільний підписник створює back-pressure аж до signal-cli.JsonRpcClientприймаєTimeProvider—CancellationTokenSource(_requestTimeout, _timeProvider)робить timeout-шлях віртуалізованим у тестах.SignalCliHostedService.StopProcessInternalAsyncNoLockтеж використовуєCancellationTokenSource(_, _timeProvider).BeginScope(RpcMethod, RpcRequestId)уJsonRpcClient.InvokeMethodAsync— кожний нижчийJsonRpcClientLog.*несе structured-properties.
Subscription race safety
- Reservation placeholder pattern у
SignalEventService.SubscribeAsyncчерезDictionary<string, TaskCompletionSource<int>> _pendingSubscribes. Конкурентні виклики для того самого облікового запису роблять РІВНО 1 RPC; усі N викликачів отримують той самий ID. ObjectDisposedException.ThrowIf(_disposed, this)наSubscribeAsync/UnsubscribeAsync(audit C6).
Async-suffix shims (one-major-grace)
ISignalAccounts.ListAccountsAsync/SyncAccountAsync,ISignalDevices.StartLinkAsync/FinishLinkAsync,ISignalGroups.ListGroupsAsync— нові методи +[Obsolete]DIM-shims на старі імена ("will be removed in 4.0").
🛠 Внутрішнє
ProcessStateManager: snapshot-then-emit (OnNext поза локом — System.Threading.Lock не реентрантний)._disposedвсюди →intзInterlocked.Exchange(lock-free disposal short-circuit). CatchObjectDisposedExceptionз OnNext (documented disposal race window)._disposedстандартизовано якint + Interlocked.ExchangeуSignalCliHostedService,JsonRpcClient,JsonRpcClientHostedService,SignalEventService.Nito.AsyncExвидалено.JsonRpcClient._sendLockіSignalCliHostedService._operationLock→SemaphoreSlim(1,1)зWaitAsync/Release..ValidateDataAnnotations()видалено з options-pipeline —[OptionsValidator]source-gen самостійно перевіряє[Required]/[Range]без reflection. Знято останній AOT-blocker у options-шляху.SignalJsonContext.GenerationMode = Default(fast-path emission + metadata) замість Metadata-only.SignalEventService,ProcessWrapper,ProcessFactory,JsonRpcClientFactory,SignalAccounts,SignalDevices,SignalGroups— sealed (CA1052).Config.BuildClasspathкешує classpath;Directory.GetFilesвикликається рівно 1 раз наConfig-інстанс.ValidateRecipients: single-pass materialization + одинforeachна user/group split (раніше — 3 пройдення).ArgumentException.ThrowIfNullOrEmptyboundary checks уSignalDevices.FinishLinkAsync/SignalGroups.ListGroupsAsync.JetBrains.AnnotationsPackageReferencePrivateAssets="all"— більше не leak у consumer dependency graph.Example/Program.csповністю переписаний наasync Task Main/await host.StopAsync()/awaitedSendTextMessageAsync— LLM-агенти, що копіюють приклад, успадковують правильні async-патерни.- Forward-slash MSBuild paths у
SignalCli.runtime.csprojіSignalCli.Native.targets— Linux-збірки runtime-пакетів більше не ламаються тихо. - (round 8) Logging-perf analyzer rules:
CA1848 → warning(блокує regression на direct_logger.Log*— кожна нова log-callsite має йти через[LoggerMessage]);CA1873 → suggestion(analyzer не розпізнає manualIsEnabled-guards, тожwarning-рівень дає false-positives на legitimate Trace-only eager-eval сайтах). Trade-off задокументовано в.editorconfig. - (round 8) Manual
if (_logger.IsEnabled(LogLevel.Trace))guards надstring.Join(", ", response)уSignalAccounts.ListAccountsAsync+SignalGroups.ListGroupsAsync—[LoggerMessage]-внутрішній IsEnabled живе всередині generated-methоду, тож callsite-level allocation платилася на КОЖНОМУ виклику (навіть Info-level). Економить N × string-allocation на listAccounts/listGroups. - (round 8)
ObservabilityPrivacyTestsflake-fix: lock-snapshot pattern для_capturedActivities/_capturedMeasurements. ActivityListener/MeterListener реєструються глобально на ActivitySource/Meter, тож callback'и можуть прилітати з потоків паралельних тестів —List<T>не thread-safe →Collection was modifiedintermittent. Всі writes тепер підLock, читачі enumerate'ять snapshot. - (round 9 §4.9)
.editorconfigпіднятоCA2007 (ConfigureAwait)доwarningпісля audit-перевірки: 0 missing-sites уsrc/SignalCli/**. Тепер регресія неможлива — будь-який майбутній bareawaitловиться build-warning'ом. - (round 9 §4.25)
[StringSyntax(StringSyntaxAttribute.Uri)]наTextMessageOptions.PreviewUrl,PreviewImage, та параметрахBuilder.WithPreview(previewUrl, …, previewImage). Zero runtime cost; IDEs тепер валідують URL-syntax. - (round 9 §4.26) XMLDoc'и на 3
Send*Async-методах вISignalMessageотримали<exception cref="TimeoutException">із посиланням наSignalCliOptions.RequestTimeoutSeconds. Closes audit-doc-gap. - (round 10 §6.12) Новий test-file
JsonContextRegistrationTests— рефлексивно стверджує, що кожен*Parameters/*ResponseDTO уSignalCli.Models.Signal.*зареєстрований уSignalJsonContextчерез[JsonSerializable(typeof(...))]. Захист від "silent {}"-регресії, коли source-gen контекст не знає тип іJsonSerializer.SerializeToElementтихо повертає порожній об'єкт. Закриває audit N8. - (round 10 §7.4)
Assert.Equal(1.0, …)→Assert.Equal(1.0, …, precision: 3)уMonitorLoop_ShouldRespectHealthCheckInterval. CA2243 best-practice — explicit precision на double-asserts. - (round 10 §4.9 follow-up) Scope-обмеження
CA2007 → warningтільки дляsrc/SignalCli/**; уTests/**залишилиnone(xUnit-runner запускає тести без SynchronizationContext, тож.ConfigureAwait(false)no-op'не). - (round 11 §4.20)
ListAccountsResponseіListGroupsResponseтепер wrapper-records надIReadOnlyList<T>із customJsonConverter(зберігає плоский JSON-array на wire). Раніше успадковувалиList<T>— консумер міг мутувати дані сервера через.Add/.Clear/.Sort. Breaking для тих, хто мутував (compile-time error). РеєстраціїList<Account>+List<Group>додано вSignalJsonContext(source-gen у .NET 7+ не має reflection-fallback).Accountотримав[JsonPropertyName("number")]— корекція wire-shape на write. 2 нові round-trip тести вJsonSerializationTests. - (round 11 §8b.3) Новий public overload
AddSignalCli(this IServiceCollection, IConfiguration)— канонічний шлях дляappsettings.json-конфігурації. RobитьAddOptions<SignalCliOptions>().Bind(section)із тими ж валідаційними правилами (XOR Java/Native + source-gen validator + ValidateOnStart), що йAction<SignalCliOptions>-overload. ЗалежністьMicrosoft.Extensions.Options.ConfigurationExtensions 10.0.0додано вSignalCli.csproj. 3 нові тести вOptionsValidationTests. Closes audit B5 hookup. - (round 12 §8c.9) Test:
SendTextMessageAsync_StatefulEnumerableRecipients_AreEnumeratedExactlyOnce— захист §8c.5 single-pass-materialization від регресії до 3-х проходів (validate + 2× Where) на stateful IEnumerable. - (round 12 §8c.10) Test:
ToProcessConfig_CachesClasspath_SecondCall_DoesNotEnumerateFiles— observable-pattern (delete jar between calls), захист §8c.8 classpath-кешування. - (round 12 §9.6/§11.C.5) CLAUDE.md "Established patterns" — нова Observability subsection: single ActivitySource/Meter
"SignalCli.NET", canonical tag-key set{method, status, trigger, event_type}(pinned byMeterTagValues_AreOnlyKnownEnumLiterals), HealthChecks adapter як ОКРЕМИЙ optional-package (NEVER hard dep наMicrosoft.Extensions.Diagnostics.HealthChecksу core), lock+snapshot pattern для listener-fan-out тестів. - (round 13 §7.2/§7.3) Reflection helpers
GetPrivateField<T>/SetPrivateFieldвидалено зSignalCliHostedServiceTestsBase. Замість них —internal IProcess? SignalCliHostedService.CurrentProcessForTests+CurrentStreamPairForTests(typed test-seam, видимий черезInternalsVisibleTo("SignalCli.Tests")). 35 reflection-сайтів у 7 test-файлах перекинуто на типовий доступ. Renames приватних полів тепер ламають білд (compile-error), а не повертають мовчазний null.
AOT (capability aot-readiness) — round 14
- (round 14 §6.7)
<IsAotCompatible>true</IsAotCompatible>УВІМКНЕНО вSignalCli.csproj. Library тепер ship'иться як AOT-сумісна — консумери можутьdotnet publish /p:PublishAot=trueсвої app'и без IL2026/IL3050 warnings, що приходять із нас. Cold-start win, smaller native binary, WASM/iOS-friendly. ⚠️ (round 14 §6.7) Breaking:ISignalCliClient.InvokeMethodAsync<TReq, TResp>тепер вимагає 2 нових параметри —JsonTypeInfo<TRequest> requestTypeInfo+JsonTypeInfo<TResponse> responseTypeInfo. Те ж саме наIJsonRpcSender. Migration:client.InvokeMethodAsync<FooReq, FooResp>("m", req, ct)→client.InvokeMethodAsync("m", req, SignalJsonContext.Default.FooReq, SignalJsonContext.Default.FooResp, ct). Це enables AOT-safety — generic-overloadJsonSerializer.Serialize<T>(_, options)(reflection-based) повністю відсутній з production-path.- (round 14 §6.4)
SignalJson.Options.TypeInfoResolverтепер тількиSignalJsonContext.Default— reflection fallback видалено. Будь-який тип, що крос-уйде JSON-кордон зsrc/SignalCli/**MAY бути зареєстрований уSignalJsonContext, інакше — runtimeNotSupportedException(захист черезJsonContextRegistrationTests(§6.12)). - (round 14 §6.10) Новий
SignalJson.OptionsForTestsproperty ([RequiresUnreferencedCode]/[RequiresDynamicCode]) — test-only path із reflection-fallback для анонімних типів. Анонімні-payload usages уJsonRpcClientTests(8 сайтів) замінено наTestProbeRequest/TestProbeResponse-records у новомуTests/SignalCli.Tests/TestSerializationContext(test-localJsonSerializerContext, не забруднює production). - (round 14 §6.11) CLAUDE.md rule #6 оновлено: source-gen-only invariant +
OptionsForTests-test-path задокументовано. - (round 14 §8b.10/§11.D.2/§9.4) З AOT-увімкненим: library build = 0 IL2026/IL3050 warnings (включно з
Diagnostics/, options-pipeline).AddSignalCli(IConfiguration)overload позначено[RequiresUnreferencedCode](боBindтягне reflection) — для AOT-deploy використовуйтеAddSignalCli(Action<SignalCliOptions>).
Deferred-cluster (round 15) — усі тести з 2026-05-23 audit реалізовано
- (round 15 §1.6/§7.7)
BackPressureTests.NotificationBurst_WithSlowSubscriber_AllMessagesDeliveredInOrder: 100-message burst через приватний ProcessMessageAsync (reflection), bounded channel capacity=8, sync-subscriber 5ms/msg → всі 100 доставлено в FIFO-порядку. Захист від drop'ів і реордерінгу при slow-consumer back-pressure. - (round 15 §1.8)
TimeoutVirtualizationTests× 2:_InvokeMethodAsync_TimeoutPath_VirtualizedByFakeTimeProvider_ThrowsTimeoutException(FakeTimeProvider.Advance(61s) триггерить timeoutCts → TimeoutException без real wall-clock) + sanity_CallerCancellation_DoesNotFalselyAttributeToTimeout. Сертифікує §1.7 TimeProvider-CTS wire-up. - (round 15 §2.5/§7.5)
StateManagerReentrancyTests× 2: synchronous Rx-subscriber виклика повторнийUpdateStateіз OnNext-handler — ланцюг доходить до Stopping за <2с (інакше WaitAsync фейлить як deadlock). Concurrent-callers contention теж покрито. - (round 15 §4.15)
*Options.Builder.Build()post-mutation guard на 3 типах — кидаєInvalidOperationExceptionякщо обов'язкові поля обнулено між ctor і Build (захист від reflection / record-withmutation). - (round 15 §5.12)
ScopeCaptureTests.InvokeMethodAsync_OpensScope_WithRpcMethod_AndRpcRequestIdчерезFakeLogger<JsonRpcClient>(пакетMicrosoft.Extensions.Diagnostics.Testing) — фіксує щоRpcMethod+RpcRequestIdstructured-scope-properties присутні на кожному log-entry, як обіцяно §5.11. - (round 15 §8a.6)
BackgroundServiceLifecycleTests.StopAsync_BlocksUntilExecuteAsync_ObservesCancellation: FakeTimeProvider-driven tick → ping observed → StopAsync → ExecuteTask.IsCompleted upto 5s real-time. Доказує що base.StopAsync блокує до завершення ExecuteAsync. - (round 15 §8a.8)
StopProcessTimeoutVirtualizationTests.StopAsync_WhenWaitForExitTimesOut_KillsProcess_OnVirtualClock: mock-process'уWaitForExitAsyncблокує на CancellationToken.Register;fakeTime.Advance(StopTimeoutSeconds + 1)тригерить kill-branch. Сертифікує §1.7/§8a.7 TimeProvider-CTS wire-up на StopProcessInternalAsync.
Hosting modernization + CI smoke (round 16) — закриває останні 4 пункти
- (round 16 §8a.2)
SignalCliHostedServiceіJsonRpcClientHostedServiceтеперIHostedLifecycleService(extendsIHostedServiceіз 4 додатковими phase-методами:StartingAsync/StartedAsync/StoppingAsync/StoppedAsync). Реалізації — no-op (поточна поведінка не зміняється); generic-host автоматично детектить interface і викликає phase'и у визначеному order'і. Foundation для майбутніх ordering-refinement'ів (warm-up ping після всіх start'ів тощо). - (round 16 §8a.3)
SignalCliHostedServiceтепер реалізує обидваIAsyncDisposable+IDisposable.DisposeAsyncдренує_operationLock.WaitAsyncіз 2с-fallback-timeout (in-flightStart/Stop/Restartмає шанс завершитися cleanly перед kill'ом); потім — спільнийDisposeCoreіз sync-cleanup'ом.Dispose()— sync-only, без drain'у. CLAUDE.md rule #9 (no sync-over-async in disposal) дотримано: обидва шляхи мають незалежні реалізації, спільне ядро. DI-контейнер preferр'итьDisposeAsyncпри scope-tear-down. Новий log-eventDisposeAsyncDrainTimeout(EventId 132). 5 нових тестів уAsyncDisposalLifecycleTests. - (round 16 §8d.13/§8d.14) Новий GitHub Actions workflow
.github/workflows/runtime-smoke.ymlіз двома Linux-job'ами:native-runtime-delivery— повнаdotnet build SignalCli.sln; assertion:signal-cli-native/signal-cliдойшов у consumer TargetDir і має executable-bit. Захист від forward-/back-slash-регресії у MSBuildInclude/PackagePath(closes audit N1 §8d.1).jre-guard-corruption— build jre-runtime, deletebin/java*, re-build expected to fail із actionable message. Захист від видалення/деградації §8d.10 post-extract<Error Condition>-guard.- Path-filtered (
src/SignalCli.runtime*,src/build/),workflow_dispatchдля manual-run.actions/*pinned до commit-SHA per §8d.9 supply-chain.