Memory budgets, caching, and source contracts
A capture may be incomplete or corrupt. A live source may change between reads. Even a correct image can describe a graph so large that an application should not traverse all of it. Code that reads untrusted bytes has to plan for these cases, because the alternative is an analyzer that hangs, exhausts memory, or reports a plausible value that was never in the image. This guide explains the tools the memory APIs provide for that planning, and the contract you must honor when you write a source of your own.
Budget an operation, not each individual read
A MemoryAccessContext is a small object that counts work against four limits and carries a cancellation token.
Set the limits you want to change in an object initializer, such as
new MemoryAccessContext { MaxTotalBytes = 4096, MaxRequests = 200 }; the others keep their defaults. Every limit
must be positive, so setting zero or a negative number throws ArgumentOutOfRangeException immediately.
| Property | Default | What it bounds |
|---|---|---|
MaxTotalBytes |
64 MiB | Bytes requested from leaf sources plus bytes staged for output |
MaxRequests |
100,000 | Every unit of work: leaf reads, mapping lookups, path steps, traversal steps |
MaxNestingDepth |
256 | Nesting of composite values (a struct inside a struct inside an array...) and stacked source layers |
MaxPointerDepth |
64 | Pointer .value steps followed by one path, such as next.value.next.value |
CancellationToken |
none | Checked before each charged request |
The nesting and pointer defaults are the same as the core parser's ReadOptions.MaxNestingDepth and
ReadOptions.MaxPointerDepth. Nesting counts value levels inside one pointer target and starts again at each
target, so a long pointer chain is limited by MaxPointerDepth, not by MaxNestingDepth. A budget exceeded for
any of the four limits fails with MemoryFailure.BudgetExceeded.
The important idea is one context per logical operation. If a list walk reads twenty links through three mapping layers, all of that work should count against one budget. Pass the same instance through callbacks and adapters; the library does this for you inside a session and a walker. Create a fresh instance for each independent operation. Contexts are counters, not thread-safe objects, so never share one between concurrent operations.
The counters measure library work, not unique addresses or hardware I/O calls. Reading the same physical byte twice charges it twice. A mapping lookup or traversal step charges one request with zero bytes; a leaf read charges the bytes it requested. Serialization and patch staging also charge work. A cache hit avoids the backing-byte charge but still costs a request, because cheap operations in an unbounded loop are still an unbounded loop.
This is why MaxTotalBytes = 4 is not enough to plan and commit a four-byte patch: planning reads the expected bytes,
commit validates them again, and the write itself is charged. Choose limits from the size of the task, then treat
BudgetExceeded as "the operation was incomplete", never as "the result was empty".
Cache stable, repeated selections
An analyzer often reads the same few fields many times: the next link of every node, the header of every page.
CachedMemorySource sits in front of another source and remembers the bytes of exact requests it has already
answered.
var session = new MemorySession(PortableMemorySchema.Create(new CStruct("struct Record { uint32 value; };"), "Record"));
var source = new ByteArrayMemorySource("snapshot", new byte[] { 42, 0, 0, 0 });
var cache = new CachedMemorySource("cached", source, capacity: 16);
var region = new MemoryRegion(cache, 0, 4);
// Two separate contexts make the cost of each read visible: the first misses, the second hits.
var cold = new MemoryAccessContext { MaxTotalBytes = 16, MaxRequests = 20, };
var warm = new MemoryAccessContext { MaxTotalBytes = 16, MaxRequests = 20, };
Require((uint)session.Read(region, "Record", "value", cold)! == 42, "Cold result");
Require((uint)session.Read(region, "Record", "value", warm)! == 42, "Warm result");
Require(cold.BytesRequested == 4 && warm.BytesRequested == 0, "Backing bytes avoided on hit");
Require(warm.Requests > 0, "Cache hits still consume work");
// Writing to the source advances its generation; the cache notices and discards its entries.
source.Write(0, new byte[] { 7, 0, 0, 0 }, new MemoryAccessContext());
Require((uint)session.Read(region, "Record", "value")! == 7, "Generation invalidates cache");
Both reads return 42. The cold read requests four backing bytes; the warm read requests zero backing bytes and still spends requests. Writing to the underlying byte array advances its generation, so the next cached read returns 7 instead of a stale value.
The cache is deliberately simple so that its behavior is predictable:
- Entries are keyed by the exact
(address, requested length)pair. Reading four bytes and then two bytes at the same address is not a hit on the four-byte entry. There is no page prefetch and no merging of overlapping ranges, so the cache never reads neighboring bytes that might be unavailable. - Capacity is a retained byte count. Eviction is first-in, first-out, and a hit does not move an entry to the end. A request larger than the capacity is answered but not stored. A short read is not stored, because it does not describe the complete range.
- It is read-only. Plan writes through the writable source beneath it; that source's generation change then invalidates the cache before its next read.
A generation is the source's own change counter. The cache compares it on every read and empties itself when
it differs. That works only if the source reports every change. ByteArrayMemorySource and OverlayMemorySource
do; StreamMemorySource cannot see external file changes and keeps the constant generation you gave it. Caching a
live source whose changes are not reported can return obsolete data indefinitely, so use a stable snapshot or a
source that advances its generation on every write.
Distinguish absent data from cancellation
// An empty mapping table means no address is available; the failure names the address and the path.
var missing = new MemoryRegion(new MappedMemorySource("missing", Array.Empty<MemoryMapping>()), 0x1000, 4);
try
{
session.Read(missing, "Record", "value");
throw new InvalidOperationException("Expected an unmapped address.");
}
catch (MemoryAccessException error) when (error.Failure == MemoryFailure.Unmapped)
{
Require(error.Address == 0x1000 && error.Path == "Record.value", "Failure coordinates");
Require(error.Code == CStructErrorCode.ReadFailed, "Core read failure code");
Require(error.LogicalRegion == missing, "Requested root retained");
}
// A cancelled token stops the operation before any bytes are read; it is not a memory failure.
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
try
{
session.Read(region, "Record", "value", new MemoryAccessContext { CancellationToken = cancellation.Token, });
throw new InvalidOperationException("Expected cancellation.");
}
catch (OperationCanceledException)
{
// Cancellation is a caller control signal, not an unavailable-memory result.
}
This snippet continues with the session and region from the cache example. A MemoryAccessException carries a
stable Failure category plus the failing source label (SourceId), address (Address), and byte length. Both
coordinates are null when the failure is not tied to a source address: a depth limit belongs to the traversal, and
the byte budget of Serialize output belongs to bytes that no source holds yet. When the failure crosses a session
call, the exception also records the requested Path (such as Record.value) and the logical root
LogicalRegion. The failing address may be a backing-file offset while the logical root is a process address;
display both with their source labels rather than comparing them as if they were the same coordinate system.
| Failure | Typical interpretation |
|---|---|
Unmapped |
No mapping covers the address, or a write is outside its source |
MissingBytes |
A requested backing range cannot supply bytes, for example a truncated file |
BudgetExceeded |
The operation limit or an overlay's capacity was reached |
StaleSource |
The source's reported state differs from the expected snapshot |
SourceFailure |
A backing read failed or an adapter returned an invalid count |
InvalidValue |
An addressed operation cannot use the value, such as following a null pointer |
Memory analysis reports failures with the same exception hierarchy as the rest of CStructSharp, described in
Errors and recovery. MemoryAccessException is a CStructReadException: its Code is
ReadLimitExceeded for BudgetExceeded and ReadFailed for every other category. The other failures of a
memory operation use the core types directly:
| Exception | Raised when |
|---|---|
CStructPathException |
A path is malformed or names no member, element, or pointer that can be followed, including an index past the declared count and an unknown root type ID |
CStructWriteException |
A value for Serialize or PlanUpdate does not have the declared shape, or a bit-slice value is out of range; MemoryPatchCommitException is one too |
CStructLayoutException |
A schema definition is invalid, or BTF, ISF, or Portable metadata is malformed or unsupported |
ArgumentException and its subclasses |
A parameter breaks its contract, such as a null argument, a limit of zero or less, or a replacement of the wrong length |
One catch (CStructException) therefore handles every expected failure while argument errors, which indicate a bug
in the calling code, still propagate. Cancellation uses OperationCanceledException before writes and may be wrapped
by a patch commit failure once writing has begun. Writing through a read-only source throws NotSupportedException. Walkers convert only Unmapped and
MissingBytes into an Unavailable result, because those describe the image; everything else describes the
operation and propagates. See patch failure semantics.
Cancellation is cooperative. The context checks its token between synchronous work requests. A source blocked in an operating-system call, or a user callback that never returns, cannot be interrupted by this API. A custom transport needs its own timeout or cancellation behavior in addition to the shared context checks.
Implement a custom source
Implement IMemorySource when bytes come from a debugger, a remote reader, a decompressor, or any transport the
library does not know. The interface is small: one synchronous method,
Read(ulong address, Span<byte> destination, MemoryAccessContext context), and two properties, Id and
Generation. The contract that makes the rest of the library work is larger than the signature, so keep this list
beside your implementation:
- Define the coordinate system. State whether addresses are process virtual addresses, physical addresses, or file offsets. Never narrow an unsigned address to a signed value without checking that it fits.
- Return the real count. Copy at most
destination.Lengthbytes and return how many you copied. A positive short read is allowed; callers continue at the next byte. Zero means no bytes are available for the request. Finite views treat an unexpected zero asMissingBytes, not as a normal end of file. - Never fake data. Do not retain the caller's span after returning, and do not turn a transport error or an
unmapped hole into zero-filled success. Wrap the underlying cause in a
MemoryAccessExceptionwithSourceFailurewhere appropriate, so the caller sees a structured failure. - Charge before doing work. Call
context.Charge(Id, address, requestedBytes)before a leaf read. Translation-only adapters charge zero-byte requests and forward the same context to their backing source. Check cancellation in longer loops. - Be honest about change. Advance a monotonic
Generationfor every observable change, or document that the caller must hold a stable snapshot and report a constant value. Never advertise a generation that can miss changes; a wrong generation silently poisons caches and patches.
IWritableMemorySource adds Write. A successful call must write the complete span and advance the generation.
It returns no byte count. A failing implementation may have partly written, which is why patch commit reports the
failing fragment as uncertain. Keep ownership of handles and transports explicit: sessions, regions, mapping
sources, and stream views never dispose caller-owned resources.
Individual byte-array and overlay operations are synchronized, and the stream adapter synchronizes access through itself while restoring the caller's stream position. These locks cover single operations only. They do not cover direct access to the stream from other code, or a whole multi-source analysis. For a consistent report, arrange a snapshot or application-level synchronization.
Expected unsuccessful reads
TrySelect, TryBind, prepared TryResolve and typed TryRead return structured failures for expected absence.
The failure distinguishes unknown types, missing members, ambiguity, invalid paths, incompatible result types,
incomplete targets, null pointers, insufficient storage, rejected scalar values and unavailable memory. Memory failures
retain their source, unsigned address, requested byte count, logical root and requested path. ToException() creates a
detailed exception when a caller needs the throwing form.
Common missing-member, null-pointer and zero-byte-source outcomes do not throw internally. A caller-defined source
or codec may itself use exceptions; those implementations keep their own cost. Invalid API arguments still throw,
cancellation remains OperationCanceledException, and exhausted byte, request or depth budgets remain terminating
MemoryAccessException failures. A recovery loop must not turn those terminating failures into absent evidence.
Caller-owned arrays and explicit bulk transport
ReadArray<T>(storage, elementTypeId, destination, context) reads exactly destination.Length elements within the
explicit storage extent. That extent is separate from a fixed-size root header, so flexible arrays and pointer/count
pairs need a caller-validated permitted region. Count times element stride is checked before staging or transport.
A count never grants access beyond that region. Numeric values use the schema's byte order. Pointer destinations use
ulong at every supported stored width and preserve tags without following pointers. Enums and custom codecs use
the ordinary value-reading API.
The default mode reads one element per transport operation. MemoryArrayReadMode.Bulk explicitly requests bounded
chunks, at most chunkBytes bytes each. It changes source request sizes, request charges and failure boundaries;
ordinary MemorySession.Read keeps its existing behavior of reading one element at a time. A translating source must handle every virtual
page independently. Translating only the first byte cannot prove that the whole range is physically contiguous.
TryReadArray reports elementsWritten. On unavailable memory it commits every complete element in the confirmed
copied prefix, including the complete prefix of the final failed chunk. An incomplete element and all unread elements
remain untouched. Cancellation and budget exhaustion propagate; earlier committed elements remain written. Empty
arrays make no transport requests but still check cancellation and charge the array operation. Positive short reads
are retried and holes never become zero-filled values.
ReadBytes copies a declared uint8 or int8 array into an exact-count byte span, or returns its final owned byte
array. It preserves raw signed storage and reads one element at a time. The owned form checks the remaining budget
before allocating its result. All destinations and backing sources remain owned by their callers.
Check your understanding
- A walk uses
maxNodes: 10and a context withmaxRequests: 12. Each node requires one traversal step and one link read through one mapping layer. Does the walk finish, and if not, how does it fail? - You wrap a
FileStreamin aStreamMemorySourceand then in aCachedMemorySource. Another program appends to the file and changes bytes you already read. What does the cache return, and what should you have done? - Your custom source reads from a network service that returns "not found" for unmapped pages. Should
Readreturn 0, return zero-filled bytes, or throw?
Answers: it does not finish; each node costs four requests (the walk step, the session's field selection,
the mapping lookup, and the leaf read), so the thirteenth request, at the start of the fourth step, throws
MemoryAccessException with BudgetExceeded, and the exception propagates out of the walk instead of becoming an
Unavailable result. The old bytes; the stream adapter's generation is
constant, so the cache has no way to notice; take a stable snapshot or copy of the file before analysis, or advance
the generation yourself by constructing a new adapter. Throw MemoryAccessException with Unmapped, or
return 0 if the page is genuinely absent from the capture; returning zero-filled bytes would present a hole as data.
Run all examples with the commands on the synthetic memory example page.