Summary
ziti-edge-tunnel 1.18.1 on ARM64 reproducibly retains a large anonymous/data allocation after forwarding Jellyfin media traffic.
A clean process starts at approximately 10.5 MiB container memory and 52.9 MiB VmData. After a multi-stream workload with a short additional phone stream (likely transcoded), it holds approximately 1.015 GiB container memory and 1.104 GiB VmData. When every stream is stopped, CPU returns to idle and the large media socket disappears, but the allocation remains unchanged.
During longer operation, combined tunnel RSS and swap reached approximately 4.08 GiB on a VM with 3.7 GiB RAM and 4 GiB swap. Linux has repeatedly OOM-killed the tunneler at approximately 3.5 GiB anonymous RSS, temporarily taking down every service using that endpoint.
The same workload was subsequently run through a full ziti-router 2.0.0 using the Go TPROXY tunneler on the same host and network. With five simultaneous streams, that router peaked below 100 MiB working memory and released the active allocation to approximately 45.7 MiB after the streams stopped. This suggests the retained allocation is specific to the C endpoint-tunneler path rather than Jellyfin, Nginx, Docker, or the host.
Environment
- Architecture:
aarch64, ARM Neoverse-N1, 2 vCPU
- OS: Ubuntu 24.04.4 LTS
- Kernel:
6.8.0-134-generic (PREEMPT_DYNAMIC)
- RAM: 3.7 GiB
- Swap: 4 GiB
- Docker client: 23.0.3
- Docker server: 24.0.7
- Image:
openziti/ziti-edge-tunnel:1.18.1
- Image architecture:
arm64
- Image ID:
sha256:08746c4f567bee268dede3aa8474feac4a7baa1626f62b602f847bab188867c4
- Repo digest:
openziti/ziti-edge-tunnel@sha256:dcfdab9abd44f51b772feca36832798f49bb3e05ed369b9a08a4d51243eeeb0f
- Ziti C SDK reported by the process: 1.18.1, commit
356a6f7
- Controller and edge routers: 2.0.0,
amd64
- Container networking:
host
- Container privileges: privileged, with
/dev/net/tun
- Restart policy:
unless-stopped
- Intercept: private
/16, TCP and UDP, ports 1-65535
- Application path: public Nginx Proxy Manager -> local Ziti intercept -> Ziti fabric -> private Jellyfin TCP port 8096
The same operational symptom was previously seen with endpoint image 1.9.8. The controlled measurements below are from 1.18.1.
Current-version caveat
At the time of filing, the latest ziti-tunnel-sdk-c release is 1.18.3. Production was moved to the Go-router mitigation after the OOM incidents, so the complete reproduction has not yet been repeated on 1.18.3.
The ziti-tunnel-sdk-c history from 1.18.1 through 1.18.3 contains two partial-authentication changes and a CI action bump; I did not find an obvious TCP tunneler or memory-management change in that range. I can test a maintainer-recommended 1.18.3/debug build if needed.
Reproduction
- Restart only the
ziti-edge-tunnel container.
- Wait for controller authorization, both edge-router channels, and intercept routes to become ready.
- Confirm idle container memory near 10.5 MiB and tunnel swap at zero.
- Start two Jellyfin streams through the Nginx/Ziti path.
- Start a third stream from a phone. In this environment that stream appears to trigger a heavier/transcoding path.
- Observe the tunneler consume approximately one full CPU core and memory rise above 1 GiB.
- Stop the phone stream, then stop both original streams.
- Observe CPU return to idle and the media connections close while the approximately 1 GiB allocation remains.
Repeated workloads over longer uptime continue growing the process until the kernel OOM-kills it.
Measurements
Long-running state before controlled restart
After approximately 36 hours:
VmSize: 4592496 kB
VmRSS: 2454776 kB
VmData: 4316596 kB
VmSwap: 1824480 kB
VmRSS + VmSwap was 4,279,256 KiB (approximately 4.08 GiB).
Immediately after restarting only the tunneler
container memory: 10.27 MiB
VmSize: 262596 kB
VmRSS: 15164 kB
VmData: 52292 kB
VmSwap: 0 kB
Within 40 seconds it stabilized around 10.46 MiB container memory, 15.8 MiB RSS, 52.9 MiB VmData, and zero swap.
Idle failed-dial control
There are some removed/offline services in this network. Failed dials (no route to host, connection refused, and no online terminators) continued after restart.
Over approximately two idle minutes, the error-line count increased from 42 to 106 while memory barely changed:
VmRSS: 15812 -> 15872 kB
VmData: 52892 -> 52972 kB
VmSwap: 0 kB throughout
This noisy idle control did not produce the media workload's gigabyte-scale growth.
Active media state
High-frequency sampling recorded:
CPU: 99-104% continuously
container memory: approximately 1.015-1.053 GiB
VmRSS: approximately 1,067,000 kB
VmData: approximately 1,104,500 kB
VmSwap: 0 kB
threads: 5
Per-thread sampling showed the main ziti-edge-tunnel/libuv event-loop thread running near 100%, while all four libuv-worker threads were sleeping in futex_wait_queue.
One Nginx-owned intercepted Jellyfin socket showed:
bytes_received: 1,078,456,330
Recv-Q: 2,973,706
Send-Q: 0
The similarity between transported bytes and retained memory is notable, but does not prove that every transported byte was leaked.
After all streams stopped
For more than 30 seconds, all samples were unchanged:
CPU: 0.03-0.26%
container memory: 1.015 GiB
VmSize: 1380204 kB
VmRSS: 1067336 kB
VmData: 1104496 kB
VmSwap: 0 kB
The main thread was sleeping in ep_poll. The large media socket had disappeared. One small keepalive remained with empty queues. No measurable allocation was returned.
Relevant logs around stream termination
ERROR tunnel-sdk:tunnel_tcp.c:191 on_tcp_client_err() client=tcp:<intercept-ip>:<ephemeral-port> err=-14, terminating connection
WARN ziti-sdk:connect.c:231 close_conn_internal() conn[.../Closed](<media-service>) dumping 2633108 bytes of undelivered data
WARN ziti-sdk:channel.c:688 dispatch_message() received message without conn_id or for unknown connection ct[DataType] conn_id[680]
The unknown connection warning then repeated many times for connection IDs 679 and 680.
OOM evidence
The kernel recorded at least two OOM kills on 2026-07-13:
19:40:12 UTC: Out of memory: Killed process (ziti-edge-tunnel) total-vm:7899700kB, anon-rss:3566092kB
21:13:37 UTC: Out of memory: Killed process (ziti-edge-tunnel) total-vm:7888892kB, anon-rss:3588536kB
Each kill caused Docker to restart the endpoint and made all proxied services unavailable during reconnection.
Comparative mitigation result: Go router TPROXY
The C endpoint was replaced with openziti/ziti-router:2.0.0, configured with binding: tunnel and mode: tproxy, on the same ARM64 host and Ziti network.
During four HD streams plus a fifth phone stream for approximately 20 seconds:
peak router CPU: 128-161% across the 2-vCPU host
active working memory: approximately 83-98 MiB
health/restarts/OOM: healthy / 0 / false
After every stream stopped:
router CPU: approximately 1-2%
working memory: stable at 45.7 MiB
process swap: 0
health/restarts/OOM: healthy / 0 / false
Public Jellyfin health continued returning HTTP 200. This short comparative test did not reproduce the C tunneler's single-core saturation, rapid ~1 GiB growth, or failure to release the active working set.
Expected behavior
- Memory used for forwarded or undelivered media data should be released after delivery or connection termination.
- Sustained stream size should not cause unbounded process growth.
- Late data for a closed connection should be discarded without retaining buffers or leaving the event loop spinning.
Actual behavior
- A fresh endpoint grows by roughly 1 GiB during this media workload.
- The allocation remains after the streams and large media sockets are gone.
- Repeated workloads accumulate enough memory to force swapping and eventually OOM-kill the endpoint.
- During the active failure state, the single main event-loop thread consumes one full CPU core.
Interpretation and evidence boundary
Directly established:
- The retained memory is private process allocation, not filesystem cache or Docker overhead.
- Restarting only the tunneler releases it.
- Idle failed-circuit churn does not explain the observed growth rate.
- Media traffic is associated with full-core event-loop use and gigabyte-scale allocation.
- The allocation persists after media connections and CPU activity cease.
- The equivalent Go-router TPROXY workload remains below 100 MiB and releases most of its active working set.
Measurement limitation:
- The operator directly observed the rapid increase when the additional phone stream started. High-frequency remote sampling began after most of that increase and captured the resulting plateau, thread/socket state, and failure to release memory, rather than every allocation during the initial slope.
Strong hypothesis, not yet source-level proof:
- A buffer/message cleanup path associated with sustained TCP delivery, backpressure, or connection cancellation retains allocations.
- The
undelivered data cleanup and subsequent late DataType messages for already-closed connection IDs may be related.
Heap/allocator instrumentation or a maintainer-provided debug build is needed to identify the allocation site.
Questions for maintainers
- Is this a known issue in the C SDK/tunneler TCP buffer cleanup path, especially following cancellation or backpressure?
- Could
close_conn_internal() dumping undelivered data followed by late DataType messages leave message buffers retained?
- Is there a recommended ASan/LSan, allocator-profiling, or debug build for capturing the allocation site under this workload?
- Would you prefer a reproduction on 1.18.3, and are there specific logging or build flags that would make it useful?
- What additional sanitized circuit/socket metadata would help narrow this down?
Privacy
Public/private addresses, domains, identity material, controller names, circuit IDs, and authentication output have been removed. Exact timestamps and additional sanitized logs can be provided if useful.
Summary
ziti-edge-tunnel1.18.1 on ARM64 reproducibly retains a large anonymous/data allocation after forwarding Jellyfin media traffic.A clean process starts at approximately 10.5 MiB container memory and 52.9 MiB
VmData. After a multi-stream workload with a short additional phone stream (likely transcoded), it holds approximately 1.015 GiB container memory and 1.104 GiBVmData. When every stream is stopped, CPU returns to idle and the large media socket disappears, but the allocation remains unchanged.During longer operation, combined tunnel RSS and swap reached approximately 4.08 GiB on a VM with 3.7 GiB RAM and 4 GiB swap. Linux has repeatedly OOM-killed the tunneler at approximately 3.5 GiB anonymous RSS, temporarily taking down every service using that endpoint.
The same workload was subsequently run through a full
ziti-router2.0.0 using the Go TPROXY tunneler on the same host and network. With five simultaneous streams, that router peaked below 100 MiB working memory and released the active allocation to approximately 45.7 MiB after the streams stopped. This suggests the retained allocation is specific to the C endpoint-tunneler path rather than Jellyfin, Nginx, Docker, or the host.Environment
aarch64, ARM Neoverse-N1, 2 vCPU6.8.0-134-generic(PREEMPT_DYNAMIC)openziti/ziti-edge-tunnel:1.18.1arm64sha256:08746c4f567bee268dede3aa8474feac4a7baa1626f62b602f847bab188867c4openziti/ziti-edge-tunnel@sha256:dcfdab9abd44f51b772feca36832798f49bb3e05ed369b9a08a4d51243eeeb0f356a6f7amd64host/dev/net/tununless-stopped/16, TCP and UDP, ports 1-65535The same operational symptom was previously seen with endpoint image 1.9.8. The controlled measurements below are from 1.18.1.
Current-version caveat
At the time of filing, the latest
ziti-tunnel-sdk-crelease is 1.18.3. Production was moved to the Go-router mitigation after the OOM incidents, so the complete reproduction has not yet been repeated on 1.18.3.The
ziti-tunnel-sdk-chistory from 1.18.1 through 1.18.3 contains two partial-authentication changes and a CI action bump; I did not find an obvious TCP tunneler or memory-management change in that range. I can test a maintainer-recommended 1.18.3/debug build if needed.Reproduction
ziti-edge-tunnelcontainer.Repeated workloads over longer uptime continue growing the process until the kernel OOM-kills it.
Measurements
Long-running state before controlled restart
After approximately 36 hours:
VmRSS + VmSwapwas 4,279,256 KiB (approximately 4.08 GiB).Immediately after restarting only the tunneler
Within 40 seconds it stabilized around 10.46 MiB container memory, 15.8 MiB RSS, 52.9 MiB
VmData, and zero swap.Idle failed-dial control
There are some removed/offline services in this network. Failed dials (
no route to host,connection refused, andno online terminators) continued after restart.Over approximately two idle minutes, the error-line count increased from 42 to 106 while memory barely changed:
This noisy idle control did not produce the media workload's gigabyte-scale growth.
Active media state
High-frequency sampling recorded:
Per-thread sampling showed the main
ziti-edge-tunnel/libuv event-loop thread running near 100%, while all fourlibuv-workerthreads were sleeping infutex_wait_queue.One Nginx-owned intercepted Jellyfin socket showed:
The similarity between transported bytes and retained memory is notable, but does not prove that every transported byte was leaked.
After all streams stopped
For more than 30 seconds, all samples were unchanged:
The main thread was sleeping in
ep_poll. The large media socket had disappeared. One small keepalive remained with empty queues. No measurable allocation was returned.Relevant logs around stream termination
The
unknown connectionwarning then repeated many times for connection IDs 679 and 680.OOM evidence
The kernel recorded at least two OOM kills on 2026-07-13:
Each kill caused Docker to restart the endpoint and made all proxied services unavailable during reconnection.
Comparative mitigation result: Go router TPROXY
The C endpoint was replaced with
openziti/ziti-router:2.0.0, configured withbinding: tunnelandmode: tproxy, on the same ARM64 host and Ziti network.During four HD streams plus a fifth phone stream for approximately 20 seconds:
After every stream stopped:
Public Jellyfin health continued returning HTTP 200. This short comparative test did not reproduce the C tunneler's single-core saturation, rapid ~1 GiB growth, or failure to release the active working set.
Expected behavior
Actual behavior
Interpretation and evidence boundary
Directly established:
Measurement limitation:
Strong hypothesis, not yet source-level proof:
undelivered datacleanup and subsequent lateDataTypemessages for already-closed connection IDs may be related.Heap/allocator instrumentation or a maintainer-provided debug build is needed to identify the allocation site.
Questions for maintainers
close_conn_internal()dumping undelivered data followed by lateDataTypemessages leave message buffers retained?Privacy
Public/private addresses, domains, identity material, controller names, circuit IDs, and authentication output have been removed. Exact timestamps and additional sanitized logs can be provided if useful.