Since cad1f04 ("Fix a potential race condition in FastMM_WalkBlocks: The header for a small block cannot be assumed to be valid if it was recently split off from a sequential feed span") FastMM_ScanDebugBlocksForCorruption silently ignores small debug blocks whose header or footer checksum is wrong - which is exactly the state it exists to find. Medium and large blocks are unaffected, and so is the use-after-free (fill pattern) check for small blocks.
Why
In the small block branch of FastMM_WalkBlocks, the decision whether the block may be reported as a debug block ends with the two checksum comparisons:
LPDebugHeader := PFastMM_DebugBlockHeader(LBlockInfo.BlockAddress);
LMayCheckForDebugInfo := BlockHasDebugInfo(LPDebugHeader)
and (GetSpanForSmallBlock(LPDebugHeader) = LPMediumBlock)
and (LBlockInfo.UsableSize > CDebugBlockHeaderSize)
and (LBlockInfo.UsableSize >= CDebugBlockHeaderSize + LPDebugHeader.UserSize
+ CalculateDebugBlockFooterSize(LPDebugHeader.StackTraceEntryCount))
and (LPDebugHeader.CalculateHeaderCheckSum = LPDebugHeader.HeaderCheckSum)
and (LPDebugHeader.CalculateFooterCheckSum = LPDebugHeader.DebugFooterPtr^);
A block whose checksums do not match is therefore handed to the callback with DebugInformation = nil, and FastMM_ScanDebugBlocksForCorruption_CallBack returns at its first line. So the one condition the scan is looking for is the one condition that hides the block from it. (The same applies to FastMM_DebugMode_ScanForCorruptionBeforeEveryOperation.)
The corruption is still caught when the block is eventually freed, via CheckDebugBlockHeaderAndFooterCheckSumsValid - what is lost is the early detection the scan provides, and the detection of blocks that are never freed.
Repro
program ScanBlindSpot;
{$APPTYPE CONSOLE}
uses
FastMM5, SysUtils;
function ScanDetects: Boolean;
begin
try
FastMM_ScanDebugBlocksForCorruption(1000);
Result := False;
except
Result := True;
end;
end;
procedure Test(const AWhat: string; ASize: Integer);
var
LP: Pointer;
LHeader: PFastMM_DebugBlockHeader;
LOriginal: Cardinal;
LDetected: Boolean;
begin
GetMem(LP, ASize);
LHeader := PFastMM_DebugBlockHeader(PByte(LP) - SizeOf(TFastMM_DebugBlockHeader));
LOriginal := LHeader.HeaderCheckSum;
LHeader.HeaderCheckSum := LOriginal xor $DEADBEEF; //simulate a corruption
LDetected := ScanDetects;
LHeader.HeaderCheckSum := LOriginal; //repair before freeing
FreeMem(LP);
WriteLn(AWhat, ' -> detected: ', LDetected);
end;
begin
FastMM_MessageBoxEvents := [];
FastMM_LogToFileEvents := [];
FastMM_EnterDebugMode;
Test('small (100 bytes)', 100);
Test('medium (50000 bytes)', 50000);
Test('large (300000 bytes)', 300000);
FastMM_ExitDebugMode;
end.
Output on current master (fb46810), Delphi 13.1 Win32:
small (100 bytes) -> detected: FALSE
medium (50000 bytes) -> detected: TRUE
large (300000 bytes) -> detected: TRUE
I ran a slightly larger matrix (header checksum corrupted, buffer overrun into the debug footer, and a write into a freed block) across the three size classes:
| Case |
cad1f04^ |
fb46810 |
| header checksum, small (100 and 2000 bytes) |
detected |
not detected |
| header checksum, medium / large |
detected |
detected |
| overrun into the footer, small |
detected |
not detected |
| overrun into the footer, medium / large |
detected |
detected |
| write after free (fill pattern), all sizes |
detected |
detected |
So all ten cases pass on the commit before cad1f04, and three fail on master. The use-after-free case still works for small blocks because a freed debug block's checksums are recalculated when it is freed, so they are valid and the block stays visible to the scan.
Suggested fix
The first four conditions are what make the block safe to touch: the debug flag, the span identity check, and the two size checks that keep DebugFooterPtr inside the block. It is only the last two - the checksums themselves - that swallow real corruption. Dropping those two lines restores all ten cases:
LMayCheckForDebugInfo := BlockHasDebugInfo(LPDebugHeader)
and (GetSpanForSmallBlock(LPDebugHeader) = LPMediumBlock)
and (LBlockInfo.UsableSize > CDebugBlockHeaderSize)
and (LBlockInfo.UsableSize >= CDebugBlockHeaderSize + LPDebugHeader.UserSize
+ CalculateDebugBlockFooterSize(LPDebugHeader.StackTraceEntryCount));
A half-written header that gets through the remaining checks is then handled by the retry loop you added in the scan callback: it re-checks BlockHasDebugInfo and the checksums up to 100 times before reporting anything, so a block that is still being initialised converges instead of being flagged.
To see whether that reintroduces the problem cad1f04 was about, I ran a stress test: 6 threads continuously allocating, filling and freeing small debug blocks (16..2016 bytes, 64 live blocks each, so spans are constantly fed, split off and recycled) while a seventh thread runs FastMM_ScanDebugBlocksForCorruption in a loop. Nothing corrupts anything, so any exception from the scan is a false positive. 25 seconds per variant, Delphi 13.1 Win32:
| Variant |
allocations |
scans |
false positives |
A/Vs |
| cad1f04^ (no guard at all) |
244 M |
72,308 |
0 |
0 |
| fb46810 (current) |
254 M |
56,427 |
0 |
0 |
| fb46810 minus the two checksum conditions |
259 M |
69,791 |
0 |
0 |
I should be explicit about what this does and does not show: my workload does not reproduce the original race (the unguarded build is clean too), so it is evidence that the proposed change does not make things worse here, not evidence that the guard is unnecessary. If you have the repro from the original report, that is the one worth running against the change.
If you would rather keep the conservative behaviour for the other FastMM_WalkBlocks consumers (FastMM_LogStateToFile, the leak report), the alternative is to make the strictness a parameter of the walk so only the corruption scan opts into seeing blocks with mismatched checksums. That is a bigger change and your call - I am happy to send whichever you prefer as a PR, with the test programs above.
Found while merging your recent changes into my fork; the test programs are plain console Delphi and need nothing from the fork.
Since cad1f04 ("Fix a potential race condition in FastMM_WalkBlocks: The header for a small block cannot be assumed to be valid if it was recently split off from a sequential feed span")
FastMM_ScanDebugBlocksForCorruptionsilently ignores small debug blocks whose header or footer checksum is wrong - which is exactly the state it exists to find. Medium and large blocks are unaffected, and so is the use-after-free (fill pattern) check for small blocks.Why
In the small block branch of
FastMM_WalkBlocks, the decision whether the block may be reported as a debug block ends with the two checksum comparisons:A block whose checksums do not match is therefore handed to the callback with
DebugInformation = nil, andFastMM_ScanDebugBlocksForCorruption_CallBackreturns at its first line. So the one condition the scan is looking for is the one condition that hides the block from it. (The same applies toFastMM_DebugMode_ScanForCorruptionBeforeEveryOperation.)The corruption is still caught when the block is eventually freed, via
CheckDebugBlockHeaderAndFooterCheckSumsValid- what is lost is the early detection the scan provides, and the detection of blocks that are never freed.Repro
Output on current master (fb46810), Delphi 13.1 Win32:
I ran a slightly larger matrix (header checksum corrupted, buffer overrun into the debug footer, and a write into a freed block) across the three size classes:
So all ten cases pass on the commit before cad1f04, and three fail on master. The use-after-free case still works for small blocks because a freed debug block's checksums are recalculated when it is freed, so they are valid and the block stays visible to the scan.
Suggested fix
The first four conditions are what make the block safe to touch: the debug flag, the span identity check, and the two size checks that keep
DebugFooterPtrinside the block. It is only the last two - the checksums themselves - that swallow real corruption. Dropping those two lines restores all ten cases:A half-written header that gets through the remaining checks is then handled by the retry loop you added in the scan callback: it re-checks
BlockHasDebugInfoand the checksums up to 100 times before reporting anything, so a block that is still being initialised converges instead of being flagged.To see whether that reintroduces the problem cad1f04 was about, I ran a stress test: 6 threads continuously allocating, filling and freeing small debug blocks (16..2016 bytes, 64 live blocks each, so spans are constantly fed, split off and recycled) while a seventh thread runs
FastMM_ScanDebugBlocksForCorruptionin a loop. Nothing corrupts anything, so any exception from the scan is a false positive. 25 seconds per variant, Delphi 13.1 Win32:I should be explicit about what this does and does not show: my workload does not reproduce the original race (the unguarded build is clean too), so it is evidence that the proposed change does not make things worse here, not evidence that the guard is unnecessary. If you have the repro from the original report, that is the one worth running against the change.
If you would rather keep the conservative behaviour for the other
FastMM_WalkBlocksconsumers (FastMM_LogStateToFile, the leak report), the alternative is to make the strictness a parameter of the walk so only the corruption scan opts into seeing blocks with mismatched checksums. That is a bigger change and your call - I am happy to send whichever you prefer as a PR, with the test programs above.Found while merging your recent changes into my fork; the test programs are plain console Delphi and need nothing from the fork.