Memory lifetime tracking

Memory lifetime tracking determines the lifetimes of heap allocations made by a recorded program. These lifetimes can be queries using the uexperimental lifetime info command for a single pointer expression, or the uexperimental lifetime report command for all local variables. For each heap allocation, these commands report its address, its size, the time that it was allocated, and, if it was freed, the time it was freed. This can be used to investigate memory leaks and use-after-free bugs: you can ask whether a pointer refers to memory that is still valid, or list allocations that were never freed.

Memory lifetime tracking works by watching the program’s allocator functions, and replaying execution history to reconstruct what was allocated and freed, and when. Use the uexperimental lifetime function add command to customize the set of allocator functions. If there are no custom allocators, the standard C library functions malloc(), free(), calloc(), realloc(), and reallocarray() are used.

Warning

Memory lifetime tracking is an experimental feature. The uexperimental lifetime commands and their output may change without notice.

Querying lifetimes

uexperimental lifetime info expression

Report the memory lifetime status of expression, which is evaluated in the current frame and must evaluate to a pointer, smart pointer, or reference.

For example:

99% 7,513> uexperimental lifetime info ptr
Auto-detecting standard allocator functions for lifetime tracking.
ptr = (int *) 0x5555555592a0
"ptr" points to a valid memory location that was allocated at time 7,050:0x7ffff7e55650.

uexperimental lifetime report

Report the lifetime status of each pointer variable in the current frame. Each pointer is classified as pointing to allocated (alive) or freed (dead) heap memory, to a valid or invalid stack location, to static memory, or to unmapped memory.

For example:

99% 7,513> uexperimental lifetime report
Memory report for "main" at time 7,513:0x55555555521f.
Name    Kind    Type    Value           Status
------  ------  ------  --------------  -------------------------------------
ptr     local   int *   0x5555555592a0  alive (alloc at 7,050:0x7ffff7e55650)

uexperimental lifetime dump-all [-bt-depth N] filename

Write the lifetime of each heap allocation in execution history to filename, formatted in JSON. The data is grouped into allocations that are still alive, allocations that have been freed, and allocations that are in progress. Each entry includes the address, size and the time range during which the allocation was live.

-bt-depth N

Also record, for each allocation, a backtrace of up to N frames captured at the point of allocation, and include it in the output. This may avoid the need to time-travel to the point of allocation.

Backtraces are experimental. They are gathered by walking the frame-pointer chain, so they are only meaningful for programs compiled with frame pointers (for example with the -fno-omit-frame-pointer option to gcc or clang), and frames are silently dropped where the chain cannot be followed.

To generate the lifetimes, this command replays the whole of execution history, which is slow if history is long. The lifetimes are cached and reused in subsequent queries if possible.

For example:

99% 7,513> uexperimental lifetime dump-all lifetimes.json
99% 7,513> python import json
99% 7,513> python print(json.dumps(json.load(open("lifetimes.json")), indent=4))
{
    "alive": [
        {
            "addr": 93824992252608,
            "len": 1024,
            "start": {
                "bbcount": 7311,
                "pc": 140737352390224
            },
            "end": null,
            "tid": 83
        }
    ],
    "dead": [
        {
            "addr": 93824992252576,
            "len": 4,
            "start": {
                "bbcount": 7050,
                "pc": 140737352390224
            },
            "end": {
                "bbcount": 7515,
                "pc": 140737352391984
            },
            "tid": 83
        }
    ]
}

Custom allocators

If your program uses its own allocation functions, register them using the uexperimental lifetime function add command, so that the lifetimes of objects allocated using these functions can be tracked.

Note that changing the set of custom allocator functions discards the cached lifetimes, if any, so that the next query command must regenerate the lifetimes.

uexperimental lifetime function auto-detect

Detect and register the standard C library allocation functions (malloc(), free(), calloc(), realloc(), and reallocarray()).

uexperimental lifetime function add [options] function

Register function as a custom allocator. Use the options to describe which argument carries each relevant value. For example, uexperimental lifetime function add -size-arg 0 my_malloc.

-size-arg INDEX, -s INDEX

The index of the allocation size argument, for a function that behaves like malloc(), calloc(), realloc(), or reallocarray().

-free-arg INDEX, -f INDEX

The index of the pointer argument, for a function that behaves like free(), realloc(), or reallocarray().

-count-arg INDEX, -c INDEX

The index of the element count argument, for function that behaves like calloc() or reallocarray(). The total allocated size is the element count parameter multiplied by the size parameter.

uexperimental lifetime function list

List the custom allocator functions registered by the uexperimental lifetime function add command.

uexperimental lifetime function remove function

Remove function from the list of custom allocator functions.

uexperimental lifetime function save filename

Save the custom allocator functions to filename. They can later be restored using the uexperimental lifetime function load command.

uexperimental lifetime function load filename

Load the custom allocator functions from filename, which must have previously been created using the uexperimental lifetime function save command.

Comparison with Address Sanitizer and Valgrind

The Address Sanitizer (ASan) and Valgrind’s Memcheck tools also find memory errors, but work in a different way. ASan and Valgrind watch the program as it runs and report an error at the instant memory is misused, while memory lifetime tracking does not observe the live program at all. It reconstructs the lifespan of each allocation by replaying execution history.

Where lifetime tracking does better

Analysis of a captured incident.

The recording is made without any memory tooling in place, and you decide to investigate lifetimes afterwards. With ASan you must recompile and rerun, and with Valgrind you must rerun under the tool. Both require you to reproduce the failure under the tool, which can often be the hard part for memory bugs. A recording captures the one run that went wrong and you can analyze it as many times as you like, along with the time-travel capabilities of UDB, allowing for an easy investigation and root cause analysis.

Questions about arbitrary points in time, not just the moment of the crash.

uexperimental lifetime info ptr answers whether a pointer was valid at the current time in the recording, and you can move to any time and ask again. A single lifetime generation pass shows both leak information and use-after-free information. ASan and Valgrind report at the moment of misuse and cannot look back.

No source changes or recompilation.

Allocators are matched by symbol, so tracking works on any ordinary build. In addition, custom allocators can be registered by name with uexperimental lifetime function add.

Where ASan and Valgrind do better

Buffer overflows and out-of-bounds access.

ASan’s red zones catch reads and writes just past the end (or before the start) of an allocation, and Valgrind detects many invalid accesses too. Lifetime tracking records the exact address range of each allocation but does not check its surroundings, so it does not flag an overflow that stays within mapped memory.

Uninitialized memory.

Valgrind’s Memcheck tracks definedness at the bit level and reports reads of uninitialized data. Lifetime tracking has no equivalent: it knows when memory was allocated and freed, not whether it was written before being read.

Reliable allocation backtraces.

ASan and Valgrind attach a dependable backtrace to every allocation and free. Lifetime backtraces are experimental, are gathered by walking the frame-pointer chain, and are therefore only meaningful when the program was compiled with frame pointers (see the -bt-depth option to the uexperimental lifetime dump-all command).

Immediate, unconfigured allocator coverage.

ASan and Valgrind understand the C runtime’s allocators out of the box, including many inlined or internal paths. Lifetime tracking follows the functions it has been told about (the standard allocators by default), so unusual or inlined allocators may need manual registering.

In short, ASan and Valgrind are strongest when you can readily reproduce a failure and want the widest catalog of error kinds flagged the moment they happen. Memory lifetime tracking is strongest when the failure is already recorded, especially if it is flaky or came from production, and you want to reason about what was valid when, with the whole execution available to explore.