Skip to content

Simulation actors register process-global repeaters/handlers with no per-instance disposal (memory leak in per-request embeddings) #436

Description

@evnchn

Filed by evnchn; drafted with Claude Code, repro run and claims verified against source.

TL;DR:

RoSys actors that are natural to instantiate inside a NiceGUI page create process-global repeaters / shutdown handlers / routes with no per-instance disposal path — so in per-client or per-request embeddings they survive client cleanup and keep running until the process shuts down.

Verified examples (each discards the Repeater that rosys.on_repeat(...) returns, so no one can stop it):

  • RobotSimulation — a 100 Hz repeater (self.step), handle discarded
  • Steerer — a 20 Hz repeater, handle discarded
  • Odometer — a 1 Hz repeater (self.prune_history), handle discarded, plus a retained event subscription
  • Automator — a global on_shutdown handler closing over self, one per instance
  • Camera / SimulatedCamera — global /images/<id>/… routes; SimulatedDevice keeps a repeater that SimulatedCamera.disconnect() never stops

For a single-robot app this is a non-issue. But anything that builds actors per request — interactive docs, a multi-tenant dashboard, any embedding where visitors come and go — leaks a fresh set of loops per visit, forever.

Ask: give these actors a per-instance disposal path (or bind their repeaters to the creating client's lifecycle) so they can be created and released per client. A draft PR with one concrete shape follows — glad to reshape it.

Runnable reproduction (RoSys 0.34.0, NiceGUI 3.14.0, Python 3.12)

pip install rosys, then python leak.py. The page builds a RobotSimulation + Odometer (a 100 Hz and a 1 Hz repeater); each page-view adds both and never releases them:

import threading
import urllib.request

from nicegui import app, ui
import rosys                 # noqa: F401
import rosys.rosys as core

@ui.page('/')
def demo() -> None:          # a normal live "steer a simulated robot" demo
    from rosys.driving import Odometer, robot_object
    from rosys.geometry import Prism
    from rosys.hardware import RobotSimulation, WheelsSimulation
    wheels = WheelsSimulation()
    RobotSimulation([wheels])                                   # -> 100 Hz Repeater(self.step)
    with ui.scene():
        robot_object(Prism.default_robot_shape(), Odometer(wheels))  # -> 1 Hz Repeater(prune_history)

def drive() -> None:
    for i in range(1, 601):
        urllib.request.urlopen('http://localhost:8082/').read()   # one page-view
        if i % 100 == 0:
            live = len([t for t in core.Repeater.tasks if not t.done()])
            print(f'{i:3d} page-views -> {live} live Repeaters')
    import os
    os._exit(0)

app.on_startup(lambda: threading.Thread(target=drive, daemon=True).start())
ui.run(port=8082, reload=False, show=False)

Output — two repeaters added per page-view, never reclaimed:

100 page-views ->  202 live Repeaters
200 page-views ->  402 live Repeaters
400 page-views ->  802 live Repeaters
600 page-views -> 1202 live Repeaters

These loops are not reclaimed when the client goes away: I separately confirmed that after the NiceGUI clients disconnect and are pruned (Client.instances back to baseline), the live Repeater count stays elevated — client deletion tears down UI elements, not these global loops.

It reaches 1 GB with a realistically-sized robot (this repro / environment)

The bare simulation above leaks ~0.1 MB/visit, so RSS climbs slowly (and the event loop saturates at ~2200 leaked repeaters before it gets far). But a real driving/mapping robot carries state — a local occupancy grid, camera frames, etc. When each leaked robot holds a realistic ~5.5 MB local map, the same unbounded leak crosses 1 GB by ~160 page-views on my machine:

Scenario After N page-views Live repeaters RSS
bare robot 600 1202 +58 MB
robot + ~5.5 MB local map 160 322 1142 MB (> 1 GB)
+ per-client teardown (cancel the repeaters on client delete) 600 4 (baseline) 233 MB, bounded

tracemalloc (measured separately from RSS — enabling it inflates RSS) attributes the ongoing allocation to the still-running leaked loops: rosys/driving/odometer.py:63 (self.history.append(...)) and rosys/geometry/pose.py.

Root cause (source refs)

rosys.on_repeat(handler, interval) returns a Repeater, but the actors that call it in __init__ discard the handle:

  • rosys/hardware/robot.pyRobotSimulation.__init__on_repeat(self.step, 0.01)
  • rosys/driving/steerer.pyon_repeat(..., 0.05)
  • rosys/driving/odometer.pyon_repeat(self.prune_history, 1.0)

Repeater.start() adds the task to the class-global Repeater.tasks (rosys/rosys.py); it is removed only by Repeater.stop(), which callers can't reach because the handle was thrown away (Repeater.stop_all() cancels but doesn't remove). Deleting the NiceGUI client tears down its UI elements but not these global loops. Separately, Automator.__init__ appends a global rosys.on_shutdown(...) per instance, and Camera.__init__ (base of SimulatedCamera) registers global /images/<id>/… routes (same-id construction replaces the routes rather than accumulating them).

Suggested direction: anything that lets a caller release an instance would fix it — e.g. actors retain the Repeaters they create and expose a tear_down()/dispose() that stops them (and unsubscribes the Automator shutdown handler / removes the Camera routes). I'll open a draft PR with one concrete shape so the fix is visible immediately; glad to adjust to whatever you prefer.

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions