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-2994FKITSYS-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 yearsAnswer
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-425keepAlive() reschedules itself with a setTimeout closure that captures this:

  • The closure is a GC root. V8 cannot collect the Janus object, 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: 30000 against janus’s default session_timeout of 60 s is a 2× margin — the ping always wins.

session_timeout is set in no FaceKom janus config

Checked 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 Janus instance 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 audit destroy() 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: true at VuerCVListenerSession.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:leavehandleVideoChatLeave(), which writes an activity row and notifies vuer_css, but never closes the room and never touches the transport. Four compounding facts:

  • vuer_css terminate() is literally return Promise.resolve() — a no-op.
  • TransportPool.sessions is a plain Map with no TTL and no sweep.
  • No base-install cron reclaims videochat rooms.
  • The only reclaimer is the AutoCloseRoomsCronJob customization, gated on roomAutoCloseHours — 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. The AutoCloseRoomsCronJob bullet 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-28this.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. No destroy.
  • vuer_oss sends none either. RoomTransportSession.closeJanus() ends at janus.destroy(), which is a session destroy, not a room destroy.
  • One janus room is created per vuer Room DB id (id: this.getRoomId()), with record: 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: 10 against sessions_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 description

On 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.ac and Makefile.am are 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 commitVersionNotes
b8bebd940.13.4What Raiffeisen and Cofidis actually run
08f25c9b1.2.4Actually a pre-release 1.2.4-dev snapshot — upstream master @ bad60d70 (2024-08-02); git describe = v1.2.3-11-gbad60d70
cc0fdca81.4.1Exactly upstream v1.4.1
af80f7ef0.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.1 pointing at fork commits, and git fetch upstream --tags silently refuses to overwrite them (exit 0). ⇒ Any git diff v1.2.4..v1.4.1 in 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()) in janus_lua.c and janus_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 freed recorder->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 in slow_link) does not apply

It is gated on slowlink_threshold > 0. slowlink_threshold appears in NO FaceKom config ⇒ default 0 ⇒ the code path is disabled. Caveat: Raiffeisen ships its own .jcfg via 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/Dockerfile also passes --enable-rest (the HTTP transport is compiled in). The newer vuer-release base does not. Relevant to attack surface and to any “same build everywhere” assumption.
  • Latent build bug in the vuer-release base janus Dockerfile: it copies libwebsockets.so.19 but symlinks libwebsockets.solibwebsockets.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:

  1. fk-dev janus idle 16 days = 20 MB RSS. Sixteen days of wall-clock with no calls produces essentially nothing.
  2. 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

PartnerJanusPlatformBudgetObserved rateConsequence
RaiffeisenSLARAFIPI-83, ASSRAFIPI-38, FKITDEV-92390.13.4AWS t3.largemaxmemory 7168 MB~126 MB/day (longest clean run 03/05 → 04/28, 2% → 97%)~8 weeks to exhaustion; mitigation = manual restart only
DÁP / NISZDAP-1141, FKITDEV-8570, FKITDEV-8573, DAP-8041.2.4Kubernetes, 4 pods8 GiB limit~0.7 GiB/day/podOOMKilled after 13 days. DAP-1141 was closed on “the upgrade process has started”never verified
CofidisFKITSYS-2994, FKITSYS-4462, FKITSYS-5003, FKITSYS-5440, FKITSYS-6721, FKITSYS-9773, FKITSYS-9871, BUGCOFI-264, COFIDISFACEKOM-7750.13.4 (still pinned)1 GB (2022) → 2.7 GB (2024-06) → 5 GB+ (2024-11)container once observed “Up 8 months”
UniCreditFKITSYS-9902build def says 1.4.1; running image on 2026-08-26 was janus:1.2.4.1-20220513see 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

JanusPartners
1.4.1cib, generali-atvilagitas, instacash, magnet, microsec, nusz, raiffeisen
0.16.1barion, fundamenta, granit, kh, mbh, mkb-instant, szerencsejatek
0.13.4cofidis 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_oss StorageService._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_LIST in all ten Raiffeisen release manifests

vuer-release/projects/raiffeisen/release/1..10/release.jsonevery 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:

  1. Add janus to the .101 manifest, so the shipped version stops lying and the CVE backlog in SLARAFIPI-52 / FKITDEV-8745 actually gets addressed.
  2. Fix the vuer_oss session 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/.

ScriptPurpose
janus-probe.jsRead-only census via the Admin API. Reports session count, handle count, videoroom room count, abandoned-empty-room count, and list payload size.
roomleak.jsRoom accumulation + list-growth benchmark (source of the 483 bytes/room figure).
sessleak.jsPer-session cost benchmark (source of the 31.7 kB figure).
abandon.jsProves 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.jsAdded 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.shAdded 2026-09-07. Verifies the AutoCloseRoomsCronJob chain (Part 10).
leakproof.js, heapdig.js, verify-claims.js, rss.shAdded 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
  • adminSecret janusoverlord
  • JanusAdmin already exists in janus-api, with listSessions / 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>.js

Decision 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.

EndpointPort
oss (https)20443
css (https)20444
januswss 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

vuerleak2 was deliberately left up on fk-dev with the leaked sessions and rooms in place. Take a census before anyone docker 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.

baselineafter cycle 10delta
janus sessions00sessions_delta: 0
janus handles000
videoroom rooms010rooms_delta: 10
janus RSS14 092 kB20 964 kBrss_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.

baselineafter cycle 10delta
janus sessions010sessions_delta: 10
janus handles010+10
videoroom rooms010 (populated)rooms_delta: 10
janus RSS14 152 kB24 216 kBrss_delta_kb: 10064

≈ 794–999 kB per abandoned call, monotonic. Reproduced in a second independent run: rss_delta_kb: 9992rss_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 claimStatus 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/

FileWhat it is
leak-harness.jsThe real-call harness — drives both arms end to end
janus-probe.jsRead-only Admin API census — prod-safe
cron-verify.shVerifies the AutoCloseRoomsCronJob chain (Part 10)
leakproof.js, heapdig.js, verify-claims.js, rss.shSupporting probes and RSS sampling
janus-leak.heapsnapshotV8 heap snapshot
arm-clean.json, arm-abandoned.json, arm-abandoned2.jsonPer-cycle census + RSS, raw, for the three runs
FKITDEV-janus-leak-ticket.mdDraft 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

  • handleVideoChatLeave only 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. CloseExpiredSelfServiceRoomsCronJob is, 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:createRoom refuses 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:934 sets req.session.role = req.user.getMainRole().
  • getMainRole() (server/db/model/user.js:187) returns the first entry of acl.roleList present in the user’s rights ⇒ admin for any multi-role user.

The failure is silent, three layers deep:

  1. without the right, waitinglist.script.js:113 never calls receiveAvailable(true);
  2. so .can-receive-call is never added;
  3. so WaitingList.styl:16 keeps .customer-item-actions at display: 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 the VuerCVListenerSession path. SelfServiceTransportSession.terminate() has zero references to vuerCVListenerSession. The dominant leak path of Part 2 is out of the cron’s reach by construction. (c) It is createdAt-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.js against 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_threshold on Raiffeisen’s box specifically — they ship their own .jcfg via their own Dockerfile layer.
  • Audit roomAutoCloseHours per partner. Unset = the one existing mitigation is inert (Part 10).
  • Recreate abandon.js if 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.

Analysis lives here. Raw ticket transcripts live in the dossiers — link, do not restate.