Skip to content

lazy: refuse an instance whose data section failed to index, and three uninitialised reads around it - #484

Merged
starseeker merged 4 commits into
stepcode:developfrom
attixray:sc-fix-lazy-null-and-uninitialised-reads
Sep 10, 2026
Merged

starseeker merged 4 commits into
stepcode:developfrom
attixray:sc-fix-lazy-null-and-uninitialised-reads

Conversation

@attixray

Copy link
Copy Markdown

A lazyDataSectionReader indexes its instances from its own constructor, through addLazyInstance(), under the section id it will be given — and lazyFileReader::initP21() registers it only afterwards, and only if the scan succeeded. A section that fails part way therefore leaves every instance it had already indexed naming a section that does not exist.

lazyInstMgr::typeFromFile() indexes _dataSections with that id unconditionally, and loadInstance() does the same behind an assert() that Release compiles out. On an empty vector that is an access violation. Thirteen lines reproduce it — a DATA section holding one instance followed by a line that is not one — and that file is added here as src/cllazyfile/test/lazy_broken_section.stp and checked by the existing lazy_index test, which segfaults inside typeFromFile() without the fix.

sourceRecord() already makes exactly this test. The other two entry points now make it too and answer 0 with a diagnostic, as they already do for an instance found in no section. The instances stay in the index — totalInstanceCount() still counts them and getInstances() still lists them; what changes is that asking for one of them fails the way every other unloadable instance fails.

Three smaller things in the same code, each its own commit:

  • loadInstance() cached a null as a loaded instance. isNilSTEPentity( 0 ) returns false, so if( !isNilSTEPentity( inst ) ) is true for a null, and several arms of the switch above it can leave one. Nothing dereferences it — lazyRefs::init() rejects two null arguments — but _loadedInstanceCount, _materializations, _cacheHighWater and the resident source-byte total all advance for an instance that never materialized, and it never settles: releaseBatch() skips the entry with if( !inst ) continue; before the decrement and the erase, so the phantom cannot be evicted. This one is also what keeps the bounds fix above from simply moving the crash to process exit.
  • namedLazyInstance::name is the one member nextInstance() does not initialise, and its assignment is behind if( _file.good() ). Nothing observes the uninitialised value today — the closing invalid-instance branch is reached on the same condition and assigns 0 — but that is a control-flow argument spanning forty lines rather than an invariant the code states.
  • the same branch deletes i.refs without nulling it, two lines above a delete i.componentTypes; i.componentTypes = 0; that does both.

Only the section-bounds fix is a crash; the three above are stated as hardening in their commit messages, not as live bugs.

The commits are ordered so that the null guard comes first: the new break in loadInstance() leaves inst null, so without it the bounds test moves the failure from the call to process exit rather than removing it.

Verified both ways on the same build tree (Release, shared libs, no schemas), rebuilding clean between runs: ctest -R lazy_index segfaults on unmodified develop with the new fixture and assertions, and passes with the four commits. The new case gives its lazyInstMgr the test's empty registry, so it does not trip loadInstance()'s assert( _mainRegistry ) in a Debug build.

Attila Prokai added 4 commits August 21, 2026 19:50
isNilSTEPentity( 0 ) returns false, so `if( !isNilSTEPentity( inst ) )` in
loadInstance() is TRUE for a null pointer, and every branch of the switch above
it can leave one:

  * case 0 and default leave `inst` at the value returned by
    _instancesLoaded.find(), which is 0 on a cache miss;
  * case 1 sets `inst = 0` explicitly on the cancellation path added for batch
    loading.

A null therefore reaches the success arm and is inserted into _instancesLoaded,
while _loadedInstanceCount, _materializations, _cacheHighWater and the resident
source-byte accounting are all advanced for an instance that was never
materialized. The lazyRefs construction that follows is harmless - its init()
rejects two null arguments - so this is accounting corruption rather than a
crash.

It does not settle either. releaseBatch() skips the entry with `if( !inst )
continue;` BEFORE the `--_loadedInstanceCount` and the erase from
_batchOwnedInstances, so the phantom can never be evicted: the counter stays
high for the life of the manager and _batchOwnedInstances grows across batches.
cacheStatistics() reports the drift.

Guard the pointer as well as the nil sentinel. A null then takes the existing
else arm, which already emits LAZY_DIAGNOSTIC_ERROR "SDAI instance
materialization failed" - the right diagnostic for what happened.
nextInstance() explicitly initialises every member of the local
namedLazyInstance except `name`, and the assignment it does get is conditional:

    if( _file.good() ) i.name = getDelimitedKeyword( ";( /\\" );

Nothing is wrong today. When that guard is false the closing "invalid instance"
branch is reached on the same condition and assigns `i.name = 0` before the
struct is returned, so the uninitialised value is never observed - but that is a
control-flow argument spanning forty lines, not an invariant the code states.

It also stops holding the moment getDelimitedKeyword() can fail softly. It
cannot upstream, because it abort()s on a keyword with no delimiter after it; a
caller that would rather report bad input than terminate the host process has to
make it return 0, and then `i.name` is read while uninitialised.

One line, and the struct is uniformly initialised where it is declared.
nextInstance()'s "invalid instance, so clear everything" branch deletes
i.refs but leaves the pointer, while componentTypes two lines below is
deleted and nulled, and i.name is assigned 0 right after. The struct is
returned by value with the stale pointer still in it.

Nothing frees it twice today: the same branch zeroes loc.begin, and both
callers - the data section constructor's scan loop and indexScope() -
stop on that before looking at anything else. So this is the same kind of
uniformity fix as the two commits before it rather than a live bug, and
it costs one line to stop being a question.
A data section indexes its instances from its own constructor, through
addLazyInstance(), under the section id it WILL be given - and
lazyFileReader::initP21() registers the section only afterwards, and only
if its scan succeeded. A section that fails part way therefore leaves
every instance it had already indexed naming a section that does not
exist.

typeFromFile() then indexes _dataSections with it unconditionally, and
loadInstance() does the same behind an assert() that Release compiles
out. On an empty vector that is an access violation. sourceRecord()
already makes exactly this test; the other two now make it too and answer
0 with a diagnostic, as they already do for an instance found in no
section.

The instances stay in the index - totalInstanceCount() still counts them
and getInstances() still lists them. What changes is that asking for one
of them fails the way every other unloadable instance fails.

Reachable from a thirteen-line file, added as test/lazy_broken_section.stp
and checked by the lazy_index test: a DATA section holding one instance
and then a line that is not one. Without the two changes below it, that
test dies with SIGSEGV inside typeFromFile().

Note that the new break in loadInstance() leaves `inst` null, and
isNilSTEPentity( 0 ) is false, so the null would be cached as a loaded
instance were it not for the guard added in "lazy: do not cache a null
instance as a loaded one" earlier in this branch. With the bounds test
alone the process survives the two calls and then dies on the way out.
@starseeker
starseeker merged commit 9d47461 into stepcode:develop Sep 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants