Skip to content

AgentServer.run deletes the metric files its own process already holds open in PROMETHEUS_MULTIPROC_DIR, so lk_agents_worker_load and app metrics vanish from /metrics #7596

Open

Description

@zdurm

Bug Description

When PROMETHEUS_MULTIPROC_DIR is set (or prometheus_multiproc_dir= is passed), AgentServer.run() deletes every file in that directory at startup:

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:

  1. Every application metric of that type disappears from /metrics.
  2. lk_agents_worker_load disappears too. It is a Gauge in the default all mode (telemetry/metrics.py, line 32), so it shares gauge_all_<pid>.db with any app gauge, and _load_task keeps writing it into the deleted file (worker.py#L810).
  3. 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", so gauge_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.3

Session/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

  1. added
    bugSomething isn't working
    on Oct 3, 2026
  2. himanshumehta commented on Oct 25, 2026

    @himanshumehta

    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.

  3. himanshumehta commented on Nov 5, 2026

    @himanshumehta

    Following up on my Oct 25 offer with the run. Three open PRs fix this (#7609, #7615, #8159). I ran your repro.py unchanged on current main (c2b2f5c, which includes livekit-agents 1.8.10; worker.py is the same on today's main) and with each PR's worker.py/metrics.py diff applied. I also ran a second script that adds the case the issue's expected behaviour describes: before run(), 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.

    startup cleanup: main vs #7609 vs #7615 vs #8159

    code your repro.py files left after run() on /metrics
    main FAIL self gauge_max only app gauge: no · sibling: no · lk_agents_worker_load: no
    #7609 (f4ea7e7) PASS self gauge_all + gauge_max app: yes · sibling: no · load: yes
    #7615 (b824e40) PASS self + live sibling app: yes · sibling: yes · load: yes
    #8159 (ec31f6d) PASS self + live sibling app: yes · sibling: yes · load: yes

    What the run shows:

    Not covered:

    • Windows.
    • A pid reused by an unrelated process.
    • The PROCESS executor: these runs use THREAD, as in your repro.
    • The PRs' own test suites.
    Reproduce
    git clone https://github.com/livekit/agents && cd agents && uv sync --all-extras --dev
    uv run python repro.py          # the script from this issue
    uv run python repro_mixed.py main
    for n in 7609 7615 8159; do
      gh pr diff $n -R livekit/agents > /tmp/$n.diff && git apply --exclude='tests/*' /tmp/$n.diff
      uv run python repro.py; uv run python repro_mixed.py "#$n"; git checkout -q -- .
    done

    Scripts: repro_mixed.py, demo-7596.sh

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions