Repository navigation
Description
Bug Description
When PROMETHEUS_MULTIPROC_DIR is set (or prometheus_multiproc_dir= is passed), AgentServer.run() deletes every file in that directory at startup:
agents/livekit-agents/livekit/agents/worker.py
Lines 707 to 718 in e684c23
| if self._prometheus_multiproc_dir and os.path.exists(self._prometheus_multiproc_dir): | |
| logger.debug( | |
| "cleaning prometheus multiprocess directory", | |
| extra={"path": self._prometheus_multiproc_dir}, | |
| ) | |
| for filename in os.listdir(self._prometheus_multiproc_dir): | |
| file_path = os.path.join(self._prometheus_multiproc_dir, filename) | |
| try: | |
| if os.path.isfile(file_path): | |
| os.unlink(file_path) | |
| except Exception as e: | |
| logger.warning(f"failed to remove {file_path}", exc_info=e) |
The cleanup does not check whose files they are. It also deletes the files of the current process. prometheus_client opens one mmap file per metric type per process (gauge_all_<pid>.db, counter_<pid>.db, and so on) and keeps it open in a module-level cache (prometheus_client/values.py, MultiProcessValue, the files dict). If any metric of a given type was created in this process before run(), the process keeps writing to the unlinked file after the cleanup, and MultiProcessCollector never sees it again.
Metrics get created before run() in normal use: an unlabeled metric opens its file at construction (module import time), and any warm-up or setup code that records a metric does the same. After that:
- Every application metric of that type disappears from
/metrics. lk_agents_worker_loaddisappears too. It is aGaugein the defaultallmode (telemetry/metrics.py, line 32), so it sharesgauge_all_<pid>.dbwith any app gauge, and_load_taskkeeps writing it into the deleted file (worker.py#L810).- Only metrics whose file type was first created after the cleanup survive. In our case that was
lk_agents_child_process_count(multiprocess_mode="max", sogauge_max_<pid>.db).
Nothing is logged above debug level, and /metrics still returns 200, so the loss is silent.
We hit this on our production worker, which uses JobExecutorType.THREAD (all jobs in one process) and records loop health histograms, counters and gauges during a startup warm-up. /metrics served only lk_agents_child_process_count. Our autoscaler reads lk_agents_worker_load, so it lost its input. Our workaround is to leave PROMETHEUS_MULTIPROC_DIR unset for the THREAD executor and keep it only for the PROCESS executor.
Expected Behavior
Metrics this process already recorded, including the server's own lk_agents_worker_load, stay visible on /metrics after AgentServer.run() starts. The startup cleanup removes only stale files from processes that no longer run.
Reproduction Steps
1. Save the script below as repro.py.
2. uv run --no-project --with 'livekit-agents==1.8.4' python repro.py
3. No LiveKit server is needed (run(unregistered=True)); the cleanup runs either way.
"""AgentServer.run deletes PROMETHEUS_MULTIPROC_DIR files that this process already holds open."""
import asyncio
import os
import socket
import tempfile
import urllib.request
# prometheus_client reads PROMETHEUS_MULTIPROC_DIR when it creates a metric value,
# so it must be set before any metric is written.
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tempfile.mkdtemp(prefix="prom-mp-")
MP_DIR = os.environ["PROMETHEUS_MULTIPROC_DIR"]
import prometheus_client # noqa: E402
from livekit.agents import AgentServer, JobContext, JobExecutorType # noqa: E402
# An application metric written at startup, before the server runs
# (for example a warm-up step, or a THREAD executor app that registers its own metrics).
APP_GAUGE = prometheus_client.Gauge("app_warmup_done", "set once warm-up finishes")
APP_GAUGE.set(1)
print("files before run():", sorted(os.listdir(MP_DIR)))
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
async def main() -> None:
prom_port = free_port()
server = AgentServer(
job_executor_type=JobExecutorType.THREAD,
num_idle_processes=0,
host="127.0.0.1",
port=free_port(),
prometheus_port=prom_port,
)
@server.rtc_session()
async def entrypoint(ctx: JobContext) -> None:
pass
# unregistered=True: no LiveKit server needed. The cleanup runs either way.
run_task = asyncio.create_task(server.run(devmode=True, unregistered=True))
await asyncio.sleep(2) # several UPDATE_LOAD_INTERVAL (0.5 s) ticks of the load task
APP_GAUGE.set(2) # still writes to the unlinked mmap file
print("files after run(): ", sorted(os.listdir(MP_DIR)))
body = await asyncio.to_thread(
lambda: urllib.request.urlopen(f"http://127.0.0.1:{prom_port}/metrics").read().decode()
)
names = sorted({l.split("{")[0].split(" ")[0] for l in body.splitlines() if l and not l.startswith("#")})
print("/metrics series: ", names)
missing = [m for m in ("app_warmup_done", "lk_agents_worker_load") if m not in body]
if missing:
print(f"FAIL: missing from /metrics after AgentServer.run(): {missing}")
else:
print("PASS: application and worker metrics survive AgentServer.run()")
await server.aclose()
run_task.cancel()
asyncio.run(main())
Output on livekit-agents 1.8.4 (same on main at 96341b0db2d0224403a36612cb04a1e38d60d405):
files before run(): ['gauge_all_92187.db']
files after run(): ['gauge_max_92187.db']
/metrics series: ['lk_agents_child_process_count']
FAIL: missing from /metrics after AgentServer.run(): ['app_warmup_done', 'lk_agents_worker_load']
Control: the same script without the app_warmup_done Gauge (no file exists before run()):
files before run(): []
files after run(): ['gauge_all_87140.db', 'gauge_max_87140.db']
/metrics series: ['lk_agents_child_process_count', 'lk_agents_worker_load']
PASS: application and worker metrics survive AgentServer.run()Operating System
macOS 26.5.1 (arm64) for the repro; production is Linux (Kubernetes)
Models Used
Not relevant (no models involved)
Package Versions
livekit-agents==1.8.4 (also reproduced on main at 96341b0db2d0224403a36612cb04a1e38d60d405; our production worker runs 1.8.2)
livekit==1.1.20
prometheus-client==0.26.0
Python 3.14.3Session/Room/Call IDs
N/A (self-hosted, reproduces without a room)
Proposed Solution
# prometheus_client names every file <prefix>_<pid>.db.
# Delete only files of processes that no longer run.
for filename in os.listdir(self._prometheus_multiproc_dir):
pid_str = filename.rsplit("_", 1)[-1].removesuffix(".db")
if not pid_str.isdigit():
continue
pid = int(pid_str)
if pid == os.getpid() or psutil.pid_exists(pid):
continue
file_path = os.path.join(self._prometheus_multiproc_dir, filename)
with contextlib.suppress(FileNotFoundError):
os.unlink(file_path)Additional Context
At minimum, skipping files that end in _<os.getpid()>.db fixes the case in this report.
Related but different: #7006 and #7017 are about dead child processes leaving files behind (too few deletions). This report is the opposite: the startup wipe deletes files of the live server process. The wipe was added in #3565.
I used an AI assistant to trace this and draft the report; I verified the reproduction myself.
Screenshots and Recordings
N/A
Activity
himanshumehta commentedon Oct 25, 2026
Hi, I'm Himanshu, looking for a scoped contribution. I saw #7609 and #7615. Would a subprocess-based validation of metrics created before AgentServer.run help? I'd check current-process metrics remain collectable while stale-process cleanup follows the intended ownership rules.
I haven't run this reproduction or the candidate PR's tests yet; this is an offer to investigate, not a claim that the gap is confirmed.
himanshumehta commentedon Nov 5, 2026
Following up on my Oct 25 offer with the run. Three open PRs fix this (#7609, #7615, #8159). I ran your
repro.pyunchanged on current main (c2b2f5c, which includes livekit-agents 1.8.10;worker.pyis the same on today's main) and with each PR'sworker.py/metrics.pydiff applied. I also ran a second script that adds the case the issue's expected behaviour describes: beforerun(), the directory holds this process's gauge file, the files of a child that has exited, and the files of a sibling process that is still running.run()/metricsgauge_maxonlylk_agents_worker_load: nogauge_all+gauge_maxWhat the run shows:
app_warmup_doneandlk_agents_worker_load, as you reported on 1.8.4.ipc/supervised_proc.py, so themark_process_deadregression in Regression: mark_process_dead dropped in 1.3.0 — prometheus multiprocess files accumulate forever #7006 (files of finished job processes) is separate and still open.Not covered:
Reproduce
Scripts: repro_mixed.py, demo-7596.sh