Memory Management
FEX.Core.dll exposes three distinct memory ownership patterns. Mixing them
up is the single biggest source of crashes in callers, so this page is the
short version every integrator should read once.
- The three patterns at a glance
- Pattern 1 — DLL-allocated single buffer (V2)
- Pattern 2 — DLL-allocated columnar arrays (V3)
- Pattern 3 — Caller-allocated buffer (raw read)
- Pitfalls
- Thread safety
The three patterns at a glance
| Pattern | Used by | DLL allocates | Caller frees with | Wraps cleanly with using |
|---|---|---|---|---|
| 1 — Single buffer (V2) | GetVersionAsJSON, GetLicenseInfoAsJSON, GetImageInfoByFilenameAsJSON, GetImageInfoByIdAsJSON, GetPartitionInfoAsJSON, GetFilePath, GetItemInfo, GetFileSystemRecords_V2, GetAvailableProperties, GetFilePropertiesFromPathAsJSON, GetFilePropertiesAsJSON, GetImageTypeAsString |
yes | FreeAllocatedBuffer(IntPtr) |
yes |
| 2 — Spec-array columns (V3) | CreatePropertySpecsFromJSON, GetFileSystemRecordsCustom_V2, GetFileSystemRecordsCustom_V3 |
yes | FreePropertySpecArray(IntPtr, int) |
yes |
| 3 — Caller-allocated raw read | ReadFileData, ReadImageData, GetVersion (legacy) |
no | caller's own free / GC | n/a |
Treat the V2 and V3 patterns as the standard. Pattern 3 only appears for raw byte streaming and exists because pre-allocating into a caller-owned buffer avoids per-chunk allocation churn.
Pattern 1 — DLL-allocated single buffer (V2)
out IntPtr Buffer (the DLL writes the pointer here)
out uint BufferSize (the DLL writes the byte count here)
The DLL allocates a fresh buffer for each call and writes the pointer into
your out IntPtr. You must call FreeAllocatedBuffer exactly once per
successful call, even if you've already copied the data out. The size is
inclusive of the trailing null for string buffers.
Canonical C# pattern
public static string GetVersion()
{
IntPtr buffer = IntPtr.Zero;
try
{
int rc = NativeMethods.GetVersionAsJSON(out buffer, out uint size);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("GetVersionAsJSON failed", rc);
// Buffer is UTF-8 with a null terminator counted in size; strip it.
return Marshal.PtrToStringUTF8(buffer, (int)size)?.TrimEnd('\0')
?? throw new FexCoreException("Empty buffer", NativeMethods.RESULT_ERROR);
}
finally
{
if (buffer != IntPtr.Zero)
NativeMethods.FreeAllocatedBuffer(buffer);
}
}
Three rules:
- Initialise
buffer = IntPtr.Zerobefore the call. - Free in
finallyso an exception during marshalling can't leak. - Don't free a non-
RESULT_OKbuffer — but defensively guarding withbuffer != IntPtr.Zerocovers both cases and is harmless.
When the buffer holds a struct array
GetFileSystemRecords_V2 returns an array of TFileRecord. The single
Buffer allocation owns:
- the array itself
- every
filenameUTF-8 string pointed to from the records - every
childrenlist pointed to from directory records
You free the whole graph with one call to FreeAllocatedBuffer. Do not
attempt to free individual filename or children pointers.
IntPtr records = IntPtr.Zero;
try
{
NativeMethods.ReadFileSystem(imageId, out _);
int rc = NativeMethods.GetFileSystemRecords_V2(
imageId, out uint count, out records, out _);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("GetFileSystemRecords_V2", rc);
int stride = Marshal.SizeOf<FileRecordNative>();
IntPtr cur = records;
for (int i = 0; i < count; i++)
{
var native = Marshal.PtrToStructure<FileRecordNative>(cur);
// copy whatever you need into managed types here
cur = IntPtr.Add(cur, stride);
}
}
finally
{
if (records != IntPtr.Zero)
NativeMethods.FreeAllocatedBuffer(records);
}
Once you've called FreeAllocatedBuffer, every IntPtr you copied out of
those records — including any cached filename pointers — is dangling.
Resolve them to managed strings before the free.
Pattern 2 — DLL-allocated columnar arrays (V3)
V3 returns columnar data: each TPropertySpec ends up holding an array of
FNumberValue values, each FValueSize bytes wide, in FDataPtr.
The cleanup boundary is the whole spec array, not individual columns.
FreePropertySpecArray(specPtr, specCount) walks the array, frees every
FDataPtr (and any string slots inside string columns), and finally frees
the spec array itself.
IntPtr specs = IntPtr.Zero;
int count = 0;
try
{
int rc = NativeMethods.CreatePropertySpecsFromJSON(
@"[{""name"":""EntryName""},{""name"":""LogicalSize""}]",
out specs, out count);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("CreatePropertySpecsFromJSON", rc);
rc = NativeMethods.GetFileSystemRecordsCustom_V3(
imageId, specs, count,
startRecord: 0, requestedCount: 100_000,
out uint total);
if (rc != NativeMethods.RESULT_OK)
throw new FexCoreException("GetFileSystemRecordsCustom_V3", rc);
// ... read the columns out of each spec's FDataPtr ...
}
finally
{
if (specs != IntPtr.Zero)
NativeMethods.FreePropertySpecArray(specs, count);
}
Key consequences:
- Never call
FreeAllocatedBufferon a column'sFDataPtr— that pointer is owned by the spec array, not by the V2 allocator. - Never call
FreePropertySpecArrayon a buffer returned by a V2 function — it would try to interpret the bytes asTPropertySpecrecords. - Each call to
GetFileSystemRecordsCustom_V3re-populates the columns in-place, so you must callFreePropertySpecArray(or call the V3 function again) before discarding the spec pointer; otherwise you leak the previous page's columns.
For paginated reads, the typical loop frees once at the end:
try
{
for (int page = 0; ; page++)
{
int got = ReadOnePage(specs, count, page * pageSize, pageSize);
if (got == 0) break;
// ... process this page's columns ...
}
}
finally
{
NativeMethods.FreePropertySpecArray(specs, count);
}
Each iteration of ReadOnePage reuses the same spec array; the DLL frees
the previous columns internally before re-populating.
Pattern 3 — Caller-allocated buffer (raw read)
ReadFileData and ReadImageData write into a buffer you supply:
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int ReadFileData(
int imageId,
int fileIndex,
long offset,
IntPtr buffer,
int bufferSize,
out int bytesRead);
Allocation is yours; release is yours. The DLL writes up to bufferSize
bytes to buffer and reports the actual count in bytesRead. There is
no FreeAllocatedBuffer here.
The FexViewer extract command reads in 1 MB chunks:
const int CHUNK = 1024 * 1024;
IntPtr scratch = Marshal.AllocHGlobal(CHUNK);
try
{
long offset = 0;
while (offset < logicalSize)
{
int want = (int)Math.Min(CHUNK, logicalSize - offset);
int rc = NativeMethods.ReadFileData(
imageId, fileIndex, offset, scratch, want, out int got);
if (rc != NativeMethods.RESULT_OK || got <= 0) break;
var slice = new byte[got];
Marshal.Copy(scratch, slice, 0, got);
outputStream.Write(slice, 0, got);
offset += got;
}
}
finally
{
Marshal.FreeHGlobal(scratch);
}
AllocHGlobal is paired with FreeHGlobal. Marshal.AllocCoTaskMem works
too if you prefer that allocator. Don't use stackalloc for buffers larger
than a few KB.
Pitfalls
Double-free
Calling FreeAllocatedBuffer twice on the same pointer is undefined
behaviour. Either zero the pointer after freeing, or rely on try/finally
flowing through exactly once.
Mixing free functions
| If the buffer came from… | Free with… |
|---|---|
any V2 export with out IntPtr Buffer |
FreeAllocatedBuffer |
CreatePropertySpecsFromJSON |
FreePropertySpecArray |
GetFileSystemRecordsCustom_V2 / _V3 |
FreePropertySpecArray |
Marshal.AllocHGlobal (yours) |
Marshal.FreeHGlobal |
If you are looking at an IntPtr and you can't immediately answer "where
did this come from", you have a bug.
Reading after free
Every Marshal.PtrToStructure<T>, Marshal.PtrToStringUTF8, and direct
pointer dereference must happen before the cleanup call. Once
FreeAllocatedBuffer returns, every secondary pointer that was inside the
freed graph is dangling. The compiler can't catch this; structure your
code so reads precede frees.
Caching pointers across calls
The FDataPtr columns in a V3 spec are only valid until the next call to
GetFileSystemRecordsCustom_V3 on the same spec array. Re-resolve after
each page.
Returning DLL pointers from your own functions
Don't. Convert to managed types (string, byte[], your own DTOs) inside
the function that owns the cleanup. Letting an IntPtr from the DLL escape
into application code makes the lifetime contract impossible to follow.
RESULT_PASSWORD_REQUIRED / RESULT_PASSWORD_INCORRECT cleanup
When OpenImage returns one of these, no ImageID is allocated — there is
nothing to close. Don't call CloseImage. Re-attempt OpenImage with a
different password instead.
Thread safety
FEX.Core.dll is fully thread-safe: callers do not need external locks.
You can issue any combination of V2 / V3 reads against the same image from
multiple threads concurrently — internal locking serialises the metadata
mutation paths and lets readers run in parallel.
The one rule: each allocation belongs to whichever thread frees it. If
thread A obtains a V2 buffer, thread B can read from it, but the call to
FreeAllocatedBuffer must be made exactly once total — pick one thread
and stick to it. The simplest model is "the same thread that called the
allocating function calls the matching free", which is what try/finally
naturally enforces.