Skip to content

[iOS] Audio transceivers are never stopped/released on unpublish (Transport.releaseTransceiver only handles .video) #1104

Description

@hiropo

Environment

  • SDK: client-sdk-swift 2.16.0 (latest released version as of 2026-09-01)
  • Server: livekit-server --dev v1.13.6 (local dev server)
  • Test device: iOS Simulator (an iPhone-16-class simulator runtime), not a
    physical device
  • Verification method for this report: source read of the 2.16.0 tag on GitHub
    (Sources/LiveKit/Core/Transport.swift, Sources/LiveKit/Participant/LocalParticipant.swift).
    The 200-cycle memory measurement below was captured against the same 2.16.0 build
    in an earlier test run; the source read confirms the code path is unchanged in the
    current 2.16.0 tag.

What happens

In an app that repeatedly publishes and unpublishes a microphone audio track, process
memory (phys_footprint) grows steadily across cycles and does not plateau.

Measurement

200 unpublish/re-publish cycles, phys_footprint sampled at intervals:

Cycle phys_footprint
baseline 26.58 MB
50 30.13 MB
100 31.53 MB
150 32.92 MB
199 34.53 MB

Net growth: 26.58 → 34.53 MB over 200 cycles, with per-block growth of roughly
28–33 KB/cycle after the first block (the first 50 cycles grew faster, ~61 KB/cycle,
which we attribute to one-time warm-up allocations rather than the leak itself).
Growth does not plateau across the full 200-cycle run.

Note: phys_footprint is whole-process resident memory (Swift/ObjC heap + native
WebRTC + stacks, etc.), not an isolated "native heap" counter, so this number is not
directly comparable in magnitude to a native-heap-only measurement taken on another
platform/SDK — only the "does it plateau or not" shape is comparable across platforms.

We also tried periodically tearing down and recreating the room connection every 50
cycles as a mitigation. It reduced (and in one interval nearly eliminated) the growth
within a block, but results were inconsistent block-to-block — one block recovered
almost nothing while another was essentially flat — consistent with the transceiver
stop/release being asynchronous and the mitigation's effectiveness depending on how
much wall-clock time elapses before the next connection. We did not verify this timing
explanation independently; it is our best-effort read of the behavior, not a confirmed
mechanism.

Expected vs actual

  • Expected: unpublishing an audio track stops/releases its RTCRtpTransceiver
    (freeing native resources), the same way it does for video tracks.
  • Actual: the transceiver is left attached to the peer connection indefinitely.
    Every publish adds one more, permanently, for the lifetime of the connection.

Suspected source location

Sources/LiveKit/Core/Transport.swift:

func remove(track sender: LKRTCRtpSender) throws {
    guard _pc.removeTrack(sender) else {
        throw LiveKitError(.webRTC, message: "Failed to remove track")
    }

    releaseTransceiver(sender: sender)
}

// Try to stop the transceiver and free the resources
// Workaround: https://groups.google.com/g/discuss-webrtc/c/WDsGuVucBjQ?pli=1
private func releaseTransceiver(sender: LKRTCRtpSender) {
    if let transceiver = _pc.transceivers.first(where: { $0.sender == sender }),
       transceiver.mediaType == .video, !transceiver.isStopped
    {
        log("Stopping video transceiver", .debug)
        transceiver.stopInternal()
    }
}

The transceiver.mediaType == .video condition means stopInternal() is only ever
called for video transceivers. Audio transceivers reaching this function always skip
the stop/release step and remain attached to the peer connection.

On the publish side, LocalParticipant.swift calls
publisher.addTransceiver(with: track.mediaTrack, transceiverInit: transInit) fresh on
every publish, with no lookup/reuse of a previously-created (and now orphaned)
transceiver — so the two sides compound: a new transceiver every publish, none of them
released for audio on unpublish.

This looks like the audio-side counterpart of #420 ("Huge memory leaks produced by publishing/unpublishing camera tracks", now closed)
for camera/video tracks (publish/unpublish memory leak on video, closed after adding
the releaseTransceiver workaround referenced by the WebRTC discussion-group link in
the comment above) — the fix appears to have been scoped to video only, leaving audio
with the same underlying behavior.

Repro

let track = LocalAudioTrack.createTrack()
for _ in 0..<200 {
    let publication = try await localParticipant.publish(audioTrack: track)
    // ... hold briefly ...
    try await localParticipant.unpublish(publication: publication)
}
// phys_footprint sampled periodically shows steady growth, no plateau.

Impact

Any push-to-talk / walkie-talkie style app that does frequent publish/unpublish
cycles (hundreds per session is a realistic usage pattern) accumulates transceivers
without bound, growing resident memory over the life of a long-running session.

Suspected fix

Drop the transceiver.mediaType == .video condition in releaseTransceiver (or add
an equivalent audio branch) so that audio transceivers are stopped the same way video
ones are on unpublish, mirroring whatever safety consideration originally limited the
video-only fix (if there was a reason audio was excluded, it isn't stated in the
comment).

Workaround

None found from application code — periodic full room reconnects reduce, but do not
reliably bound, the growth (see measurement notes above).

Question for maintainers

Was audio deliberately excluded from releaseTransceiver for a specific reason (e.g.
some interaction with audio session lifecycle), or was the video-only condition simply
never extended to audio when the video leak was fixed?


Companion report for the Android SDK, same defect shape: livekit/client-sdk-android#1012

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions