When a Delphi constructor raises an exception, the RTL immediately calls the destructor on the half-built instance, so every destructor must cope with fields that were never assigned. HotXLS applies that rule to its destructors, TXLSXRichText and TXLSXCells included: an allocation failure inside those constructors reaches your code as the original EOutOfMemory, not as a secondary access violation raised during clean-up
The bug class is old and easy to miss. A service converts a 300 MB workbook on a busy server, memory runs short, and the log shows an access violation inside a destructor. Nothing about that message says "out of memory", nothing suggests that trying again later would work, and the stack trace points at a destructor that looks perfectly correct. The original exception existed for a few microseconds and then got replaced. This article walks through why that happens, how HotXLS structures its destructors to avoid it, and how to write your own classes so they never do it
What happens when a Delphi constructor raises?
The object is destroyed for you before the exception reaches the caller, and your destructor runs against whatever state the constructor reached. The sequence is fixed by the compiler and the RTL. NewInstance allocates the instance and InitInstance zero-fills it, so every object reference starts as nil, every integer as 0 and every string as empty. The constructor body then runs inside an implicit exception handler. If anything in that body raises, the generated code calls the virtual destructor: BeforeDestruction runs, then your Destroy, then FreeInstance releases the memory, and finally the exception is re-raised. AfterConstruction never runs, and the variable on the left of X := TFoo.Create is never assigned
So the destructor sees an object that stopped at an arbitrary line of its constructor. Fields assigned before the failure hold real objects. Fields after it are still nil. A destructor written on the assumption that construction finished will dereference one of those nil fields and raise an access violation of its own
That second exception is the real damage. Delphi does not chain exceptions raised during this clean-up: the access violation replaces the EOutOfMemory, the original exception is discarded, and the caller only ever sees the AV. The useful diagnosis, "an allocation failed, try again with more memory", is gone, and in its place is something that looks like memory corruption
Where does the secondary access violation come from?
It comes from a destructor that assumes its constructor completed and touches a list field that is nil when the very first internal allocation fails. Two HotXLS classes show the shape clearly. Both the standalone TXLSXRichText.Create and the comment-owned overload TXLSXRichText.Create(AOwner: TXLSXComment) call inherited Create and then allocate FRuns := TList.Create. TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) does the same with FList := TList.Create before it builds its packed-string tables and packed cell storage. In Lib/lxHandleX.pas the two destructors guard those fields:
procedure TXLSXRichText.ClearCore;
var
i: Integer;
begin
if FRuns=nil then Exit; // the constructor may have stopped before FRuns
for i:= 0 to FRuns.Count- 1 do
TXLSXRichTextRun(FRuns[i]).Free;
FRuns.Clear;
end;
destructor TXLSXCells.Destroy;
begin
FPackedStorageEnabled:= False;
if FList<>nil then Clear; // FList is nil if TList.Create failed
// ... packed storage, string tables and FList are released with Free
inherited Destroy;
end;
The failure point is precise. Counting heap allocations during construction, the first one is the instance itself; if NewInstance fails, no constructor body runs and no destructor is called, so that case is always clean. The second allocation is the TList. When it fails, FRuns or FList stays nil, the RTL calls Destroy, and an unguarded Destroy reads Count through a nil reference, which is exactly the secondary access violation. Later failures inside TXLSXCells.Create are harmless, because once FList exists, Clear checks FPackedStorage <> nil before using the packed storage
These classes matter because they are created in bulk. Every loaded worksheet builds a TXLSXCells collection, and every cell or comment with formatted runs builds a TXLSXRichText. A large workbook with heavy rich text creates thousands of them, so under memory pressure the allocation that finally fails has a real chance of landing on one of these lines
Free is nil-safe, field access is not
TObject.Free checks Self <> nil before calling Destroy, so FRuns.Free on a nil field is a no-op. Reading FRuns.Count is not: Count reads a field at an offset from the instance pointer, and a virtual call such as FRuns.Clear first reads the VMT pointer from the instance. Both fault on nil. That asymmetry is the entire trap. The release calls in a typical destructor are already safe; the loops and clean-up calls placed before them are where the trouble lives. Here is the pattern in a small class of your own:
type
TStyledText = class
private
FRuns: TList;
FIndex: TStringList;
public
constructor Create;
destructor Destroy; override;
end;
constructor TStyledText.Create;
begin
inherited Create;
FRuns := TList.Create; // EOutOfMemory here leaves FRuns = nil
FIndex := TStringList.Create; // ... and here leaves FIndex = nil
end;
destructor TStyledText.Destroy; // BAD: assumes the constructor finished
var
I: Integer;
begin
for I := 0 to FRuns.Count - 1 do // access violation when FRuns = nil
TObject(FRuns[I]).Free;
FIndex.Free;
FRuns.Free;
inherited Destroy;
end;
The corrected destructor guards every dereference and releases fields only through nil-safe calls:
destructor TStyledText.Destroy;
var
I: Integer;
begin
if FRuns <> nil then
for I := 0 to FRuns.Count - 1 do
TObject(FRuns[I]).Free;
FreeAndNil(FIndex);
FreeAndNil(FRuns);
inherited Destroy;
end;
The rules that fall out of this are short enough to apply in code review:
- Every use of an owned field in a destructor, other than
FreeorFreeAndNil, needs anilorAssignedcheck, including calls into helpers such asClearorClearCorethat iterate the field - Release owned objects with
FreeorFreeAndNil, never by callingDestroydirectly, which skips theniltest - Keep
inherited Destroyas the last statement, so the ancestor tears down its own fields after yours are gone - Do not reason from field order in the constructor; the next maintainer will insert an allocation above yours and quietly move the failure point
Where inherited Create belongs
Call inherited Create first, as both HotXLS constructors do. The ancestor's fields then exist before your code runs, and the destructor's mirror image, inherited Destroy last, unwinds in the reverse order. If the ancestor constructor itself raises, your destructor still runs with every one of your fields nil, which a nil-safe destructor handles without special cases. Explicit assignments such as FOwner := nil in a constructor are redundant after InitInstance, but harmless and sometimes worth keeping as documentation
Do not catch and free inside the constructor
Two well-meaning patterns make this worse. The first is wrapping the constructor body in try ... except Free; raise; end: the RTL is already going to call the destructor, so the object is destroyed twice. The second is swallowing the exception to "keep going": the caller then receives a valid-looking reference to an object with nil fields, and the failure moves to some unrelated method call much later. If a constructor creates a temporary it does not keep, free that temporary in a local try ... finally and let the exception continue upwards untouched
How do you test constructor failure paths?
Fail each allocation point in a constructor on purpose, one at a time, and require EOutOfMemory every time, followed by a successful normal construction. The tool for that is a replacement memory manager whose GetMem, AllocMem and ReallocMem count positive-size requests and return nil at a chosen ordinal. Count the allocations a constructor makes on your target build first, then run one injected failure per ordinal
After every injected failure, construct the same class again with the allocator restored and exercise it: for a rich text class, add a run, set its text, and confirm that the plain text comes back as expected. Normal lifecycle checks belong alongside, such as saving a workbook with formatted runs and comments to a file and to a stream, freeing the source workbook, and reopening and comparing both copies
Allocation counts belong to one compiler and platform; another compiler or RTL can allocate differently, so measure on the build you ship. HotXLS applies the same nil-safe rule across the library, from style, chart and pivot collections to the hash tables, zip writer and PDF exporter. Hash tables deserve a specific note: a table that changes its mask before a reallocation that then fails leaves its destructor walking memory past the old table, so the mask must change only after the new storage exists. One case sits in the RTL itself: TInterfaceList faults in its destructor when its internal allocation fails. None of this amounts to rollback of an arbitrary operation that fails halfway, and it is not a general out-of-memory recovery guarantee
What does this change for code that opens large workbooks?
In practice, EOutOfMemory from these construction paths is a signal you can catch and act on, instead of an access violation you have to treat as a crash. TXLSXWorkbook.Create takes no parameters, and TXLSXWorkbook.Open(const FileName: WideString): Integer returns 1 on success and -1 on failure. Exceptions raised while loading are recorded in the workbook's Diagnostics collection as xlsDiagnosticUnhandledException and then re-raised, which is the behaviour described in the HotXLS structured diagnostics article. A retry wrapper looks like this:
type
EWorkbookOpenFailed = class(Exception);
function OpenWorkbookWithRetry(const FileName: WideString;
MaxAttempts: Integer): TXLSXWorkbook;
var
Attempt: Integer;
Book: TXLSXWorkbook;
begin
Result := nil;
for Attempt := 1 to MaxAttempts do
begin
Book := nil;
try
try
Book := TXLSXWorkbook.Create; // no constructor arguments
if Book.Open(FileName) <> 1 then // 1 = success, -1 = failure
raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
[string(FileName)]);
Result := Book;
Book := nil; // ownership moves to the caller
Exit;
finally
Book.Free; // nil-safe after a hand-off
end;
except
on E: EOutOfMemory do
begin
if Attempt = MaxAttempts then
raise;
ReleaseApplicationCaches; // your code: drop caches, other books
end;
end;
end;
end;
A few details in that wrapper are deliberate. Each attempt builds a fresh TXLSXWorkbook, because nil-safe destructors make construction failures clean but do not make a half-finished Open resumable, so the failed instance is simply freed. Only EOutOfMemory is retried; EWorkbookOpenFailed, password errors and anything else propagate on the first attempt. An EAccessViolation should never be retried at all, since it signals a defect rather than a resource shortage. And the retry only makes sense if something changes between attempts, which is why the handler frees memory first. If you need the diagnostic trail of the failed attempt, read Book.Diagnostics before the finally block frees it
Retrying is a mitigation, not a capacity plan. A 32-bit process that keeps hitting EOutOfMemory on large files is usually short of contiguous address space rather than physical memory, and the durable answer is a Win64 build. The same thinking applies on the write side, where HotXLS crash-safe saves with staged temp files keep a failed save from damaging the existing file. If several threads share one workbook, HotXLS read leases and write guards describe how access to that instance is coordinated
A checklist for your own Delphi classes
The guards themselves are one line each, and the same audit takes minutes on your own code. Look for destructors that call a Clear, Reset or ClearCore method before releasing fields, then check whether that method touches a field the constructor might not have reached. Search for loops over owned lists that are not preceded by a nil check. Look for constructors that catch exceptions, and remove both the swallowing and the manual Free. If you want proof rather than inspection, the counting memory manager described above fits in a page of code and turns every allocation in a constructor into a test case
For the full feature set, supported Delphi and C++Builder versions and licensing, see the HotXLS Delphi Excel component product page