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.py — RobotSimulation.__init__ → on_repeat(self.step, 0.01)
rosys/driving/steerer.py — on_repeat(..., 0.05)
rosys/driving/odometer.py — on_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.
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
Repeaterthatrosys.on_repeat(...)returns, so no one can stop it):RobotSimulation— a 100 Hz repeater (self.step), handle discardedSteerer— a 20 Hz repeater, handle discardedOdometer— a 1 Hz repeater (self.prune_history), handle discarded, plus a retained event subscriptionAutomator— a globalon_shutdownhandler closing overself, one per instanceCamera/SimulatedCamera— global/images/<id>/…routes;SimulatedDevicekeeps a repeater thatSimulatedCamera.disconnect()never stopsFor 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, thenpython leak.py. The page builds aRobotSimulation+Odometer(a 100 Hz and a 1 Hz repeater); each page-view adds both and never releases them:Output — two repeaters added per page-view, never reclaimed:
These loops are not reclaimed when the client goes away: I separately confirmed that after the NiceGUI clients disconnect and are pruned (
Client.instancesback to baseline), the liveRepeatercount 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:
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(...)) androsys/geometry/pose.py.Root cause (source refs)
rosys.on_repeat(handler, interval)returns aRepeater, but the actors that call it in__init__discard the handle:rosys/hardware/robot.py—RobotSimulation.__init__→on_repeat(self.step, 0.01)rosys/driving/steerer.py—on_repeat(..., 0.05)rosys/driving/odometer.py—on_repeat(self.prune_history, 1.0)Repeater.start()adds the task to the class-globalRepeater.tasks(rosys/rosys.py); it is removed only byRepeater.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 globalrosys.on_shutdown(...)per instance, andCamera.__init__(base ofSimulatedCamera) 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 atear_down()/dispose()that stops them (and unsubscribes theAutomatorshutdown handler / removes theCameraroutes). I'll open a draft PR with one concrete shape so the fix is visible immediately; glad to adjust to whatever you prefer.