Skip to content

Fix localtime/1 mem leak#2083

Draft
petermm wants to merge 1 commit intoatomvm:release-0.7from
petermm:fix-localtime-leak
Draft

Fix localtime/1 mem leak#2083
petermm wants to merge 1 commit intoatomvm:release-0.7from
petermm:fix-localtime-leak

Conversation

@petermm
Copy link
Copy Markdown
Contributor

@petermm petermm commented Feb 3, 2026

Fixes #2062

replicate:

  def myloop do
    :erlang.localtime("CET-1CEST,M3.5.0,M10.5.0/3")
    |> IO.inspect()

    :timer.sleep(100)

    :erlang.system_info(:esp32_free_heap_size)
    |> IO.inspect()

    myloop()
  end

And see rapidly decreasing free memory - solution inspired by espressif/esp-idf#9764

Currently in draft, as I believe there is some issue with setting the tz to "" on generic_unix - which means it is set to utc, just need to understand that or guard it..

These changes are made under both the "Apache 2.0" and the "GNU Lesser General
Public License 2.1 or later" license terms (dual license).

SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later

@petermm petermm force-pushed the fix-localtime-leak branch from e336026 to 3683b3a Compare February 3, 2026 20:47
@bettio
Copy link
Copy Markdown
Collaborator

bettio commented Feb 6, 2026

This is a non trivial issue and I'm investigating a fix as well.

@petermm petermm force-pushed the fix-localtime-leak branch from 3683b3a to d791eaf Compare April 5, 2026 19:41
@petermm petermm changed the base branch from main to release-0.7 April 5, 2026 19:41
@petermm petermm force-pushed the fix-localtime-leak branch from d791eaf to d3169f4 Compare April 5, 2026 19:43
The original localtime/1 implementation had several correctness issues:

- Memory leak on newlib/picolibc (ESP32): setenv leaks the old
  "NAME=value" string on every overwrite (espressif/esp-idf#3046).
  Fix: use putenv with a fixed-size static buffer that is installed
  once and modified in place, avoiding repeated allocations. Guarded
  behind __NEWLIB__ / __PICOLIBC__ so standard platforms use normal
  setenv/unsetenv.

- Use-after-free: the getenv("TZ") pointer was used after setenv
  overwrites it, reading freed or stale memory. Fix: strdup the old
  TZ value before modifying the environment.

- Missing tzset after restore: the original code called tzset only
  when setting the new TZ, not after restoring the old one, leaving
  libc in an inconsistent state for subsequent calls.

- Missing localtime_r NULL check: a failing localtime_r would pass
  NULL to build_datetime_from_tm. Fix: raise badarg on NULL.

The static buffer workaround raises badarg for oversized TZ strings
rather than silently leaking. The restore path falls back to setenv
if the buffer is too small (refreshing the cached env pointer to
maintain the static buffer invariant), since the environment must
always be restored. When TZ was originally unset, "UTC0" is used as
the restore value on newlib/picolibc since putenv cannot remove
entries; this is a pragmatic approximation (not a true restore) that
is acceptable because unset TZ defaults to UTC on these platforms.

The concurrency model assumes AtomVM is the sole user of TZ -- no
external threads should read or write TZ or call time functions that
depend on it during the temporary override.

See: espressif/esp-idf#3046
Signed-off-by: Peter M <petermm@gmail.com>
@petermm petermm force-pushed the fix-localtime-leak branch from ca8904b to bb6d834 Compare April 20, 2026 19:05
@pguyot
Copy link
Copy Markdown
Collaborator

pguyot commented Apr 21, 2026

RATIONALE

The standard developers noted that putenv() is the only function available to add to the environment without permitting memory leaks.

https://pubs.opengroup.org/onlinepubs/009695399/functions/putenv.html

Couldn't we do putenv+unsetenv?

@petermm
Copy link
Copy Markdown
Contributor Author

petermm commented Apr 21, 2026

According to Codex, no:

Static Buffer vs. unsetenv("TZ")

Using a static buffer with putenv() does not make unsetenv("TZ") leak-free on either newlib or picolibc.

The key point is that our static buffer is only the input to putenv(). On both libcs, putenv() does not keep that buffer installed directly as the environment entry:

  • On picolibc, putenv() duplicates the input string and then calls setenv(), and setenv() allocates a fresh heap-backed "TZ=..." entry.
  • On newlib, putenv() likewise routes through _setenv_r(), which allocates storage for the environment entry.

On both libcs, unsetenv("TZ") then removes the pointer from environ, but does not free the removed "TZ=..." entry. So this sequence:

  1. putenv(static_buffer)
  2. unsetenv("TZ")
  3. later putenv(static_buffer) again

is functionally safe, but it still allocates a new internal env entry on each install/remove cycle and leaves the old one orphaned.

What The Static Buffer Still Buys Us

The static buffer is still useful, but for a different reason:

  • it gives us a stable caller-owned string to pass to putenv()
  • after the first install, we can cache getenv("TZ")
  • we can then mutate the installed env slot in place

That only avoids repeated growth if we keep the TZ slot installed.

Conclusion

If the goal is bounded memory across repeated temporary timezone overrides, then the UTC0 fallback is still needed on both newlib and picolibc.

  • unsetenv("TZ") is fine for semantics
  • unsetenv("TZ") is not the leak-avoidance path
  • restoring to UTC0 keeps the installed slot alive so it can be reused in place

One nuance: UTC0 is only needed for leak avoidance across repeated install/remove cycles. If the originally-unset case happens only once in a process lifetime, then the leaked allocation is bounded and may be acceptable.

@petermm petermm marked this pull request as ready for review April 21, 2026 10:41
@pguyot
Copy link
Copy Markdown
Collaborator

pguyot commented Apr 21, 2026

Looking at the source code of putenv in picolibc, it is not POSIX compliant. The passed strings doesn't seem to be part of the environment from my (sapiens) understanding.

It calls setenv and the str is not passed at all to setenv.

https://github.com/picolibc/picolibc/blob/e459e715bfe9e6945ce4bbbadad2a88d75b1265a/libc/stdlib/putenv.c#L40

So this doesn't fix the issue with picolibc.

Can we simply swap environ while holding the lock? It works on macOS at least and seems dirty but compliant. It may work with both newlib and picolibc. There is no guarantee that localtime (or localtime_r) doesn't read TZ by the standard, but picolibc doesn't.

#include <time.h>
#include <stdio.h>

extern char **environ;
static const char *UTC0_ENVIRON[2] = {"TZ=UTC0", NULL};

int main()
{
    time_t now;
    time(&now);
    printf("The time is ");
    char **saved_environ = environ;
    environ = (char **) UTC0_ENVIRON;
    tzset();
    fputs(asctime(localtime(&now)), stdout);
    environ = saved_environ;
    printf("\n");
    return 0;
}

@petermm petermm marked this pull request as draft April 22, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak in erlang:localtime/1

3 participants