Homey Pro (Early 2023) · two private apps, one simple and one complex, both affected
Summary
An app of mine is killed by Memory Warning Limit Reached every few hours. I
have instrumented it heavily and can now describe the behaviour precisely. Two
findings that I have not seen written down anywhere:
RSS grows in discrete 20–33 MB steps, and page-fault counters read
immediately before and after each loop iteration place them between two
iterations, not inside one. Most are returned within minutes; occasionally
one is not, and the floor ratchets up permanently.
The event-loop rate during the first 8–10 hours after start determines
where RSS settles. Polling every 500 ms from a cold start reaches 160 MB
in 70 minutes and gets killed. Running the first ten hours at 1500 ms and
only then switching to 500 ms settles the floor at ~100–120 MB — and after
that the interval makes no difference at all. The floor stops rising about
9 hours in, which is where the ten hours comes from.
The two are independent: the steps arrive at the same size and roughly the same
rate whatever the loop interval. What the interval changes is the floor they
land on, and therefore whether a step is survivable.
A second app on the same Homey, with unrelated code and a very different
workload, shows steps of the same shape. I have not been able to test what
would remove them from either app.
How RSS is measured here. An app cannot read its own current RSS (see
Q5 — Can an app read its own RSS? below), so a separate monitoring app polls
usage.mem for each app through homey-api every 15 seconds. All floor and step figures below come from
that. Figures called maxRSS come from process.resourceUsage() inside the
app and are high-water marks.
The apps
App 1
— energy management for a Sungrow SH10RT hybrid inverter with a
25.6 kWh battery. Modbus TCP polling, a state machine for charge/discharge,
~76 capabilities, one device, no cloud, one persistent TCP socket.
App 2
— draws a PNG chart every five minutes and uploads it over HTTP (to my internal web server on LAN) .
Reads app 1’s capabilities via homey-api. Around 3 MB of preallocated buffers
and almost no logic.
What the kill looks like
12:44:16 app starts, floor 85 MB
13:00 warm-up done, floor 97.4
13:00-21:00 drift 1.17 MB/h floor 106.8
21:48:39 +33.0 MB, single step -> 143.6 not returned
21:50-22:44 145.7 flat for nearly an hour
22:44:11 loop interval 1500 -> 500 ms
22:46:56 +4.3 MB -> 150.1
22:48:09 killed
That matches the documented behaviour — warning above 150 MB, killed after
about 50 seconds above it — so the kill itself works as designed. The question
is where the 33 MB came from.
The drift between steps is 1.17 MB/h. At that rate the run would have taken
30 hours to reach the limit on its own; it was killed after 10, one hour after a
single unreturned step.
The steps: measured over 24 hours
Eight increases of ≥8 MB, measured against actual RSS sampled every 15 seconds
by a separate monitoring app:
time floor -> peak +MB outcome
08:38:29 103.8 -> 129.5 +25.7 returned after 6.0 min
09:15:35 99.2 -> 114.1 +14.9 returned after 3.0 min
12:20:51 109.5 -> 132.1 +22.6 PERMANENT
14:28:26 104.0 -> 129.2 +25.2 returned after 4.3 min
19:53:20 105.3 -> 117.3 +12.0 returned after 2.0 min
21:48:39 110.6 -> 143.6 +33.0 PERMANENT -> killed one hour later
00:48:38 100.6 -> 121.1 +20.5 returned after 6.5 min
01:42:02 101.2 -> 128.2 +27.0 returned after 1.3 min
Six of eight are returned to the OS within minutes. Two are not, and those lift
the floor for good. Size does not predict which: 22.6 MB stayed while
27.0 MB was returned. Neither does peak_malloced_memory.
Between the steps the floor drifts 1.2 MB/h.
Where the memory is allocated
I read process.resourceUsage().minorPageFault immediately before and
immediately after each poll cycle, so an increase can be attributed to one side
or the other. Every step in the 24-hour window looks the same:
06:14 between ticks 26.6 MB gap 1442 ms
08:38 between ticks 25.5 MB gap 1446 ms
11:31 between ticks 24.8 MB gap 1433 ms
13:18 between ticks 22.7 MB gap 1455 ms
14:28 between ticks 23.5 MB gap 1388 ms
18:42 between ticks 25.1 MB gap 1430 ms
19:53 between ticks 30.2 MB gap 1442 ms
21:48 between ticks 33.1 MB gap 1456 ms
23:38 between ticks 26.6 MB gap 1435 ms
01:41 between ticks 25.5 MB gap 1419 ms
Always between two iterations, always across a 1388–1456 ms interval — the
configured 1500 ms less the iteration’s own run time — and always 22–33 MB. The measurements taken inside a tick in the same seconds are
1.6–3.8 MB. None of my JavaScript is running when the 25 MB arrives.
Page faults track the size exactly — in a control test, allocating a 30 MB
Buffer produced 7689 minor page faults (30.0 MB).
The gap length also identifies the loop interval, which settles a question I
expected to be asked. Of twelve measured events in that 24-hour window, eleven
happened at a 1388–1456 ms gap (the 1500 ms interval) and exactly one at 448 ms
(the 500 ms interval). The steps are therefore not a consequence of running
fast — the largest one, 33 MB, arrived nine hours into a slow-polling window.
Everything V8 reports stays flat
Over a 4-hour run RSS went from 92 to 160 MB while all V8 figures together grew
about 6 MB:
metric start after 4 h
heapUsed 18.4 MB 23.0 MB
heapTotal 23.2 MB 27.9 MB
external 3.8 MB 3.8 MB
malloced_memory 0.5 MB 0.5 MB
active libuv handles Pipe:1, Socket:4 unchanged
maxRSS 92.1 MB 160.2 MB
Handle counts never move, so it is not a handle or socket leak.
The warm-up rate decides the floor
One process, 71 hours, no restarts, two variables changed at known times.
Poll interval 1500 ms → 500 ms, while already settled
5 h before (1500 ms): floor 121.5 MB
3 h after (500 ms): floor 121.5 MB difference +0.0 MB
For comparison, from a cold start at 500 ms the first ≥5 MB step arrived after
12–16 minutes in all four runs I have, and it was 22–27 MB every time.
Cold start, same code, same hardware, same day
500 ms throughout 94 -> 160 MB in 70 minutes -> killed
1500 ms for 8-10 h 97 -> 121 MB over ~9 hours -> flat, ran 71 hours
then 500 ms no effect at all
During warm-up the growth tracks the number of loop iterations rather than
wall-clock time: roughly 25–30 MB per 10 000 iterations at both 500 ms and
1000 ms. Halving the interval does not make an iteration cost more — it just
runs twice as many per hour, so the ceiling arrives twice as fast.
I now start the app at 1500 ms and switch to 500 ms after ten hours. It keeps
the floor around 100 MB instead of 125, which buys margin — but it does not stop
the steps, and it does not always save the app. In the run shown above the floor
had drifted to 110.6 when a 33 MB step landed, and that was enough.
Forcing garbage collection helps during warm-up, not after
Same poll interval (1500 ms, verified at 10 iterations per 15 s), same code,
same machine, same warm-up phase — the only difference is global.gc() every
30 seconds:
GC off 89.9 -> 128.7 MB in 1.7 h = 23.3 MB/h -> killed after 1 h 58 min
GC on 96.8 -> 109.3 MB in 1.7 h = 7.5 MB/h -> settled, ran for days
But GC does not reclaim a step once it has happened. In the 70 seconds
between the fatal step and the kill, five garbage collections ran and RSS did
not move a single MB. A 20 MB transient on another day took 5 minutes 16
seconds to return with GC running every 30 seconds throughout — so whatever
returns the memory operates on a minutes timescale and is not GC.
A second app shows the same pattern
App 2 does almost nothing: renders a PNG every five minutes, uploads it, reads
capabilities. Over 22 hours its floor moved 93.9 → 97.7 MB, with two transients
(+9.2 and +11.7 MB) that returned within five minutes. On another day it took
two ~10 MB steps that did not return, then sat flat for 14 hours.
Same stepping shape, completely different code. That is what makes me think
this is platform behaviour rather than something in my control logic.
Questions
Q1 — Is the warm-up effect known?
Is it known that a faster event-loop cadence during the first hours after start
leads to a permanently higher RSS floor on the 2023+ hardware? It is not
intuitive: an iteration that allocates nothing should not grow the process.
Q2 — Is there a better fix than warming up slowly?
Ten hours at a third of the control resolution is a lot to pay, and it does not
stop the steps — it only lowers the floor they land on.
Q3 — What allocates 20–33 MB between two ticks?
I do not know. What I can say is what it is not: every figure V8 reports
stays flat across a step, and the libuv handle count does not change. That
leaves memory the process obtained from the allocator without V8 accounting for
it.
If that is glibc arena behaviour, malloc_trim(0) would be the remedy, but
Node does not expose it. global.gc() is not a substitute — measured above, it
changed nothing after a step had happened. Would setting MALLOC_ARENA_MAX or
MALLOC_TRIM_THRESHOLD_ for app processes be possible, or exposing a trim call
to apps?
Q4 — Can the limit be raised for an app that needs it?
I have read on this forum that com.athom.matter is allowed roughly double —
I have not verified that myself, but if it is right the mechanism exists. My floor is ~100 MB after the workaround, but a single unreturned step
still takes it to 145 — four MB from the kill.
Q5 — Can an app read its own RSS?
Not today. /proc is not mounted in the app sandbox; confirmed at runtime:
process.memoryUsage.rss() -> ENOENT: no such file or directory,
uv_resident_set_memory
The only figure available in-process is getrusage(ru_maxrss), a high-water
mark that never decreases and therefore reports every transient peak as if it
were permanent. I had to write a second app that polls app.usage.mem through
homey-api just to see the real curve — and an app that wants to protect itself
has to call the REST API to look up its own memory use.
Exposing current RSS to apps would make this class of problem far easier to
diagnose, and would let an app back off on its own before it is killed.
What I have already ruled out
All buffers allocated once at onInit and written in place; no per-iteration
allocation in the poll loop.
Log strings are never built unless the line is actually emitted.
Trigger card lookups cached; TCP connections kept alive with keep-alive.
Chart rendering and all HTTP uploads moved into a separate app: ~92 kB of code
and 3 MB of buffers left app 1. The ceiling did not change.
Poll intervals tested at 500, 1000, 1500 and 2000 ms.
Forced GC tested on and off within the same process.
libuv handle count monitored continuously — constant at Pipe:1, Socket:4.
diag. showing some manual restarts with different RSS behaviours and one crash.


