From 51ec8b05d748e46b1528acfaa7512126a9178792 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 22 May 2026 00:46:55 +0200 Subject: [PATCH 1/2] debug: add memory_snapshot WS command for leak diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users see dashboard RAM grow during/after builds but there's no way to tell what grew. Adds an opt-in tracemalloc-backed WS command that returns top-N allocators plus a diff against a saved baseline, so a bug report can carry actionable evidence. Off by default — set ESPHOME_DEBUG_MEMORY=1 to start tracking from process boot (catches catalog loads + startup allocs), or call the command once to enable lazily. --- docs/API.md | 3 + esphome_device_builder/__main__.py | 10 ++ esphome_device_builder/controllers/debug.py | 93 ++++++++++++++ esphome_device_builder/device_builder.py | 4 + esphome_device_builder/helpers/memory.py | 125 ++++++++++++++++++ tests/test_debug_memory.py | 134 ++++++++++++++++++++ 6 files changed, 369 insertions(+) create mode 100644 esphome_device_builder/controllers/debug.py create mode 100644 esphome_device_builder/helpers/memory.py create mode 100644 tests/test_debug_memory.py diff --git a/docs/API.md b/docs/API.md index c4fde0bb..c4ad2d42 100644 --- a/docs/API.md +++ b/docs/API.md @@ -372,6 +372,9 @@ Same-subnet peers read `remote_build_port` from TXT so a `--remote-build-port` o |---------|------|----------|-------------| | `ping` | — | `{pong: true}` | Health check | | `subscribe_events` | — | Streaming | Subscribe to real-time events | +| `debug/memory_snapshot` | `top_n?`, `save_as?`, `compare_with?`, `drop_baseline?` | `{system, top_allocators, baseline_names, note?}` | Capture a `tracemalloc` snapshot for leak diagnosis | + +**`debug/memory_snapshot` usage:** to bisect a memory leak, start the dashboard with `ESPHOME_DEBUG_MEMORY=1` so `tracemalloc` traces every allocation from boot. Call once with `save_as="before"` to bookmark a baseline, reproduce the suspected leak (run a build, browse the UI, …), then call again with `compare_with="before"` to get the top-N allocators ordered by size delta. Without the env var the command still works but will only see allocations made after its first invocation. Returned `system` carries process-wide stats: `tracemalloc_current_bytes` / `tracemalloc_peak_bytes` (when tracking), `gc_counts`, `sys_allocated_blocks`, `max_rss_bytes`. **`subscribe_events` initial state:** diff --git a/esphome_device_builder/__main__.py b/esphome_device_builder/__main__.py index daf78fc8..048664f4 100644 --- a/esphome_device_builder/__main__.py +++ b/esphome_device_builder/__main__.py @@ -101,6 +101,16 @@ def _setup_logging(log_level: str, log_file: str | None = None) -> None: def main() -> None: """Run the ESPHome Device Builder.""" + # Enable tracemalloc as the very first step so the + # ``debug/memory_snapshot`` WS command (helpers/memory.py) can + # produce diffs that include the catalog loads and other + # startup allocations. Off by default — adds per-allocation + # overhead. + if os.environ.get("ESPHOME_DEBUG_MEMORY"): + import tracemalloc # noqa: PLC0415 + + tracemalloc.start(25) + parser = argparse.ArgumentParser( description="ESPHome Device Builder", formatter_class=argparse.ArgumentDefaultsHelpFormatter, diff --git a/esphome_device_builder/controllers/debug.py b/esphome_device_builder/controllers/debug.py new file mode 100644 index 00000000..a83ee3c7 --- /dev/null +++ b/esphome_device_builder/controllers/debug.py @@ -0,0 +1,93 @@ +"""Debug WS commands — memory snapshots for support / leak hunts.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from ..helpers import memory +from ..helpers.api import CommandError, api_command +from ..models import ErrorCode + +if TYPE_CHECKING: + from ..device_builder import DeviceBuilder + +_LOGGER = logging.getLogger(__name__) + +_MAX_TOP_N = 200 + + +class DebugController: + """Owns the ``debug/*`` WS commands. Stateless beyond ``helpers.memory``.""" + + def __init__(self, device_builder: DeviceBuilder) -> None: + self._db = device_builder + + @api_command("debug/memory_snapshot") + async def memory_snapshot( + self, + *, + top_n: int = 25, + save_as: str | None = None, + compare_with: str | None = None, + drop_baseline: str | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """ + Return process memory stats + the top ``tracemalloc`` allocators. + + First call enables ``tracemalloc`` lazily and returns an empty + ``top_allocators`` (allocations before this call aren't traced). + Set ``ESPHOME_DEBUG_MEMORY=1`` at process start to catch + startup allocations too. + + ``save_as``: bookmark the snapshot for later ``compare_with``. + ``compare_with``: diff against a previously-saved baseline. + ``drop_baseline``: forget the named baseline; succeeds silently + if it wasn't saved. + """ + if not isinstance(top_n, int) or top_n < 1 or top_n > _MAX_TOP_N: + raise CommandError( + ErrorCode.INVALID_ARGS, + f"top_n must be an int between 1 and {_MAX_TOP_N}", + ) + + if drop_baseline is not None: + memory.drop_baseline(drop_baseline) + + if not memory.is_tracking(): + memory.start_tracking() + _LOGGER.info("Memory tracking enabled via debug/memory_snapshot") + return { + "system": memory.system_stats(), + "top_allocators": [], + "baseline_names": memory.baseline_names(), + "note": ( + "tracemalloc was just enabled — allocations before " + "this call aren't traced. Run a build, then call " + "again with save_as to bookmark a baseline, and " + "again later with compare_with to see what grew." + ), + } + + snapshot = memory.take_snapshot() + + baseline = None + if compare_with is not None: + baseline = memory.get_baseline(compare_with) + if baseline is None: + raise CommandError( + ErrorCode.NOT_FOUND, + f"baseline {compare_with!r} not saved; known: {memory.baseline_names()}", + ) + + if save_as: + memory.save_baseline(save_as, snapshot) + + return { + "system": memory.system_stats(), + "top_allocators": memory.format_top_allocators( + snapshot, baseline=baseline, top_n=top_n + ), + "baseline_names": memory.baseline_names(), + } diff --git a/esphome_device_builder/device_builder.py b/esphome_device_builder/device_builder.py index acbf3000..0bf4812b 100644 --- a/esphome_device_builder/device_builder.py +++ b/esphome_device_builder/device_builder.py @@ -37,6 +37,7 @@ has_remote_build_settings_persisted, load_remote_build_settings, ) +from .controllers.debug import DebugController from .controllers.devices import DevicesController from .controllers.editor import EditorController from .controllers.firmware import FirmwareController @@ -256,6 +257,7 @@ def __init__(self, settings: DashboardSettings) -> None: self.onboarding: OnboardingController | None = None self.remote_build_offloader: OffloaderController | None = None self.remote_build_receiver: ReceiverController | None = None + self.debug: DebugController | None = None # mDNS advertise — populated in start() once we know zeroconf # is up. Optional: a zeroconf-bind failure leaves this None @@ -328,6 +330,7 @@ async def start(self) -> None: self.onboarding = OnboardingController(self) self.remote_build_offloader = OffloaderController(self) self.remote_build_receiver = ReceiverController(self) + self.debug = DebugController(self) await self.devices.start() await self.firmware.start() await self.editor.start() @@ -407,6 +410,7 @@ async def start(self) -> None: self.onboarding, self.remote_build_offloader, self.remote_build_receiver, + self.debug, ): self.command_handlers.update(collect_api_commands(controller)) diff --git a/esphome_device_builder/helpers/memory.py b/esphome_device_builder/helpers/memory.py new file mode 100644 index 00000000..f9633964 --- /dev/null +++ b/esphome_device_builder/helpers/memory.py @@ -0,0 +1,125 @@ +""" +Memory-debugging helpers backing the ``debug/memory_snapshot`` WS command. + +Wraps stdlib ``tracemalloc`` plus a small in-memory baseline store +so support requests asking for a heap diff don't need users to +attach a profiler — just enable tracking (set +``ESPHOME_DEBUG_MEMORY=1`` or call the WS command once), +``save_as="before"`` a build, ``compare_with="before"`` after, and +paste the diff. The baseline store is process-local and lost on +restart; that's fine for ad-hoc debugging. +""" + +from __future__ import annotations + +import gc +import sys +import tracemalloc +from typing import Any + +try: + import resource +except ImportError: + # Windows doesn't ship the ``resource`` module. The RSS field + # is best-effort everywhere and just gets omitted there. + resource = None # type: ignore[assignment] + +_DEFAULT_FRAMES = 25 + +_baselines: dict[str, tracemalloc.Snapshot] = {} + + +def start_tracking(frames: int = _DEFAULT_FRAMES) -> None: + """Enable ``tracemalloc`` allocation tracking. Idempotent.""" + if not tracemalloc.is_tracing(): + tracemalloc.start(frames) + + +def is_tracking() -> bool: + """Return whether ``tracemalloc`` is currently tracking allocations.""" + return tracemalloc.is_tracing() + + +def take_snapshot() -> tracemalloc.Snapshot: + """Return a fresh ``tracemalloc`` snapshot. Caller ensures tracking is on.""" + return tracemalloc.take_snapshot() + + +def save_baseline(name: str, snapshot: tracemalloc.Snapshot) -> None: + """Store *snapshot* under *name* for later ``compare_with``.""" + _baselines[name] = snapshot + + +def get_baseline(name: str) -> tracemalloc.Snapshot | None: + """Return the snapshot stored under *name*, or ``None`` if unknown.""" + return _baselines.get(name) + + +def baseline_names() -> list[str]: + """List currently-stored baseline names, sorted.""" + return sorted(_baselines) + + +def drop_baseline(name: str) -> bool: + """Drop the named baseline; return whether it existed.""" + return _baselines.pop(name, None) is not None + + +def format_top_allocators( + snapshot: tracemalloc.Snapshot, + *, + baseline: tracemalloc.Snapshot | None = None, + top_n: int = 25, +) -> list[dict[str, Any]]: + """ + Return the top-*top_n* allocators in *snapshot* (or diff vs *baseline*). + + Each entry: ``{traceback, size_bytes, size_diff_bytes, count, count_diff}``. + Diff fields are zero when no baseline is supplied. ``traceback`` is + a list of ``":"`` strings, deepest frame last. + """ + if baseline is not None: + stats = snapshot.compare_to(baseline, "lineno") + else: + stats = snapshot.statistics("lineno") + return [_stat_to_dict(stat) for stat in stats[:top_n]] + + +def system_stats() -> dict[str, Any]: + """Return cheap process-wide memory stats — safe to call without tracking.""" + stats: dict[str, Any] = { + "gc_counts": list(gc.get_count()), + "sys_allocated_blocks": sys.getallocatedblocks(), + "tracking": tracemalloc.is_tracing(), + } + if tracemalloc.is_tracing(): + current, peak = tracemalloc.get_traced_memory() + stats["tracemalloc_current_bytes"] = current + stats["tracemalloc_peak_bytes"] = peak + stats["tracemalloc_overhead_bytes"] = tracemalloc.get_tracemalloc_memory() + max_rss = _max_rss_bytes() + if max_rss is not None: + stats["max_rss_bytes"] = max_rss + return stats + + +def _stat_to_dict(stat: Any) -> dict[str, Any]: + """Convert a ``tracemalloc`` Statistic / StatisticDiff to wire shape.""" + return { + "traceback": [str(frame) for frame in stat.traceback], + "size_bytes": stat.size, + "size_diff_bytes": getattr(stat, "size_diff", 0), + "count": stat.count, + "count_diff": getattr(stat, "count_diff", 0), + } + + +def _max_rss_bytes() -> int | None: + """Best-effort RSS high-water mark; ``None`` when ``resource`` is missing.""" + if resource is None: + return None + ru_maxrss = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + # macOS reports ru_maxrss in bytes; Linux / BSD report it in KB. + if sys.platform == "darwin": + return ru_maxrss + return ru_maxrss * 1024 diff --git a/tests/test_debug_memory.py b/tests/test_debug_memory.py new file mode 100644 index 00000000..54799cb5 --- /dev/null +++ b/tests/test_debug_memory.py @@ -0,0 +1,134 @@ +"""Tests for ``controllers/debug.py`` + ``helpers/memory.py``. + +Pins the wire shape of ``debug/memory_snapshot`` and the +save / compare / drop baseline contract. ``tracemalloc`` is +process-global, so each test stops + clears state in a fixture +to keep the suite hermetic. +""" + +from __future__ import annotations + +import tracemalloc +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from esphome_device_builder.controllers.debug import DebugController +from esphome_device_builder.helpers import memory +from esphome_device_builder.helpers.api import CommandError +from esphome_device_builder.models import ErrorCode + + +@pytest.fixture(autouse=True) +def _reset_tracemalloc() -> Any: + """Stop tracemalloc and forget baselines around every test.""" + if tracemalloc.is_tracing(): + tracemalloc.stop() + memory._baselines.clear() + yield + if tracemalloc.is_tracing(): + tracemalloc.stop() + memory._baselines.clear() + + +def _controller() -> DebugController: + """Build a controller against a stub DeviceBuilder — only ``_db`` is held.""" + return DebugController(MagicMock()) + + +async def test_first_call_enables_tracing_returns_note() -> None: + """Cold call enables tracemalloc and returns the helpful note.""" + assert not tracemalloc.is_tracing() + result = await _controller().memory_snapshot() + assert tracemalloc.is_tracing() + assert result["top_allocators"] == [] + assert result["baseline_names"] == [] + assert "tracemalloc was just enabled" in result["note"] + assert result["system"]["tracking"] is True + + +async def test_warm_call_returns_top_allocators() -> None: + """Second call (with tracing already on) returns real allocator stats.""" + memory.start_tracking() + # Force at least one Python-level allocation so the snapshot + # has something to report. + _payload = [object() for _ in range(100)] + assert _payload + + result = await _controller().memory_snapshot(top_n=10) + + assert "note" not in result + assert len(result["top_allocators"]) <= 10 + assert all("traceback" in entry for entry in result["top_allocators"]) + assert all("size_bytes" in entry for entry in result["top_allocators"]) + assert result["system"]["tracking"] is True + + +async def test_save_and_compare_baseline_roundtrip() -> None: + """``save_as`` then ``compare_with`` returns a diff against the baseline.""" + memory.start_tracking() + controller = _controller() + + saved = await controller.memory_snapshot(save_as="before") + assert "before" in saved["baseline_names"] + + # Allocate something the diff can pick up. + _growth = [bytearray(1024) for _ in range(50)] + assert _growth + + diff = await controller.memory_snapshot(compare_with="before", top_n=20) + assert "before" in diff["baseline_names"] + # At least one entry should show a non-zero size_diff after the + # bytearray allocation above. + assert any(entry["size_diff_bytes"] != 0 for entry in diff["top_allocators"]) + + +async def test_compare_with_unknown_baseline_raises_not_found() -> None: + """An unsaved baseline name is a user-facing NOT_FOUND error.""" + memory.start_tracking() + + with pytest.raises(CommandError) as exc_info: + await _controller().memory_snapshot(compare_with="nope") + + assert exc_info.value.code == ErrorCode.NOT_FOUND + + +async def test_drop_baseline_then_compare_raises_not_found() -> None: + """``drop_baseline`` removes the named snapshot from the store.""" + memory.start_tracking() + controller = _controller() + + await controller.memory_snapshot(save_as="tmp") + assert "tmp" in memory.baseline_names() + + await controller.memory_snapshot(drop_baseline="tmp") + assert "tmp" not in memory.baseline_names() + + with pytest.raises(CommandError) as exc_info: + await controller.memory_snapshot(compare_with="tmp") + assert exc_info.value.code == ErrorCode.NOT_FOUND + + +@pytest.mark.parametrize("bad_top_n", [0, -1, 1000, "10", None]) +async def test_invalid_top_n_raises_invalid_args(bad_top_n: Any) -> None: + """``top_n`` is range-validated; out-of-bounds / wrong-type rejects with INVALID_ARGS.""" + memory.start_tracking() + + with pytest.raises(CommandError) as exc_info: + await _controller().memory_snapshot(top_n=bad_top_n) + + assert exc_info.value.code == ErrorCode.INVALID_ARGS + + +def test_system_stats_includes_tracemalloc_when_tracing() -> None: + """``system_stats`` adds tracemalloc fields only when tracking is on.""" + cold = memory.system_stats() + assert cold["tracking"] is False + assert "tracemalloc_current_bytes" not in cold + + memory.start_tracking() + warm = memory.system_stats() + assert warm["tracking"] is True + assert "tracemalloc_current_bytes" in warm + assert "tracemalloc_peak_bytes" in warm From b948822b37bfdfc297b8fc90d3e9ab2f5ab5711a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 22 May 2026 01:01:49 +0200 Subject: [PATCH 2/2] debug: address review feedback on memory_snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - type-validate save_as / compare_with / drop_baseline before dict access — non-string values now reject with INVALID_ARGS instead of bubbling a TypeError out as INTERNAL_ERROR. - parse ESPHOME_DEBUG_MEMORY as a real on-shape boolean ("1" / "true" / "yes" / "on") so ESPHOME_DEBUG_MEMORY=0 doesn't silently enable tracking the way bool(os.environ.get(...)) would. - extract the gate into _memory_tracking_enabled_from_env() so the truthy table can be unit-tested directly. - multi-line test module docstring now starts on the line after the opening triple-quote (CLAUDE.md style). --- esphome_device_builder/__main__.py | 13 ++++++++- esphome_device_builder/controllers/debug.py | 18 ++++++++++-- tests/test_debug_memory.py | 22 ++++++++++++++- tests/test_main_cli.py | 31 +++++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/esphome_device_builder/__main__.py b/esphome_device_builder/__main__.py index 048664f4..0b442285 100644 --- a/esphome_device_builder/__main__.py +++ b/esphome_device_builder/__main__.py @@ -46,6 +46,17 @@ "CRITICAL": "red", } +# Accepted "on" spellings for ``$ESPHOME_DEBUG_MEMORY``. Anything else +# (``0`` / ``false`` / empty / unset / a typo) leaves tracemalloc off +# so a bare ``ESPHOME_DEBUG_MEMORY=0`` doesn't silently turn it on the +# way a ``bool(os.environ.get(...))`` check would. +_DEBUG_MEMORY_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def _memory_tracking_enabled_from_env() -> bool: + """Whether ``$ESPHOME_DEBUG_MEMORY`` is set to an "on"-shape value.""" + return os.environ.get("ESPHOME_DEBUG_MEMORY", "").strip().lower() in _DEBUG_MEMORY_TRUTHY + def _setup_logging(log_level: str, log_file: str | None = None) -> None: """Set up logging with a coloured console handler and an optional rotating file.""" @@ -106,7 +117,7 @@ def main() -> None: # produce diffs that include the catalog loads and other # startup allocations. Off by default — adds per-allocation # overhead. - if os.environ.get("ESPHOME_DEBUG_MEMORY"): + if _memory_tracking_enabled_from_env(): import tracemalloc # noqa: PLC0415 tracemalloc.start(25) diff --git a/esphome_device_builder/controllers/debug.py b/esphome_device_builder/controllers/debug.py index a83ee3c7..05bc5f9b 100644 --- a/esphome_device_builder/controllers/debug.py +++ b/esphome_device_builder/controllers/debug.py @@ -15,6 +15,17 @@ _LOGGER = logging.getLogger(__name__) _MAX_TOP_N = 200 +_MAX_BASELINE_NAME_LEN = 100 + + +def _validate_baseline_name(value: Any, *, field: str) -> str: + """Return *value* as a non-empty bounded-length ``str`` or raise INVALID_ARGS.""" + if not isinstance(value, str) or not value or len(value) > _MAX_BASELINE_NAME_LEN: + raise CommandError( + ErrorCode.INVALID_ARGS, + f"{field} must be a non-empty string of at most {_MAX_BASELINE_NAME_LEN} characters", + ) + return value class DebugController: @@ -53,7 +64,7 @@ async def memory_snapshot( ) if drop_baseline is not None: - memory.drop_baseline(drop_baseline) + memory.drop_baseline(_validate_baseline_name(drop_baseline, field="drop_baseline")) if not memory.is_tracking(): memory.start_tracking() @@ -74,6 +85,7 @@ async def memory_snapshot( baseline = None if compare_with is not None: + compare_with = _validate_baseline_name(compare_with, field="compare_with") baseline = memory.get_baseline(compare_with) if baseline is None: raise CommandError( @@ -81,8 +93,8 @@ async def memory_snapshot( f"baseline {compare_with!r} not saved; known: {memory.baseline_names()}", ) - if save_as: - memory.save_baseline(save_as, snapshot) + if save_as is not None: + memory.save_baseline(_validate_baseline_name(save_as, field="save_as"), snapshot) return { "system": memory.system_stats(), diff --git a/tests/test_debug_memory.py b/tests/test_debug_memory.py index 54799cb5..1d4a7dfb 100644 --- a/tests/test_debug_memory.py +++ b/tests/test_debug_memory.py @@ -1,4 +1,5 @@ -"""Tests for ``controllers/debug.py`` + ``helpers/memory.py``. +""" +Tests for ``controllers/debug.py`` + ``helpers/memory.py``. Pins the wire shape of ``debug/memory_snapshot`` and the save / compare / drop baseline contract. ``tracemalloc`` is @@ -121,6 +122,25 @@ async def test_invalid_top_n_raises_invalid_args(bad_top_n: Any) -> None: assert exc_info.value.code == ErrorCode.INVALID_ARGS +@pytest.mark.parametrize( + ("field", "value"), + [ + ("save_as", ["list-not-string"]), + ("save_as", ""), + ("save_as", "x" * 101), + ("compare_with", {"dict": "no"}), + ("compare_with", 42), + ("drop_baseline", None.__class__), # arbitrary unhashable-ish junk + ], +) +async def test_non_string_baseline_name_raises_invalid_args(field: str, value: Any) -> None: + """Baseline-name fields type-check before reaching the dict, not after.""" + memory.start_tracking() + with pytest.raises(CommandError) as exc_info: + await _controller().memory_snapshot(**{field: value}) + assert exc_info.value.code == ErrorCode.INVALID_ARGS + + def test_system_stats_includes_tracemalloc_when_tracing() -> None: """``system_stats`` adds tracemalloc fields only when tracking is on.""" cold = memory.system_stats() diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py index 59ec9561..9f6efd20 100644 --- a/tests/test_main_cli.py +++ b/tests/test_main_cli.py @@ -369,3 +369,34 @@ def test_main_runs_device_builder_when_lock_acquired( device_builder_ctor.assert_called_once() instance.run.assert_called_once() + + +# --------------------------------------------------------------------------- +# _memory_tracking_enabled_from_env — ESPHOME_DEBUG_MEMORY gate +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "True", "yes", "on", " 1 ", " YES "]) +def test_memory_tracking_env_gate_truthy( + value: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Documented on-shapes (case-insensitive, whitespace allowed) enable tracking.""" + monkeypatch.setenv("ESPHOME_DEBUG_MEMORY", value) + assert main_module._memory_tracking_enabled_from_env() is True + + +@pytest.mark.parametrize("value", ["0", "false", "no", "off", "", "nope", "2"]) +def test_memory_tracking_env_gate_falsy( + value: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anything outside the on-shape set leaves tracking off — including ``0``.""" + monkeypatch.setenv("ESPHOME_DEBUG_MEMORY", value) + assert main_module._memory_tracking_enabled_from_env() is False + + +def test_memory_tracking_env_gate_absent(monkeypatch: pytest.MonkeyPatch) -> None: + """Unset env var defaults to disabled — the gate's whole point.""" + monkeypatch.delenv("ESPHOME_DEBUG_MEMORY", raising=False) + assert main_module._memory_tracking_enabled_from_env() is False