Janus memory leak — root cause (2026-09)
Root cause: vuer_oss strands live janus sessions. It is a FaceKom bug, not an upstream janus bug.
Four years of tickets (ASSRAFIPI-38 2024 → SLARAFIPI-83 2026, FKITSYS-2994 → FKITSYS-9871 at Cofidis, FKITDEV-8570 / FKITDEV-8573 / DAP-1141 at DÁP) have been closed on the premise “Janus leaks, it’s third-party, upgrade it.” That premise is wrong. The leak is driven by our own signalling code failing to call destroy(), and by our own 8-year-old omission of videoroom room destruction.
For Agents
Every mechanism claim below was verified from source in
vuer_oss+@techteamer/janus-api; every number tagged MEASURED came from a live janus via the Admin API. File:line citations are the value of this note — preserve them when editing. 2026-09-07: the leak is now measured end to end on real browser calls with real media — see Part 8, which supersedes the old inferred per-session figure and corrects the “a cleanly ended call is fine” half of the hypothesis. Ticket dossiers are raw transcripts and are linked, never restated.
The one-line answer for the customer
Restarting janus is not a workaround for a third-party defect — it is masking our own session leak. Upgrading janus will not fix it, and we now have evidence that it never has: no partner has ever had a post-upgrade memory measurement, and the partner on the newest janus leaks fastest.
2026-09-07 — MEASURED, and the hypothesis is corrected
Real browser calls with real media on an isolated stack, 10 cycles per arm, janus RSS + Admin API census after every cycle: abandoned call ≈ 0.8–1.0 MB (1 session + 1 handle + 1 populated videoroom, permanent) — cleanly ended call ≈ 453 kB (1 videoroom, never destroyed). Both arms leak. “Abandoned calls leak, properly ended calls are fine” was only half right. Detail in Part 8; two new side-defects in Part 9; the previously-open cron question is answered in Part 10.
Verdict table
| Question asked for 4 years | Answer |
|---|---|
| Is this an upstream janus memory leak? | No. The TechTeamer fork changes zero lines of janus C source, and the upstream issue cited since 2024 (meetecho/janus-gateway#3408) does not apply — see Debunk. |
Will the version upgrade in SLARAFIPI-52 / the .101 release fix it? | No. Upgrading 1.2.4-dev → v1.4.1 gains exactly one unconditionally-applicable leak fix (a char * in the recorder). It cannot account for 126 MB/day. |
| What actually causes it? | vuer_oss creates janus sessions it never destroys. A stranded session keeps pinging janus forever, so janus can never time it out. Plus: videoroom rooms are never destroyed at all. MEASURED 2026-09-07 (Part 8): an abandoned call permanently strands 1 session + 1 handle + 1 populated room. |
| Is a properly ended call safe? | No — measured. Sessions and handles are reclaimed on the clean path, but the videoroom never is: 10 clean calls left 10 rooms behind, ≈453 kB each, monotonic. Part 8, Arm A. |
| How much does one call actually cost? | ~0.8–1.0 MB abandoned, ~453 kB clean — MEASURED, reproduced in a second independent run. This supersedes the old inferred 0.5–1.5 MB range. |
| Is there any mitigation available today? | Partially. AutoCloseRoomsCronJob really does reclaim the abandoned-call path (chain verified in source) — but only if roomAutoCloseHours is set, and it has no default; and it can never reach the VuerCVListenerSession path. Part 10. |
| Why is it “per unit time” at some sites and not others? | It isn’t per-unit-time anywhere. It is per call. Two independent confirmations in Part 5. |
Does Raiffeisen get a new janus in .101? | Not as things stand — janus is absent from all ten Raiffeisen release manifests. See Part 6. |
Part 1 — Why a stranded session is immortal
This is the crux, and it is counter-intuitive. A leaked JS object would normally be harmless to the server: the process dies or the socket drops, janus times the session out, memory comes back. That safety net does not exist here, because a leaked Janus object keeps itself alive and keeps janus’s copy alive too.
janus-api/src/Janus.js:407-425 — keepAlive() reschedules itself with a setTimeout closure that captures this:
- The closure is a GC root. V8 cannot collect the
Janusobject, no matter how many application references were dropped. - The object is not merely retained, it is actively pinging. Its websocket stays open, so janus sees a healthy session and never times it out.
keepAliveIntervalMs: 30000against janus’s defaultsession_timeoutof 60 s is a 2× margin — the ping always wins.
session_timeoutis set in no FaceKom janus configChecked
vuer_docker,vuer_build,vuer-release. Everything runs on the 60 s default, which the 30 s keepalive defeats by construction. There is no configuration escape hatch here.
A crash or disconnect leaks nothing (MEASURED)
MEASURED on fk-dev: janus destroys sessions in under 5 seconds when the websocket dies. 40 sessions were opened, then their sockets terminated with no destroy and no keepalive; the session count went 44 → 4 within 5 s.
Consequence
Every “the pod crashed / the network blipped / the container restarted” theory is dead. Janus’s own reaping works correctly. The only way to strand a session is a live, pinging JS object — i.e. our code holding a
Janusinstance it forgot to destroy.
destroy() is not the bug
Janus.destroy() is correct: idempotent, and it clears the keepalive on the success path, the rejection path and the 5 s-timeout path. The clean end-of-call flow is correct on both the operator and the customer side.
Diagnostic shortcut
The bug is always “destroy was never called”, never “destroy is broken”. When hunting a new leak path, look for an owner that lost its reference or an early
throw— do not auditdestroy()again.
Part 2 — Leak paths, ranked
1. VuerCVListenerSession — leaks on every invocation, success path included
vuer_oss/server/cv/VuerCVListenerSession.js:62
The class has no destroy() and no close(), and _janus.destroy() appears nowhere in either repo. The owner, SelfServiceTransportSession.terminate() (server/transport/session/SelfServiceTransportSession.js:392-404), closes only this.janus — but the listener holds a second, independent janus session.
Cost per invocation:
- 1 session
- 2 handles
- 1 recorder (
record: trueatVuerCVListenerSession.js:150) - up to 2 PeerConnections
This is the dominant term. It does not need an error, a timeout, or an unusual user action; the happy path leaks.
2. RoomTransportSession — leaks on any call not ended through VideoChatService.close()
vuer_oss/server/service/VideoChatService.js:201-233
The operator closing the browser tab emits videochat:leave → handleVideoChatLeave(), which writes an activity row and notifies vuer_css, but never closes the room and never touches the transport. Four compounding facts:
vuer_cssterminate()is literallyreturn Promise.resolve()— a no-op.TransportPool.sessionsis a plainMapwith no TTL and no sweep.- No base-install cron reclaims videochat rooms.
- The only reclaimer is the
AutoCloseRoomsCronJobcustomization, gated onroomAutoCloseHours— so partners without that setting have nothing at all.
Both halves of this are now measured — and there is a third consequence
Part 8, Arm B: ten abandoned calls left 10 stranded sessions + 10 handles + 10 populated rooms, still alive long after the run finished. Part 9: the vuer Room row also stays
status='incall'forever, and that blocks the operator from taking any further call. TheAutoCloseRoomsCronJobbullet above is correct but incomplete — the verified chain and its three constraints are in Part 10.
3. RoomInspector.connect() failure — strands a session with zero references
vuer_oss/server/socket/events/videochat.js:348-357
client.roomInspector = inspector is assigned after the await. If connect() throws, the assignment never happens, so the disconnect handler cannot see the session to clean it up. Nothing in the process holds a reference — except the keepalive closure, which holds it forever.
This is triggered by normal operation, not by an incident: findRoom() throws "Video room not found" for rooms nobody has published into yet.
4. VuerCVSession — teardown registered after the connect
vuer_oss/server/cv/VuerCVSession.js registers its teardown (task.result…finally) at line 87, but calls _connectJanus() at line 58. Anything that throws in between strands the session.
Adjacent defect in the same subsystem: CVTask never arms its timeout. server/cv/CVTask.js:6-28 — this.timeout is undefined at the moment super() runs. Affects LivenessTask, LivenessV2Task, ActionTask, DocumentTask, HoloV2Task, SpeechTask, PadTask, MRZTask.
5. Concurrent videochat:senderPeer:init — orphans handles, not sessions
VideoRoomPublisherJanusPlugin does not override hangup(), unlike the listener plugin, so a publisher handle never self-detaches. Smaller blast radius (handles only) but the same root shape.
Part 3 — Secondary FaceKom defect: videoroom rooms are never destroyed
Independent of the session leak, and just as much ours.
janus-api’s videoroom plugins send only:join,start,rtp_forward,stop_rtp_forward,edit,list,create,configure,listparticipants. Nodestroy.vuer_osssends none either.RoomTransportSession.closeJanus()ends atjanus.destroy(), which is a session destroy, not a room destroy.- One janus room is created per vuer Room DB id (
id: this.getRoomId()), withrecord: true. - Present since janus-api’s first commit, 2018-01-12. This is an 8-year-old design gap, not a regression — which is exactly why no upgrade and no bisect has ever found it.
Observed directly on real calls, 2026-09-07
Ten cleanly ended calls left ten videorooms behind (Part 8, Arm A) —
rooms_delta: 10againstsessions_delta: 0. This is no longer a source reading, it is a measurement, and it means the room leak is not conditional on anything going wrong. Every call ever made on every FaceKom deployment has left its room behind.
How much does it actually cost? (MEASURED)
~6.0 kB retained per abandoned room. Too small to be the dominant term: 100 MB/day would require ~17 000–21 000 rooms/day.
But it has a compounding partner that is not small:
findRoom()pulls the ENTIRE room list and linear-scans it by descriptionOn every publisher connect and every inspector connect. MEASURED: 483 bytes/room, linear across 6 points — 3.0 KB @ 6 rooms → 969 KB / 69 ms @ 2006 rooms. Extrapolated: ~9.7 MB of list payload per call setup at 20 000 rooms, plus the CPU to marshal and scan it.
So the room leak’s real cost is not the 6 kB of retained room state — it is that an unbounded room list turns every single call setup into a multi-megabyte allocation and a linear scan.
Destroying rooms caps growth but does not reclaim (MEASURED)
MEASURED: 2000 rooms destroyed, the room list returned to baseline — but RSS did not return to the OS (glibc arena behaviour). Room destruction caps growth; it does not reclaim what has already been mapped. Plan fixes accordingly: this is a preventive measure, not a recovery one.
Also measured
400 signalling-only sessions + handles ≈ 31.7 kB each. This is the floor for a session that never gets as far as media.
Part 4 — Upstream and fork facts
The fork is a packaging wrapper, nothing more
The TechTeamer/janus-gateway fork changes ZERO lines of janus C source
*.c,*.h,src/,configure.acandMakefile.amare byte-identical to upstream. The fork’s entire content is CI/packaging:.travis.yml,test/check_janus.sh, npm demo tooling. ⇒ Any theory of the form “our fork introduced or retained the leak” is dead, and any upstream fix lands in us unmodified once we take the commit.
Commit → version map (verified from configure.ac AC_INIT)
| Fork commit | Version | Notes |
|---|---|---|
b8bebd94 | 0.13.4 | What Raiffeisen and Cofidis actually run |
08f25c9b | 1.2.4 | Actually a pre-release 1.2.4-dev snapshot — upstream master @ bad60d70 (2024-08-02); git describe = v1.2.3-11-gbad60d70 |
cc0fdca8 | 1.4.1 | Exactly upstream v1.4.1 |
af80f7ef | 0.16.1 |
GOTCHA — the fork ships its own tags that shadow upstream's
The fork carries its own tags named
v1.2.4,v1.3.0,v1.4.0,v1.4.1pointing at fork commits, andgit fetch upstream --tagssilently refuses to overwrite them (exit 0). ⇒ Anygit diff v1.2.4..v1.4.1in a fresh clone is WRONG. Refetch upstream tags into their own namespace:+refs/tags/*:refs/tags/up/*. Same family as narrowed-fetch-refspec-stale-devel-merge — a fetch that reports success while leaving you on stale refs.
DEBUNK — upstream issue #3408 does not apply
ASSRAFIPI-38 has cited meetecho/janus-gateway#3408 as the relevant leak since 2024. It does not apply:
- It was closed 2024-10-23.
- Root cause was the Lua plugin, fixed by PR #3409, whose entire diff is
g_thread_unref(g_thread_self())injanus_lua.candjanus_duktape.c. - Both plugins are excluded from our build by
--disable-all-plugins.
⇒ The single most-quoted piece of evidence in the whole four-year ticket chain is about code we do not compile.
What the upgrade actually buys
Upgrading 1.2.4-dev → v1.4.1 gains exactly one unconditionally-applicable leak fix:
4419baf0(2024-12-10) —janus_recorder_free()never freedrecorder->description.
Four other leak fixes in that range are conditional and do not apply here: SVC, dummy publishers, RTP forwarders, remote publishers.
One deserves its own note because it looks applicable and is not:
4fc066ff(videoroom subscriber refcount leak inslow_link) does not applyIt is gated on
slowlink_threshold > 0.slowlink_thresholdappears in NO FaceKom config ⇒ default0⇒ the code path is disabled. Caveat: Raiffeisen ships its own.jcfgvia its own Dockerfile layer, so confirm this on their box before quoting it to them.
No open upstream leak issues affect videoroom + websockets. 14 upstream commits exist after v1.4.1; none is a leak fix.
Build configuration
--enable-post-processing --disable-data-channels --disable-all-plugins \
--enable-plugin-echotest --enable-plugin-videoroom \
--disable-all-transports --enable-websockets
- The legacy
vuer_build/base/janus/Dockerfilealso passes--enable-rest(the HTTP transport is compiled in). The newervuer-releasebase does not. Relevant to attack surface and to any “same build everywhere” assumption. - Latent build bug in the
vuer-releasebase janus Dockerfile: it copieslibwebsockets.so.19but symlinkslibwebsockets.so→libwebsockets.so.16, which does not exist. Dangling; harmless at runtime because linking resolves via SONAME — but it will bite the first person who tries to build against it. - Pinned deps are aging: libnice 0.1.17 (2020), libsrtp 2.5.0, libwebsockets 4.3.2.
Part 5 — Cross-partner picture
It is per-call, not per-unit-time
Two independent confirmations:
- fk-dev janus idle 16 days = 20 MB RSS. Sixteen days of wall-clock with no calls produces essentially nothing.
- DÁP Grafana shows RSS FLAT across the whole weekend, then stepping up during business hours.
This finally answers the question ASSRAFIPI-38 asked in 2024 and never got answered
“Látjátok már, hogy ezt a memória elfogyást mi okozza?” — yes: it scales with call volume, because each call strands sessions. Idle time costs nothing.
Per-partner status
| Partner | Janus | Platform | Budget | Observed rate | Consequence |
|---|---|---|---|---|---|
| Raiffeisen — SLARAFIPI-83, ASSRAFIPI-38, FKITDEV-9239 | 0.13.4 | AWS t3.large | maxmemory 7168 MB | ~126 MB/day (longest clean run 03/05 → 04/28, 2% → 97%) | ~8 weeks to exhaustion; mitigation = manual restart only |
| DÁP / NISZ — DAP-1141, FKITDEV-8570, FKITDEV-8573, DAP-804 | 1.2.4 | Kubernetes, 4 pods | 8 GiB limit | ~0.7 GiB/day/pod | OOMKilled after 13 days. DAP-1141 was closed on “the upgrade process has started” — never verified |
| Cofidis — FKITSYS-2994, FKITSYS-4462, FKITSYS-5003, FKITSYS-5440, FKITSYS-6721, FKITSYS-9773, FKITSYS-9871, BUGCOFI-264, COFIDISFACEKOM-775 | 0.13.4 (still pinned) | 1 GB (2022) → 2.7 GB (2024-06) → 5 GB+ (2024-11) | container once observed “Up 8 months” | ||
| UniCredit — FKITSYS-9902 | build def says 1.4.1; running image on 2026-08-26 was janus:1.2.4.1-20220513 | see warning below |
Build definition ≠ deployed reality
UniCredit’s manifest claims 1.4.1; the box was running a 2022 image. Never answer a version question from a build definition — read the running image.
The observation that kills the upgrade theory
DÁP, on the newer janus (1.2.4), leaks ~5× faster than Raiffeisen on the older 0.13.4. Different workloads, so it is not a clean comparison — but it is the opposite of what “newer is fixed” predicts.
No partner has EVER had a post-upgrade memory measurement
“Upgrade will fix it” has closed multiple tickets over four years and has never once been verified. If we upgrade again without a before/after measurement, we will be back here in 2027.
Fleet janus build definitions
| Janus | Partners |
|---|---|
| 1.4.1 | cib, generali-atvilagitas, instacash, magnet, microsec, nusz, raiffeisen |
| 0.16.1 | barion, fundamenta, granit, kh, mbh, mkb-instant, szerencsejatek |
| 0.13.4 | cofidis alone |
These are build definitions — see the UniCredit warning above before treating any row as deployed truth. Partner ↔ YouTrack-suffix mapping: client-registry.
Keep separate — this is not the Node.js leak
Do not fold FKITDEV-9193 into the janus story
FKITDEV-9193 is a distinct, confirmed Node.js leak in
vuer_ossStorageService._getInFlightCache/stream(), and it likely drives FKITSYS-9902 (UniCredit, OSS alone at 17.6 GB). Different process, different mechanism, different fix. Conflating them will produce a wrong RCA in both directions.
Part 6 — Release blocker (actionable)
Janus is absent from
COMPONENT_LISTin all ten Raiffeisen release manifests
vuer-release/projects/raiffeisen/release/1..10/release.json— every single one is[vuer_oss, vuer_css].
The JANUS_VERSION_COMMIT field that flips from 08f25c9b to cc0fdca8 at rel6 (1.9.11.94) is an attribute on the vuer_oss component — it records what vuer_oss is built against. It is not a janus image.
⇒ No janus image has ever been built or shipped to Raiffeisen. They remain on 0.13.4. Confirmed independently by Szabó Márton in FKITDEV-9239:
“Mióta vuer-release repót használnak (2026-03), azóta nem kaptak új janus komponenst. Jelenleg a 0.13.4-es van náluk. Következő release-nél azt is kell buildelni, nem csak a vuer_css és vuer_oss-t.”
Janus must be added to the build for .101 or nothing changes — and note that even once it is added, Part 4 says the upgrade alone will not stop the growth. These are two separate asks:
- Add janus to the
.101manifest, so the shipped version stops lying and the CVE backlog in SLARAFIPI-52 / FKITDEV-8745 actually gets addressed. - Fix the
vuer_osssession and room leaks. That is what stops the memory growth.
Related build-path context: vuer-release-build-flow, vuer-build-never-pushes (Cofidis and CIB are on the legacy path, so how their janus reaches Harbor is not established).
Part 7 — Diagnostic tooling
Validated read-only diagnostic scripts live at /Users/levander/coding/facekom/out/janus-memory/.
| Script | Purpose |
|---|---|
janus-probe.js | Read-only census via the Admin API. Reports session count, handle count, videoroom room count, abandoned-empty-room count, and list payload size. |
roomleak.js | Room accumulation + list-growth benchmark (source of the 483 bytes/room figure). |
sessleak.js | Per-session cost benchmark (source of the 31.7 kB figure). |
abandon.js | Proves janus reaps sessions on transport drop (the 44 → 4 in 5 s measurement). ⚠️ Not present on disk as of 2026-09-02 — only the three above are. Recreate it if that proof needs re-running. |
leak-harness.js | Added 2026-09-07. The real-call harness — drives both arms end to end with two real browsers and real media, censusing after every cycle. Source of Part 8. |
cron-verify.sh | Added 2026-09-07. Verifies the AutoCloseRoomsCronJob chain (Part 10). |
leakproof.js, heapdig.js, verify-claims.js, rss.sh | Added 2026-09-07. Supporting probes and RSS sampling. |
The Admin API is already enabled — no deployment change is needed to run the census:
- endpoint
wss://janus:7989 adminSecretjanusoverlordJanusAdminalready exists injanus-api, withlistSessions/listHandles/handleInfo
Run pattern on fk-dev (see dev-build-host, fk-dev-deploy-smoke-runbook):
scp <script>.js fk-dev:/tmp/
docker cp /tmp/<script>.js vuer_oss:/tmp/
docker exec vuer_oss node /tmp/<script>.jsDecision rule for reading a production census
Three outcomes, three different root causes
- Rooms high + sessions low → rooms are never destroyed (Part 3).
- Sessions ≫ concurrent calls → stranded, keepalive’d sessions (Parts 1–2). This is the expected result.
- Both low but RSS high → the leak really is inside janus, and only then does the upstream story become relevant again.
Part 8 — Empirical proof: measured on real calls (2026-09-07)
The leak is now MEASURED end to end — and the measurement CORRECTS the earlier hypothesis
~0.8–1.0 MB per abandoned call and ~453 kB per cleanly ended call, both monotonic across 10 cycles. Both arms leak. The pre-measurement story — “abandoned calls leak, clean calls are fine” — was only half right.
The rig
An isolated full stack on fk-dev — deliberately not the shared dev deployment: postgres + rabbitmq + janus + vuer_oss + vuer_css on their own bridge network, own database, own TLS proxy. Compose project vuerleak2, at /workspace/_leak2/ on fk-dev.
| Endpoint | Port |
|---|---|
| oss (https) | 20443 |
| css (https) | 20444 |
| janus | wss 18990, Admin API 17990 |
The media was verified real, not stubbed: two <video> elements per side, 640×480, with advancing currentTime — i.e. actually decoded remote frames, not a black canvas or a signalling-only handshake. This is what makes these numbers different from the synthetic benchmarks in Parts 3 and 7: full ICE/DTLS/SRTP/recorder state really was allocated.
Instrumentation: janus process RSS plus a full Admin API census (sessions / handles / videoroom rooms) sampled after every cycle.
The stack is still holding the leak state — it is evidence
vuerleak2was deliberately left up on fk-dev with the leaked sessions and rooms in place. Take a census before anyonedocker compose downs it.
Arm A — CLEAN (operator ends the call properly)
The operator ends the call the way a real operator does: [data-action="leave"] → [data-dialog-name="leave-dialog"] [data-action="confirm"]. 10 cycles.
| baseline | after cycle 10 | delta | |
|---|---|---|---|
| janus sessions | 0 | 0 | sessions_delta: 0 |
| janus handles | 0 | 0 | 0 |
| videoroom rooms | 0 | 10 | rooms_delta: 10 |
| janus RSS | 14 092 kB | 20 964 kB | rss_delta_kb: 6872 |
≈ 453 kB per cleanly-ended call, monotonic across all ten cycles.
A perfectly ended call still leaks
Sessions and handles are reclaimed correctly on the clean path —
destroy()works, exactly as Part 1 said it does. But the videoroom is never destroyed: one empty room per call, forever. Caveat on how to quote this: in this arm part of the RSS growth may be allocator retention rather than live state. The room count is the hard evidence here, not the kilobytes.
Arm B — ABANDONED (customer tab killed mid-call)
10 cycles.
| baseline | after cycle 10 | delta | |
|---|---|---|---|
| janus sessions | 0 | 10 | sessions_delta: 10 |
| janus handles | 0 | 10 | +10 |
| videoroom rooms | 0 | 10 (populated) | rooms_delta: 10 |
| janus RSS | 14 152 kB | 24 216 kB | rss_delta_kb: 10064 |
≈ 794–999 kB per abandoned call, monotonic. Reproduced in a second independent run: rss_delta_kb: 9992 ⇒ rss_per_cycle_kb: 999.2.
Every abandoned call permanently costs 1 janus session + 1 handle + 1 populated videoroom.
The state is permanent — verified well after the runs finished
An independent census taken long after both arms had completed still found:
- 9 janus sessions still alive
- 10 videorooms, 9 of them still holding participants
- 10 vuer Room rows stuck at
status='incall'in the database
Nothing reclaimed any of it. This is Part 1 — “a stranded session is immortal” — confirmed on real media sessions rather than on synthetic signalling sessions.
What this changes
| Earlier claim | Status after measurement |
|---|---|
| ~0.5–1.5 MB per stranded session (inferred) | SUPERSEDED ⇒ ~0.8–1.0 MB per abandoned call, MEASURED |
| ”abandoned calls leak” | Confirmed — session + handle + populated room, permanently |
| ”a properly ended call is fine” | WRONG — a clean call still leaks one videoroom, ≈453 kB |
| rooms are never destroyed (Part 3, from source) | Observed directly — 10 clean calls ⇒ 10 rooms |
| ~6.0 kB retained per abandoned room (Part 3, synthetic) | Still true as retained room struct; the per-clean-call RSS cost is ~75× larger because a real call allocates far more than the room |
Artifacts
Raw data and tooling: /Users/levander/coding/facekom/out/janus-memory/
| File | What it is |
|---|---|
leak-harness.js | The real-call harness — drives both arms end to end |
janus-probe.js | Read-only Admin API census — prod-safe |
cron-verify.sh | Verifies the AutoCloseRoomsCronJob chain (Part 10) |
leakproof.js, heapdig.js, verify-claims.js, rss.sh | Supporting probes and RSS sampling |
janus-leak.heapsnapshot | V8 heap snapshot |
arm-clean.json, arm-abandoned.json, arm-abandoned2.json | Per-cycle census + RSS, raw, for the three runs |
FKITDEV-janus-leak-ticket.md | Draft dev ticket |
Test code is pushed on branch chore/FKITDEV-9239-e2e-janus-memleak in vuer_oss + vuer_docker. Building the harness required beating seven separate environment traps — those are their own note: vuer-browser-e2e-real-call-gotchas.
Part 9 — Two side-defects found while measuring (2026-09-07)
Neither is a memory bug. Both are user-visible, and both were invisible until somebody drove a real call end to end.
1. An abandoned call leaves the Room row at status='incall' FOREVER — and that takes the operator out of service
handleVideoChatLeaveonly writes an activity row. It does not close the room.- The only thing that closes a room is
videochat:close. - No cron covers operator videochat rooms.
CloseExpiredSelfServiceRoomsCronJobis, exactly as its name says, self-service only.
Operational consequence — ONE abandoned call and the operator can no longer take calls
With a room left at
incall,videochat:createRoomrefuses to open a second room for that operator (operator_in_open_room). After a single abandoned call that operator is out of service until somebody closes the room by hand. This is an availability bug, not just a memory bug — and it is a plausible explanation for “the operator cannot receive calls” reports that were never connected to the janus story.
2. Multi-role users silently cannot answer calls (role gate)
videoChat.receiveCall is granted only to the operator role in config/roles.json. But:
WebServerAuth.js:934setsreq.session.role = req.user.getMainRole().getMainRole()(server/db/model/user.js:187) returns the first entry ofacl.roleListpresent in the user’s rights ⇒adminfor any multi-role user.
The failure is silent, three layers deep:
- without the right,
waitinglist.script.js:113never callsreceiveAvailable(true); - so
.can-receive-callis never added; - so
WaitingList.styl:16keeps.customer-item-actionsatdisplay: none.
Fix — do what the app itself does: POST /api/role-switch with document.body.dataset.csrftoken. That is exactly the call default.layout.js:48 makes.
Symptom → cause shortcut
“Operator is logged in, sees the waiting customer, but there is no accept button” ⇒ role, not permissions data, not CSS, not sockets.
Part 10 — The cron question, answered (2026-09-07)
Part 2 named AutoCloseRoomsCronJob as the only reclaimer but left its actual behaviour open. It is now traced end to end in source.
customization/cron/AutoCloseRoomsCronJob.js, schedule */5 * * * *:
AutoCloseRoomsCronJob
→ queueClient.roomCron.autoClose (cron.js:139 — publisher)
→ queue-room-cron
→ queue_server/RoomCron (server.js:509 — consumer)
→ RoomService.autoClose (RoomService.js:447)
→ videochat.close()
→ roomTransport.destroy()
→ closeJanus()
→ janus.destroy()
The chain works. And it can genuinely reach an abandoned call, because transports leave TransportPool.sessions only via destroySession() / rpcDestroy() — nothing removes them when the socket dies. The stranded transport is therefore still sitting in the pool for the cron to find.
⇒ AutoCloseRoomsCronJob is a real mitigation for the abandoned-call path — the first deployable lever this investigation has produced. But three constraints bound it hard:
Three constraints — read all three before promising a partner anything
(a) Double-gated on
roomAutoCloseHours, which has NO DEFAULT. The config docs say it outright: “Nincs alapértelmezett érték.” Unset ⇒ the cron is a no-op. Every partner without that value has no mitigation at all — the situation Part 2 flagged. (b) It structurally CANNOT clean theVuerCVListenerSessionpath.SelfServiceTransportSession.terminate()has zero references tovuerCVListenerSession. The dominant leak path of Part 2 is out of the cron’s reach by construction. (c) It iscreatedAt-based, not last-activity-based. So it cannot be tuned aggressively — a short window would kill live long calls. It is a coarse backstop, never a fix.
Part 11 — Caveats and open items
SUPERSEDED — the per-stranded-session cost is no longer an inference
The ~0.5–1.5 MB range that used to stand here was an inference extrapolated from signalling-only sessions (31.7 kB, measured synthetically). It is superseded by Part 8: ~0.8–1.0 MB per abandoned call and ~453 kB per cleanly-ended call, measured on real browser calls with real media, 2026-09-07, reproduced. Quote Part 8. Do not quote the old range.
Still open:
- No production census has ever been taken. Run
janus-probe.jsagainst a real partner janus (Raiffeisen or DÁP) — the mechanism and the per-call cost are now measured on an isolated rig, but the Part 7 decision rule has still never been applied to a live partner. - Confirm
slowlink_thresholdon Raiffeisen’s box specifically — they ship their own.jcfgvia their own Dockerfile layer. - Audit
roomAutoCloseHoursper partner. Unset = the one existing mitigation is inert (Part 10). - Recreate
abandon.jsif the transport-drop proof needs re-running. - Decide the fix shape:
- (a)
destroy()calls on the five leak paths of Part 2; - (b) a room destroy in
RoomTransportSession.closeJanus()— Part 8 proves this is required even for perfectly ended calls; - (c) a TTL/sweep on
TransportPool.sessions; - (d) close the Room row on
videochat:leave, so one abandoned call stops taking an operator out of service (Part 9). - (a) and (b) are independent and both needed; (c) and (d) are the operational hardening.
- (a)
Related
Analysis lives here. Raw ticket transcripts live in the dossiers — link, do not restate.
- Raiffeisen thread: SLARAFIPI-83 → FKITDEV-9239; predecessor ASSRAFIPI-38; OS/version context SLARAFIPI-52; incident ASSRAFIPI-54
- DÁP thread: DAP-1141, FKITDEV-8570, FKITDEV-8573, DAP-804
- Cofidis thread: FKITSYS-2994, FKITSYS-4462, FKITSYS-5003, FKITSYS-5440, FKITSYS-6721, FKITSYS-9773, FKITSYS-9871, BUGCOFI-264, COFIDISFACEKOM-775
- Other sites: FKITSYS-3921 (Bankmonitor OOM), FKITSYS-9827 (Generali), BUGMNET-33 (Magnet), FKITDEV-5590 (Polgári Bank), FKVKTA-228
- Janus version / upgrade work: FKITDEV-7259, FKITDEV-4638, FKITDEV-9043 (1.4.1 tag), FKQA-334, FKITDEV-8745 (vulnerability patches)
- Explicitly NOT this bug: FKITDEV-9193 (Node.js
StorageServiceleak), FKITSYS-9902 (UniCredit OSS 17.6 GB) - Memos: FKPRE-598 (2024-06-07 Resource Retro), ORBIT-21
- Harness / reproduction: vuer-browser-e2e-real-call-gotchas (the seven traps that stand between you and a scripted real call), vuer_docker, k6-e2e-harness-vuer-oss
- Architecture / ops: fk-dev-deploy-smoke-runbook (the browser never connects to janus — signalling is server-to-server), dev-build-host, infrastructure, vuer-release-build-flow, vuer-build-never-pushes, client-registry
- Gotcha family: narrowed-fetch-refspec-stale-devel-merge
- Index: Ticket Investigations, FaceKom Topics, FaceKom Activity Log