Skip to content

Fix borrowed module dictionary reference and PyState exit-time use-after-free in the export wrapper - #259

Open
astherath wants to merge 2 commits into
NTNU-IHB:masterfrom
astherath:fix/pyslaveinstance-refcount-and-exit-race
Open

Fix borrowed module dictionary reference and PyState exit-time use-after-free in the export wrapper#259
astherath wants to merge 2 commits into
NTNU-IHB:masterfrom
astherath:fix/pyslaveinstance-refcount-and-exit-race

Conversation

@astherath

Copy link
Copy Markdown

Summary

  • findClass() released a borrowed module-dictionary reference and mishandled compile errors.
  • The library unload hook could release PyState after its static destructor had already freed the shared-pointer control block.

Bug 1: borrowed module dictionary

On master, findClass() stores PyModule_GetDict(pyModule) at
src/pythonfmu/PySlaveInstance.cpp:57. The result is borrowed, but master
calls Py_DECREF(pGlobals) on the normal path at line 116 and again in the
compile-error path at lines 64–72. Each instantiation therefore produces a
one-reference refcount drift: sys.getrefcount(module.__dict__) falls below
len(gc.get_referrers(module.__dict__)), while the module and its functions
still refer to that dictionary.

The same compile-error path calls Py_Finalize() from a shared library that
does not own the interpreter. A Python host aborts with _Py_GetConfig and a
NULL thread state instead of reporting the failed instantiation.

The fix removes both borrowed-reference decrements and keeps the existing
Py_Finalize() removal. It also removes PyErr_Print() and the NULL
Py_DECREF(pCode), leaving the exception set so the caller reports
fmi2Fatal and FMPy raises Failed to instantiate model.

Bug 2: PyState exit lifetime

Master has a namespace-scope std::shared_ptr<PyState> at line 667. Its
__cxa_atexit static destructor runs before _dl_fini invokes the platform
unload hook, which calls finalizePythonInterpreter() at line 695.
libstdc++ leaves the shared-pointer control-block pointer (_M_pi) dangling
after the first release, so the second release is a heap-use-after-free.

On glibc, the DSO is NODELETE because it exports STB_GNU_UNIQUE
symbols, so FMPy's freeInstance()/dlclose() leaves it mapped and the hook
runs at process exit. This affects every Linux FMPy run, including one plain
fmpy.simulate_fmu() and upstream's own integration tests under ASan; it does
not require multiple FMUs.

The fix heap-allocates the shared-pointer holder for process lifetime and
resets its contained pointer under pyStateMutex from the unload hook. The
reset is idempotent. createInstance() now returns the shared-pointer copy
taken under the mutex and assigns it to data.pyState before constructing the
slave. This restores the intended co-ownership: a live slave keeps PyState
alive, so a non-Python host cannot finalize it underneath that slave.

On Windows, please confirm that the anonymous-namespace DllMain is invoked;
dumpbin /symbols PySlaveInstance.obj | findstr DllMain will show a mangled
?DllMain@?A0x... if it is not the global entry point. I can move DllMain to
global extern "C" scope in a follow-up if needed.

How to reproduce

Build the wrapper and a test FMU with ordinary tools (the installed pythonfmu
binary is used by default; --wrapper injects a separately built library):

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DPython3_ROOT_DIR=/path/to/python \
-DPython3_EXECUTABLE=/path/to/python/bin/python \
-DCMAKE_CXX_FLAGS='-Wall -Wextra'
cmake --build build
python -m pythonfmu build -f model.py -d output --no-external-tool
Bug 1: dict_referrers.py
importargparse, gc, subprocess, sys, tempfile, zipfilefrompathlibimportPathimportfmpyS='''from pythonfmu.fmi2slave import Fmi2Slave,Fmi2Causality,Realclass ProbeSlave(Fmi2Slave): def __init__(self,**kw): super().__init__(**kw); self.x=0.; self.register_variable(Real("x",causality=Fmi2Causality.output)) def do_step(self,t,dt): self.x=t+dt; return True'''defpatch(f,w):
t=f.with_suffix('.tmp')
withzipfile.ZipFile(f) ass,zipfile.ZipFile(t,'w') asz:
foreins.infolist(): z.writestr(e,w.read_bytes() ifPath(e.filename).suffixin {'.so','.dll','.dylib'} elses.read(e))
t.replace(f)
p=argparse.ArgumentParser(); p.add_argument('--wrapper',type=Path); p.add_argument('--count',type=int,default=3); a=p.parse_args()
w=Path(tempfile.mkdtemp(prefix='pythonfmu-refcount-')); src=w/'probe_slave.py'; src.write_text(S)
subprocess.run([sys.executable,'-m','pythonfmu','build','-f',str(src),'-d',str(w),'--no-external-tool'],check=True,stdout=subprocess.DEVNULL)
f=next(w.glob('*.fmu'))
ifa.wrapper: patch(f,a.wrapper)
md=fmpy.read_model_description(f,validate=False); d=fmpy.extract(f,unzipdir=str(w/'x')); models=[]; bad=Falseforiinrange(a.count):
m=fmpy.fmi2.FMU2Slave(guid=md.guid,unzipDirectory=d,modelIdentifier=md.coSimulation.modelIdentifier,instanceName=f'i{i}'); m.instantiate(); models.append(m); gc.collect(); gc.collect(); q=sys.modules['probe_slave'].__dict__; r=gc.get_referrers(q); n=sys.getrefcount(q)-1; print(f'after instantiate {i+1}: getrefcount={n} gc_referrers={len(r)}',flush=True); bad|=n<len(r)
forminmodels: m.terminate(); m.fmi2FreeInstance(m.component); m.component=NoneraiseSystemExit(int(bad))

The master build exits non-zero (the mismatch appears by the second
instantiation); the fixed build exits zero.

Bug 2: exit_simulate.py
importargparse,subprocess,sys,tempfile,zipfilefrompathlibimportPathimportfmpyS='''from pythonfmu.fmi2slave import Fmi2Slave,Fmi2Causality,Realclass SimSlave(Fmi2Slave): def __init__(self,**kw): super().__init__(**kw); self.x=0.; self.register_variable(Real("x",causality=Fmi2Causality.output)) def do_step(self,t,dt): self.x=t+dt; return True'''defpatch(f,w):
t=f.with_suffix('.tmp')
withzipfile.ZipFile(f) ass,zipfile.ZipFile(t,'w') asz:
foreins.infolist(): z.writestr(e,w.read_bytes() ifPath(e.filename).suffixin {'.so','.dll','.dylib'} elses.read(e))
t.replace(f)
p=argparse.ArgumentParser(); p.add_argument('--wrapper',type=Path); a=p.parse_args(); w=Path(tempfile.mkdtemp(prefix='pythonfmu-exit-')); src=w/'sim_slave.py'; src.write_text(S)
subprocess.run([sys.executable,'-m','pythonfmu','build','-f',str(src),'-d',str(w),'--no-external-tool'],check=True,stdout=subprocess.DEVNULL)
f=next(w.glob('*.fmu'))
ifa.wrapper: patch(f,a.wrapper)
r=fmpy.simulate_fmu(str(f),stop_time=.5,validate=False); print(f'SIMULATED rows={len(r)} last_x={r["x"][-1]:.3f}',flush=True)

The deterministic command is:

cmake -S . -B build-asan -DCMAKE_CXX_FLAGS='-fsanitize=address -fno-omit-frame-pointer' -DCMAKE_SHARED_LINKER_FLAGS=-fsanitize=address
cmake --build build-asan
ASAN_OPTIONS=detect_leaks=0 LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
python exit_simulate.py --wrapper /path/to/libpythonfmu-export.so

The master wrapper reports heap-use-after-free in
finalizePythonInterpreter; the fixed wrapper exits cleanly. The plain
allocator abort is heap-layout dependent, so ASan is the required detector.

Bug 1 compile-error path: compile_error_path.py
importargparse,faulthandler,subprocess,sys,tempfile,zipfilefrompathlibimportPathimportfmpyfaulthandler.enable(); S='''from pythonfmu.fmi2slave import Fmi2Slave,Fmi2Causality,Realclass CeSlave(Fmi2Slave): def __init__(self,**kw): super().__init__(**kw); self.x=0.; self.register_variable(Real("x",causality=Fmi2Causality.output)) def do_step(self,t,dt): self.x=t+dt; return True'''defpatch(f,w):
t=f.with_suffix('.tmp')
withzipfile.ZipFile(f) ass,zipfile.ZipFile(t,'w') asz:
foreins.infolist(): z.writestr(e,w.read_bytes() ifPath(e.filename).suffixin {'.so','.dll','.dylib'} elses.read(e))
t.replace(f)
p=argparse.ArgumentParser(); p.add_argument('--wrapper',type=Path); a=p.parse_args(); w=Path(tempfile.mkdtemp(prefix='pythonfmu-compile-error-')); src=w/'ce_slave.py'; src.write_text(S)
subprocess.run([sys.executable,'-m','pythonfmu','build','-f',str(src),'-d',str(w),'--no-external-tool'],check=True,stdout=subprocess.DEVNULL)
f=next(w.glob('*.fmu'))
ifa.wrapper: patch(f,a.wrapper)
md=fmpy.read_model_description(f,validate=False); d=fmpy.extract(f,unzipdir=str(w/'x')); mk=lambdan:fmpy.fmi2.FMU2Slave(guid=md.guid,unzipDirectory=d,modelIdentifier=md.coSimulation.modelIdentifier,instanceName=n)
a=mk('first'); a.instantiate(); print('FIRST_OK',flush=True); Path(d,'resources','ce_slave.py').write_text('this is not python (\n'); b=mk('second')
try: b.instantiate(); print('SECOND_INSTANTIATE_RETURNED',flush=True)
exceptExceptionase: print('SECOND_INSTANTIATE_RAISED:',type(e).__name__,e,flush=True)
print('HOST_STILL_ALIVE',flush=True); a.terminate(); a.fmi2FreeInstance(a.component); a.component=None

Master aborts with status 134. With the borrowed-reference fix, FMPy raises
Failed to instantiate model, prints HOST_STILL_ALIVE, and exits 0.

Verification

  • Release -Wall -Wextra: master and branch each emit 16 pre-existing warnings; normalized warning sets are identical (delta 0).
  • Full branch suite: 1270 passed, 1 failed; the sole failure is the pre-existing test_default_experiment FMPy 0.3.31 string-versus-number assertion. The new regression test passes.
  • The same regression test against a master build fails (1 failed, 10 deselected); against this branch it passes (1 passed, 10 deselected).
  • Branch ASan integration run (-k 'not throw_py_error'): 10 passed, 1 deselected, with no sanitizer report.
  • ASan exit_simulate.py: master reports the finalize-time heap-use-after-free; branch prints SIMULATED rows=1001 last_x=0.500 and exits 0.

Known remaining issues not addressed here

The following pre-existing issues are follow-up work: leaked MRO/reflection temporaries and duplicate sys.path entries; leaked logger and FMU-state temporaries; missing NULL checks; double cleanup in cleanPyObject() and failure cleanup in initialize(); an uninitialized pClass_; and signed/unsigned value-reference warnings. These are intentionally outside this minimal fix.

These issues were found while hosting many FMUs in one process with FMPy.

PyModule_GetDict() returns a borrowed reference, but findClass()
Py_DECREF'd it once per instantiation (and once more on the compile-
error path). Each fmi2Instantiate of the same FMU therefore removed a
reference the wrapper never owned; the module dictionary is eventually
released while the module and its functions still point at it.
Observable from the host: sys.getrefcount(module.__dict__) falls below
len(gc.get_referrers(module.__dict__)) by one per instantiation.
The compile-error path also called Py_Finalize() inside a shared library
that does not own the interpreter (a Python host aborts with
"_Py_GetConfig: ... thread state is NULL"), printed and cleared the
exception so the caller could not report it, and Py_DECREF'd a NULL
pCode.
Stop decrementing the borrowed dictionary, leave interpreter
finalization to the host, and leave the compile error set so that
fmi2Instantiate fails with fmi2Fatal instead of crashing the process.
Adds test_integration_reinstantiate_same_fmu.
The namespace-scope std::shared_ptr<PyState> was released twice at
process exit: by its __cxa_atexit-registered destructor and again by
finalizePythonInterpreter() from the library unload hook, which
_dl_fini runs afterwards. libstdc++ does not null the control-block
pointer on destruction, so the second release touched freed memory.
Hosts saw an intermittent "corrupted double-linked list" abort on exit;
AddressSanitizer reports the heap-use-after-free on every exit, including
a plain fmpy.simulate_fmu() run.
Keep the holder heap-allocated for the life of the process (no static
destructor) and reset it under pyStateMutex from the unload hook, which
stays idempotent.
Also hand the shared PyState to the slave *before* constructing it: the
assignment after std::make_unique was a dead store since NTNU-IHB#237 (data is
passed by value), so slaves never actually co-owned PyState. They now do,
as NTNU-IHB#211/NTNU-IHB#213 intended, so the interpreter cannot be finalized under a
live slave.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@astherath