Fix: GetMany queries that succeed with a nil result (issue #3070) - #3076
Open
dgarnier wants to merge 5 commits into
Open
Fix: GetMany queries that succeed with a nil result (issue #3070)#3076dgarnier wants to merge 5 commits into
dgarnier wants to merge 5 commits into
Conversation
…3070) A GetMany batch containing a query that evaluates successfully to a nil/missing result crashes the mdsip worker: getManyObj() stores the NULL Data* under the 'value' key, and Dictionary::setItem dereferences it (data->incRefCount()). Add MdsGetManyTest covering: - Dictionary holding a missing (NULL) value: setItem, getItem, len, and replacing missing<->data - the serialize/deserialize round trip of a dictionary with a missing value (the transport format of the GetManyExecute reply) - GetManyExecute() end to end with a query mirroring the field case, DATA(BEGIN_OF(BUILD_RANGE(*, *, 5E-6))), asserting the answer is { 'value': missing } exactly as a plain get() of the same expression would return The test crashes without the corresponding fix to Dictionary::setItem / getItem in include/mdsobjects.h. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a GetMany query evaluates successfully to nil, execute() returns a
NULL Data* (convertFromDsc() maps an empty XD to NULL), and getManyObj()
stores it under the 'value' key of the answer dictionary. That call,
Dictionary::setItem, dereferenced the value unconditionally
(data->incRefCount()), crashing the mdsip worker mid-reply. A plain
get() of the same expression harmlessly returns nil, and queries that
FAIL inside a GetMany are handled (an 'error' entry is returned) - only
the successful-nil case was fatal.
NULL-guard the value refcounting in Dictionary::setItem and getItem.
Everything below already treats a NULL Apd element as missing: the Apd
constructor and propagateDeletion() guard it, convertToApdDsc() emits a
null descriptor pointer for it, MdsSerializeDscOut() writes it as
offset 0, and both python clients deserialize it back as None/missing -
so the answer arrives as { 'value': * }, matching plain get().
Fixes MDSplus#3070
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
deleteData(NULL) dereferenced the pointer at data->refCount. Make it a no-op on NULL, matching the "NULL == missing" convention the rest of the Apd/Dictionary machinery already follows. This closes the remaining crash on the args-less / nil-value GetMany paths (server getManyObj cleanup of an absent "args" key, and client GetMany::get() of a missing value). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix: GetMany queries that succeed with a nil result (issue #3070)
Problem
A
GetManyquery whose expression evaluates to nil (e.g.GETNCI(<node>, "RECORD")of a clock whose record is
* : * : 5E-6— a Range with a missing begin) crashedthe mdsip worker instead of returning
{ 'value': missing }, the way a plainget()of the same expression does.The crash had several layers; the root cause turned out to be broader than the
Dictionaryaccessors that first showed up in a debugger.Root cause
The C++ object layer uses a
NULLData *to mean missing (*) — it is whatexecute()returns for a nil expression, and whatconvertToApdDsc()emits as anull descriptor pointer. The machinery was inconsistent about tolerating that NULL:
Dictionary::getItem/setItemdereferenced the stored value without aNULL check, so storing/reading a missing value segfaulted. (fixed in the header)
deleteData(Data *)dereferenceddata->refCountwith no NULL guard.This is the real culprit and the one the fix below targets.
deleteData(NULL)is reached on ordinary cleanup paths, becauseAutoData<T>— theRAII wrapper used throughout the mdsip object code — calls
deleteData(ptr)unconditionally in its destructor, and it routinely wraps the (nullable) result of
getItem():getManyObj(mdsipobjects.cpp): a query may legitimately omit theargskey — the server explicitly supports that (if (argsData.get() && argsData->len() > 0)).But
AutoData<List> argsData((List *)currArg->getItem(&argsKey))then wraps theNULLthatgetItemreturns for the absent key, and destroying it at the end ofeach loop iteration calls
deleteData(NULL).GetMany::get()(mdsipobjects.cpp): for a query that succeeds with anil result,
AutoData<Data> result(dictionary->getItem(&valueKey))wrapsNULL, sothe same
deleteData(NULL)fires on the way out — i.e. the crash also reproduces ona real client reading back a nil value, not just inside the worker.
Why it never showed up before: the production C++
GetManyclient always attachesan
argskey (an emptyListwhen there are no args), sogetItem(&argsKey)was neverNULLin normal use. The new test exercises the args-less path on purpose.The fix, and why it lives in
deleteDataWe considered fixing only
getManyObj, but that would patch one call site and leavethe same defect live on the client (
GetMany::get()) and at every otherAutoData<X>(getItem(...))in the codebase. The NULL reachingdeleteDatais notspecific to
getManyObj— it is the general contract thatgetItem()/execute()return NULL for "missing" and that
AutoDatais an RAII wrapper you can hand that NULLto.
getManyObjis correct as written; it was simply relying on adeleteDatathatcouldn't hold up its end.
So the fix belongs at the shared choke point.
deleteDatais the right one: it matchesC++'s own
delete nullptrsemantics, matches the already-NULL-tolerant helpers in thesame file (e.g.
getMember), and covers both theAutoDatadestructor and the rawdeleteData(descs[i])calls. (Apd::propagateDeletionalready guards its own elements,which is exactly the convention we are now making universal.)
Tests
mdsobjects/cpp/testing/MdsGetManyTest.cpp(new) covers three levels:Dictionaryaccepts a missing (NULL) value —setItem/getItem/replace round trips.result dictionary.
GetManyExecute()on a batch containing a nil-result query returns{ 'value': missing }(answered, not an error) instead of crashing.macOS test-harness changes (required to even see this failure)
Two changes to the Check-based C test harness were needed so that macOS reports crashes
and timeouts the way Linux does — without them, this bug manifested as a silent hang and
the test appeared to "pass" or stall rather than fail:
testing/check_backend.c— On macOS a hardware fault (null deref, illegalinstruction, …) is delivered to the task-level Mach exception port (the system crash
reporter), which parks the faulting thread instead of terminating the process. The
forked test child then never dies, the parent blocks forever in
waitpid(), and thewhole run stalls. In
__setup_child()we now clear the child's exception ports(
task_set_exception_ports(..., EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION | EXC_MASK_ARITHMETIC, MACH_PORT_NULL, ...)), so a fault falls through to the defaultaction and a crashing test dies promptly and is reported, just like on Linux.
testing/backends/check/lib/macos_timer.h— macOS lacks POSIXtimer_createdelivery, so the harness emulates it with a dispatch timer. The default expiration
handler previously called
signal(SIGALRM, NULL), which only reset the SIGALRMdisposition and never actually raised the signal — so test timeouts silently never
fired on macOS and a hung child stalled the runner indefinitely. It now does
kill(getpid(), SIGALRM)to emulate real POSIX-timer delivery, so the harness'SIGALRM handler runs and kills the timed-out child.
Together these make a segfault/hang in a test surface as a reported failure on macOS
instead of a silent stall — which is how the remaining
deleteDatacrash was foundafter the header guards were added.
Fixes #3070.