Line-by-line review of Telegram.Native.Calls/VoipManager.{h,cpp} and
Telegram.Native.Calls/VoipGroupManager.{h,cpp}, cross-checked against the C# call
sites in Telegram/Services/Calls/ and against tgcalls' own expectations.
Line numbers are as of c533f04c0, i.e. before the P0 commits below; anything still
open has shifted by a few lines since.
Legend: [live] = confirmed reachable from current app code · [latent] = correct today only by convention or because no caller exercises it.
Build: msbuild Telegram.Native.Calls\Telegram.Native.Calls.vcxproj /p:Configuration=Debug /p:Platform=x64 /m with VS 18 Community. Takes a few minutes and links clean; the C4715
and C4702 warnings it prints are pre-existing.
All five landed on develop as 4eda2af98..c4bbb77f6, one fix per commit, built x64 Debug.
-
Unchecked
get_selfon aVoipCaptureBase—VoipManager.cpp:215-219[live] →4eda2af98Startdoesget_self<implementation::VoipVideoCapture>(descriptor.VideoCapture())with notry_as.get_selfreinterprets the ABI pointer, so aVoipScreenCapturereadsm_implat the wrong offset. Reachable becauseVoipCall._cameraholds either kind (VoipCall.cs:832) and is passed straight into the descriptor (VoipCall.cs:680) — start screen sharing before the call reachesReady.Fixed: the
try_aschain becameGetVideoCaptureImplinVoipScreenCapture.h, used by all four call sites (both managers,Start+SetVideoCapture). -
E2E: a null delegate silently transmits plaintext —
VoipGroupManager.cpp:409-435[live] →d200580d0OnE2EEncryptDecryptreturns the input unchanged whenm_encryptDatais null, and hands raw ciphertext to the media stack whenm_decryptDatais null. Two windows:SetEncryptDecryptruns after construction (VoipGroupCall.cs:297,:748), andSetEncryptDecrypt(null, null)runs beforeStop()(VoipGroupCall.cs:787).Fixed: returns an empty vector, which is what
FrameTransformer::Transform(GroupInstanceCustomImpl.cpp:1454) treats as "drop this frame" — and what the managedEncryptData/DecryptDataalready return when TDLib fails (VoipGroupCall.cs:414).The window is now closed too, see P2 below — and it was teardown-only, not construction. Every construction site installs the delegates synchronously before anything can move media, so the "delegates are set after construction" half of this finding was wrong.
-
persistentStateout-of-bounds write —VoipManager.cpp:72-79[latent] →fef656e87Empty vector indexed by
[i]. Nothing in C# setsPersistentStatetoday, so it never fires — but it is heap corruption the moment someone wires it up. Also re-fetchesdescriptor.PersistentState()twice per byte.Fixed with
vector_to_unmanaged. Note nothing reads the state back —getPersistentStateis still commented out atVoipManager.cpp:363— so the property could just as well be deleted. -
CLoopbackCaptureheld by value —VoipGroupManager.h:81→c4bbb77f6It is a ref-counted WRL
RuntimeClass, and itsMETHODASYNCCALLBACKsub-objects forwardAddRef/Releaseto it while MF work items hold those refs. Storage died withVoipGroupManagerregardless of the refcount.Fixed:
ComPtr<CLoopbackCapture>viaMake<>, created only on the screencast path. That also removed theStopCaptureAsyncevery non-screencast manager was making. -
Loopback start failure crashed on stop —
LoopbackCapture.cpp→d4f761270Originally filed as "
Stop()callsMFShutdown()process-wide". That part was wrong —MFStartupis called fromInitializeLoopbackCapture(LoopbackCapture.cpp:65) and the two are paired 1:1, which is the contract MF expects.What was real underneath it:
OnStopCapturedereferencedm_AudioClientunconditionally, whileStopCaptureAsyncaccepts the error state that a failed activation leaves behind and where no client was ever created — a null deref on exactly the machines where process loopback is unsupported. And theMFStartupwas left dangling whenever the start failed, sinceStopCaptureAsyncis the only thing that paired it; the destructor now does.Still open, deliberately not touched:
StopCaptureAsyncblocks the calling thread onm_hCaptureStopped.wait()with no timeout, andStop()runs on the UI thread. → P2.
-
Remote video sink registered over and over —
VoipManager.cpp:271-278[live]Originally filed as "the null passed by
VoipCall.SetRemoteVideoOutputis ignored, so the sink is never detached". Wrong on both halves. tgcalls'setIncomingVideoOutputtakes aweak_ptrandVideoSinkImpl::addSinkappends, pruning entries only as they expire (InstanceV2Impl.cpp:710-741) — so a null sink was never how you detach, and passing one through would just append a dead entry. Detaching already works, viaVoipVideoOutputSink::Stopdropping theshared_ptr(VoipPage.xaml.cs:943).The real bug is the reverse:
OnRemoteMediaStateChangedre-sets the same output on every state change where video is active (VoipPage.xaml.cs:210-215), and each call appends the same sink again, so one frame is rendered N times — at 30fps, N times the D2D and Composition work.Fixed by remembering the last sink and skipping a repeat. The field is a
weak_ptron purpose: it exists only to recognise a repeat, and a strong one would keep the sink alive and break detach-by-expiry.Corrected on a second pass — the first version early-returned on a null sink, which was wrong twice over. It left
m_incomingVideoOutputpointing at a sink the caller had just asked to detach, and it swallowed the one thing a null is good for:setIncomingVideoOutputalso assigns_currentSink(InstanceV2Impl.cpp:2036), which a newly negotiated video channel gets handed on creation (:1512). Never clearing it means a channel that appears after the detach re-attaches the old sink. Null now flows through as an emptyshared_ptr, and the dedupe check handles it for free — two nulls in a row compare equal and do nothing.Not an issue for
VoipGroupManager::AddIncomingVideoOutput— every caller there builds a fresh sink (GroupCallPage.xaml.cs:2125,StoryContent.xaml.cs:844). -
done()never latches_done, so a double deferral re-enters tgcalls —VoipGroupManager.h:128-148,:177-205,:234-242[live]OnMediaChannelDescriptionsRequestedcalledargs.Deferral(...)on the null-participants path and fell through without returning (VoipGroupCall.cs:913-918) — so in practice it NREd onparticipants.ToDictionary()inside anasync voidbefore it ever got as far as deferring twice. Both halves fixed:_done = nullptrafter firing in all three task impls, and the missingreturn. -
OnMediaChannelDescriptionsRequestedcan double-add a description —VoipGroupCall.cs:952-962When any ssrc was unknown, the second pass walked all of
AudioSourceIdsagain and re-added the ones the first pass had already resolved. It only stayed harmless because tgcalls asks for one ssrc at a time — the assumption the comment above it leans on.The second pass now walks
unknownSources, which is both correct and the smaller loop: it no longer re-reads theIVectoracross the ABI either.Note while here: tgcalls never calls
cancel()on these tasks (nocancelcall exists inGroupInstanceCustomImpl.cpp), so thecancel()overrides are dead code. What keeps a post-Stopdeferral safe is that tgcalls' own completion lambdas capture aweak_ptr— worth a comment so nobody "simplifies" the null checks away. -
GetDebugInfoleaks and can return garbage —VoipManager.cpp:338-351malloc'dwlognever freed, and theMultiByteToWideCharreturn went unchecked, so a failure returned an uninitialized buffer. The correct implementation was already sitting right below it as dead code — that is the C4702 the build used to warn about. Now the only line. -
IsMutedlies for screencast —VoipGroupManager.h:83,.cpp:102-129Field defaults to
trueand the screencast path calledm_impl->setIsMuted(false)directly, leaving it stale. Now routed through theIsMuted(bool)setter.Left alone:
AudioProcessId == 0still returns early and stays muted, as does a loopback that fails to start. That is the right meaning — a screencast with no audio to send is muted — and it now reports itself honestly. -
Uninitialized
qualityImpl—VoipGroupManager.cpp:353-365Switch had no
default:; now initialised toThumbnail. Thescaleswitches at:344-351and:378-385also have no default, but 0 is the 1000ms case and tgcalls only ever passes one of the four listed periods, so those are left as they are. -
AddIncomingVideoOutputhas no null guard —VoipGroupManager.cpp:181-188get_selfon a null sink thenimplementation->Sink()is a null deref.VoipManager's equivalent already checked. -
TURN entry with an empty host —
VoipManager.cpp:118-131pushStunskipped empty hosts,pushTurndidn't, so a server with no IPv6 contributed a TURN entry pointing at nothing. Still open, deliberately: the reflector loop (:141-158) ignoresIpv6Addressentirely, andRtcServer::idisuint8_twhilereflectorIdis aptrdiff_t. → P4. -
EncryptionKeyread with no null/size check —VoipManager.cpp:81-88Was 256 property calls plus 256
GetAtcalls, and a short vector would have thrownhresult_out_of_boundsout ofStart. OneGetManyinto the shared array, which clamps to what is there, and the redundant copy through a stackstd::arrayis gone. -
DECIDED, kept as is —
RequestMediaChannelDescriptionTaskImpl::donehardcodestype = Audio—VoipGroupManager.h:140— not a bug; decide whether to keep it that wayVoipMediaChannelDescriptioncarries onlyAudioSourceandUserId(Telegram.Native.Calls.idl:67-71), so audio-only is the shape of the whole API rather than a slip in this one line. It also looks correct: the path exists to resolve unknown audio ssrcs (maybeRequestUnknownSsrc), while video endpoints arrive throughSetRequestedVideoChannels. Supporting video would mean extending the IDL and the managed side — a feature, not a fix. -
Empty-but-non-null broadcast part reported as
Success—VoipGroupManager.h:185-197Now
NotReady, so tgcalls retries instead of decoding a zero-byte part. Took the double copy with it (the P3 item below) since it was the same five lines:GetManystraight into the destination instead of an iterator copy followed by amemcpy. -
responseTimestampwas only set on theSuccesspath —VoipGroupManager.h:189-207Found by re-reviewing the commit above, which is also what made it matter:
NotReadyis precisely the status tgcalls readsresponseTimestampon, to decide where to restart a stream that has not begun yet (StreamingMediaContext.cpp:857-860). It arrived as 0. The caller had been supplying it all along (VoipGroupCall.cs:886) and we dropped it.Two bugs in one line, because the field is also in seconds while every other timestamp in this API is milliseconds — tgcalls does
responseTimestamp * 1000.0to get back to ms. We passedUnixTimeMillisecondsstraight through, 1000x too large. Converted at the tgcalls boundary rather than changing the delegate, so the WinRT surface stays consistently milliseconds:requestCurrentTimegenuinely is ms (StreamingMediaContext.cpp:643), so this one field was the odd one out, not the caller.Verified on a live stream by Fela, 2026-08-11 — so the seconds reading is right, and the
NotReady+segmentTimestamp == 0startup path really does get exercised.
-
Close the E2E delegate window — carried over from P0 →
5356ebabbThere was no window at construction. Checked every site:
297,352and748all callSetEncryptDecryptsynchronously, on the same thread, before theSetConnectionMode/EmitJoinPayloadthat starts media (:564,:765) — and:145, the one construction that doesn't set them, is the non-conference call, whereIsConferencemeans thee2eEncryptDecryptcallback is never wired at all (VoipGroupManager.cpp:69-84).The real window was teardown:
SetEncryptDecrypt(null, null)ran beforeStop()in both paths (:787and:1116), so tgcalls was still live with no delegate installed. Fixed by reordering — the clear only exists to break the native to managed cycle, which works just as well after the instance is gone. Not compiled; C# side, statement reorder. -
DECIDED, kept as is —
DecryptDataignores the data channel —VoipGroupCall.cs:421It always passes
new GroupCallDataChannelMain(), whileEncryptDatadistinguishes screen sharing from main. Work out whether decrypting a remote screencast needs the other channel, or whether the receiving side genuinely only ever sees main. -
StopCaptureAsyncblocks the caller with no timeout —LoopbackCapture.cpp:230m_hCaptureStopped.wait()was unbounded andVoipGroupManager::Stop()runs on the UI thread, so a work queue that never drained hung the app. Gone with the rewrite below:Stopnow takes the lock the sample callback holds, so it waits for one packet read at most and never for a work item that may never run. -
Rewrite
CLoopbackCapturewithout WIL, C++/WinRT friendly (Fela)Now
VoipLoopbackCapture(VoipLoopbackCapture.{h,cpp}), matching how the rest of the project names things. What changed:- WRL
RuntimeClass→winrt::implements, which brings agility with it, soFtmBaseis gone too.wil::com_ptr_nothrow→com_ptr,wil::unique_event→handle,wil::critical_section→slim_mutex, and theRETURN_IF_FAILEDmacros → plainif (auto result = …; FAILED(result)). - The
METHODASYNCCALLBACKmacro and its fouroffsetof-derived callback objects are gone. Only the sample pump genuinely needs the MMCSS work queue, so start, stop and finish are now direct calls and there is oneIMFAsyncCallbackleft. - No more
m_hCaptureStoppedhandshake: aslim_mutexheld across the sample callback is whatStopsynchronises against. - The handler is a constructor argument rather than a settable sink, so it cannot be missing when the first packet lands.
Three real bugs fell out of the rewrite, all in the packet loop:
- The byte count came from
GetNextPacketSizebut the copy ran afterGetBuffer, which is allowed to hand back a different frame count — an over-read whenever they differ. AUDCLNT_BUFFERFLAGS_SILENTwas ignored. A silent buffer's contents are undefined, so silence was being sent as whatever happened to be in memory. Now zeroed.m_sampleswas invoked unguarded.
- WRL
-
AUTOCONVERTPCMis passed as the periodicity argument —VoipLoopbackCapture.cpp, inOnActivatedIAudioClient::Initializetakes it inStreamFlags, but it sat inhnsPeriodicity, which the documentation says shared mode requires to be 0. Inherited verbatim from the Microsoft sample.Measured before changing — a standalone harness activated the same process-loopback client and called
Initializethree ways:Variant Result AUTOCONVERTPCM as hnsPeriodicity(what shipped)S_OK, 480-frame bufferAUTOCONVERTPCM in StreamFlags, periodicity 0S_OK, 480-frame bufferno AUTOCONVERTPCM at all, periodicity 0 S_OK, 480-frame bufferSo shared mode ignores periodicity rather than validating it — the misplacement was harmless and screen audio has been working for real, not by luck. The control run also shows the flag does nothing here: process loopback converts to whatever format is asked for regardless. Moved into
StreamFlagswith periodicity 0 as a correctness tidy-up, with the measurement recorded in the comment so nobody re-derives it.Incidental, and relevant to the clock-buffer item below: the buffer comes back as 480 frames, exactly one 10ms WebRTC quantum, despite a 20ms request.
-
Lock held across managed callbacks, and taken again by
add/remove—VoipManager.cpp:415-458vs:463-571;VoipGroupManager.cpp:306-435vs:447-539winrt::eventsynchronises itself, som_lockonly served to serialise callbacks against (un)subscription — at the price of a lock-order inversion with the C# side: the UI thread takes_managerLockthen unsubscribes (VoipCall.cs:803-814→ wantedm_lock), while a tgcalls worker heldm_lockinside managed code that takes C# locks (_stateLock,VoipCall.cs:507). No handler closed the cycle, but any handler touching_managerLockwould have.Gone from all 41 sites.
VoipManagerhas no lock at all now;VoipGroupManagerkeeps one for the only state that is genuinely shared — the encrypt/decrypt delegates, written from the UI thread and read per frame from a media thread.The bigger win is what came off that lock: the managed
EncryptDatablocks on a TDLib round trip (VoipGroupCall.cs:407-414), and it used to do that while holding the same mutex as every audio-level update and every subscribe. The delegates are now copied out under the lock and invoked outside it.A comment on the event fields records why there is no lock, so it does not come back.
-
m_implis not guarded — reachable inVoipGroupManager, not inVoipManagerEvery
if (m_impl)is TOCTOU againstStop()'sreset(). Traced properly rather than assumed, and the two managers turn out to differ:VoipManageris safe. Every managed call site,Disposeincluded, runs insidelock (_managerLock)(VoipCall.cs:365,:804). The contract holds; it just is not written down anywhere.VoipGroupManageris not.VoipGroupCalltakes no such lock, andDisposeis reached fromUpdateon TDLib's update thread (VoipCoordinator.cs:483→VoipGroupCall.cs:1414), while the UI thread callsAddIncomingVideoOutput,SetRequestedVideoChannelsandSetVolumeunguarded.Stop()resettingm_implagainst any of those is a use-after-free, not merely a torn read.
Correcting my own earlier note: guarding
m_implwould not put a lock back on the path that reaches managed code. The callbacks never touchm_impl— only inbound calls do, and those go to tgcalls, not to C#. That reasoning was wrong.Fixed the first way:
VoipGroupCallnow takes the_managerLockit already declared — the field existed but guarded nothing except a block of commented-out code copied fromVoipCall. Held around every call that reaches the tgcalls instance, and around the teardown inDisposeandEndScreenSharing.Deliberately left outside the lock: the
IsMutedandIsNoiseSuppressionEnabledgetters, which read a native field rather than touchingm_impl, and the null checks that only decide whether to start something.Two places needed more than a wrapper. The
EmitJoinPayloadcontinuations inRejoinandRejoinScreenSharingrun after awaits, so they take the lock again where they call in — C# cannot hold one across an await. AndUpdateParticipantdereferenced_managerunconditionally on TDLib's update thread, which was a null reference waiting to happen quite apart from the race.The two alternatives, if this ever proves too coarse: a dedicated native mutex around
m_impl(watch the ordering —Stop()holding it while stopping the loopback would deadlock againstAddExternalAudioSamples), or an atomicshared_ptrswap so a late caller keeps the instance alive instead of blocking. -
No destructor on either manager
Destruction without
Stop()destroyed the tgcalls instance without callingstop(), and left the loopback capture running. Both destructors callStop(), which is idempotent. -
VoipManager::Starthas no re-entrancy guard — calling it twice destroyed the previousInstancewithoutstop().Now a no-op when one is already running, rather than a stop-and-restart: the instance in flight is serving the live call, and the second descriptor would be for the same one. Completes the Start/Stop lifecycle work the destructors began.
-
DECIDED, confirmed correct —
requestMediaChannelDescriptionsgating onIsConference—VoipGroupManager.cpp:69-83The C# handler is subscribed unconditionally (
VoipGroupCall.cs:151,:296,:351). For non-conference calls tgcalls falls back to synthesizing a description withuserId = 0(GroupInstanceCustomImpl.cpp:3453-3458). Confirm that's intended before changing anything.
-
OnE2EEncryptDecryptdoes ~2 COM calls per byte, per packet —VoipGroupManager.cpp:409-435The return path rebuilt the result with a
push_backloop over anIIterator, on every frame of a conference call. Now oneGetManyinto a pre-sized buffer. The lock it all used to run under is gone too, see P2.Still there, and not fixable from this side: the input is copied into a
single_threaded_vectorbecause the delegate takes anIVector<byte>, and the managed side then callsToArray()on it (VoipGroupCall.cs:407) — two copies before TDLib even sees the frame. Removing them means changing the delegate signature in the IDL to pass a buffer the managed side writes into. → below. -
The E2E delegate signature forces two copies per frame —
VoipGroupManager.idl:15-16Reshaped to
UInt8[]in and out, since every byte ends up base64 encoded into TDLib JSON and anIVectorwas the wrong shape at both ends. The projection copies once each way and the managed side hands the array straight to TDLib — two COM objects, two RCWs and theToArray()are gone.ReceiveSignalingDataand the signaling callback got the same treatment; the latter stopped being an event at the same time, since it only ever had one listener, which deletedSignalingDataEmittedEventArgsoutright.It also exposed a live bug in the managed half — see the transform semaphore below.
-
The frame transform waited on a shared semaphore —
VoipGroupCall.cs:403-432Found while reshaping the delegates above.
new Semaphore(0, 1)was shared across every invocation, but tgcalls builds one frame transformer per simulcast layer and one per incoming channel, each on its own thread — so a secondRelease()before the firstWait()throwsSemaphoreFullExceptionon TDLib's callback thread, and a waiter can be woken by another frame's answer and return a null result, dropping a good frame.My own P2 change is what exposed it. Holding
m_lockacross the managed delegate used to serialise every encrypt and decrypt through one mutex, which accidentally made the shared semaphore safe. Taking that lock off the callback path was right, but I did not check what depended on the serialisation.Each transform now waits on its own
ManualResetEventSlim, with a 5s timeout so a stuck TDLib drops the frame rather than wedging a media thread. Not disposed on purpose — a late answer would stillSet()it. -
OnAudioLevelsUpdatedallocates per update —VoipGroupManager.cpp:313-329One
AppendCOM call per participant, ~10x/s for the whole call. Now filled into a reservedstd::vectorand handed over once viasingle_threaded_vector(std::move(vec))— which is what the/*std::move(levels)*/comment had been asking for.Correcting my own earlier note: there was no boxing per element.
VoipGroupParticipantis an IDL struct, so theIVectorholds it by value.The remaining
IVectorallocation per update is fixed by the event signature. -
BroadcastPartTaskImpl::donecopies the payload twice —VoipGroupManager.h:187-191. Done alongside theNotReadyfix in P1. -
ReceiveSignalingDatapush_backs per byte with no reserve —VoipManager.cpp:367-379. OneGetManyinto a pre-sized buffer. -
SetRequestedVideoChannelsbuilds the vector even whenm_implis null —VoipGroupManager.cpp:273-302. Early-out at the top, plus areserve. It still walksSourceGroups()/SourceIds()through COM iterators, which is fine: it runs when the video layout changes, not per frame. -
Protocol()'s comparator takesstd::stringby value —VoipManager.h:34. Nowconst&. The lexicographic sort it performed was a live bug, not a latent one — see P4. -
—RemoveSsrcsiterator copyVoipGroupManager.cpp:173-179. Won't fix.GetManyneeds matching element types and this convertsint32_ttouint32_t, so it would trade the COM calls for a second allocation. It runs when a participant leaves, with a handful of ssrcs — not a hot path, and the current form is the clearer one.
Checked while rewriting the capture, since "we just inject bytes" sounded like the wrong shape. It is not — the screencast path is already on the intended hook:
- A screencast instance never opens the microphone.
createAudioDeviceModulebranches onVideoContentType::Screencastand builds aFakeAudioDeviceModuleover anExternalAudioRecorder(GroupInstanceCustomImpl.cpp:4392-4397). The loopback capture is that instance's audio device. addExternalAudioSamplesconverts int16 → float and appends to_externalAudioSamples(:3828), andExternalAudioRecorder::Record(:958) pulls 480 samples — 10ms of 48kHz mono — off the front each time WebRTC asks. That is why the capture format is fixed at 1×48000×16.- The
AudioCapturePostProcessorthat mixes external samples into the microphone (:842-859) is the other consumer, for the main instance. We never feed it.
So there is no better hook to move to; a custom AudioDeviceModule via
createAudioDeviceModule would just be reimplementing FakeAudioDeviceModule. What is
worth attention is the coupling, not the mechanism:
-
The buffer between the two clocks is unmanaged —
GroupInstanceCustomImpl.cpp:3838WASAPI pushes on its capture clock, WebRTC pulls 10ms at a time on its own. Nothing reconciles them: the buffer grows to 2 seconds and then drops from the front, so a consumer that falls behind gets a permanent 2s lag plus a discontinuity, not a resynchronisation. It is also the reason screen audio can drift out of sync with video over a long share. Worth measuring the steady-state depth before deciding anything.
-
DEFERRED to an upstream pass — Screencast audio is mono-only by construction —
options.num_channels = 1Fine for speech, lossy for music or a game. Widening it means the descriptor, the capture format and
ExternalAudioRecorderall have to agree, so it is a tgcalls change as much as ours.
-
EmitJoinPayload(VoipGroupManager.cpp:151-163) invokedcompletionsynchronously on the null-impl path and asynchronously otherwise. It no longer completes at all when there is no instance: an empty payload only got the caller as far as a join the server would reject. -
const auto RegisterTag = tgcalls::Register<...>()at namespace scope in a header (VoipManager.h:22-24) — internal linkage, one registration per including TU. Moved intoVoipManager.cpp, which is where a thing with internal linkage belongs. - The screencast group manager writes
tgcalls_screencast.txtrather than sharingtgcalls_group.txtwith the main one. Both still grow without bound; that needs a rotation policy, not a rename. - Dead
#ifndef _WIN32branch inside the designated-initializer list —VoipManager.cpp:51-57. It could never have compiled; deleted. -
No remove counterpart to— not a leak. Same mistake as the P1 video-sink item: the groupAddIncomingVideoOutputVideoSinkImpl::OnFrameprunes expired weak_ptrs exactly like the 1:1 one (GroupInstanceCustomImpl.cpp:662-665), so releasing the sink is the removal. Left open only as a reminder of the shape. - DECIDED, kept as is —
enableAEC/enableNS/enableAGChard-codedtruein 1:1 calls (VoipManager.cpp:46-48) while group calls honour theIsNoiseSuppressionEnabledsetting. Needs your call — it is a product decision, not a defect. -
Protocol()sorted versions lexicographically. Filed as latent; it was live. I had assumed tgcalls' majors were single-digit. They are not — the registered set is2.7.7 5.0.0 7.0.0 8.0.0 9.0.0 10.0.0 11.0.0, and a string sort orders that9 8 7 5 2.7.7 11 10, putting the two newest protocols at the end of a list the comment says the server reads newest first. Now compared component-wise as numbers, checked against the real set plus the strict-weak-ordering properties.
Every defect is fixed. Fela ruled on the open questions on 2026-08-11; what follows is what he decided and the three things still genuinely open.
Decided — accepted as they are, not defects:
- The data channel that
EncryptData/DecryptDatadiscard. Native picksScreenSharingvsMainand the managed side sendsnull/Mainregardless. enableAEC/enableNS/enableAGChard-coded in 1:1 calls while group calls honour the setting.RequestMediaChannelDescriptionTaskImplcarrying only audio.requestMediaChannelDescriptionsgated onIsConference— confirmed correct.- The 5s transform timeout stays. Encryption is near-instantaneous, which is why tgcalls made it synchronous in the first place; the timeout is there for a stuck TDLib, not a slow one.
Deferred to an upstream pass: mono-only screencast audio.
Still open:
- The unmanaged buffer between the WASAPI and WebRTC clocks — worth logging the steady-state depth over a long share before designing anything.
Left deliberately, with the reasoning recorded above: m_impl being unguarded (P2),
RemoveSsrcs (P3), unbounded log growth (P4).
Every item that was a defect and did not need a decision, a device or a measurement is
now fixed. Two of them were only found by re-reading commits already made — the
responseTimestamp gap and this version sort — so the pass over one's own work earned
its keep.