emscripten icon indicating copy to clipboard operation
emscripten copied to clipboard

Embind regression: Leaked C++ object when calling from C++ to JS

Open stbergmann opened this issue 1 year ago • 1 comments

I originally mentioned this in a comment at https://github.com/emscripten-core/emscripten/pull/21022#issuecomment-2329399203 "embind: Don't automatically destroy arguments passed into val calls. (#21022)", but it likely got missed there:

Since https://github.com/emscripten-core/emscripten/commit/6e5469075b597701d52659150dfa7859e905121f "embind: Don't automatically destroy arguments passed into val calls. (#21022)" was merged into 3.1.52, the below test program behaves differently than before, and I wonder whether that's intentional. The test setup is

$ cat test.cc
#include <iostream>
#include <memory>

#include "emscripten.h"
#include "emscripten/bind.h"

class Smart {
public:
  Smart(): id_(counter++) { std::cout << "create " << id_ << "\n"; }
  Smart(Smart const & other): id_(counter++) {
    std::cout << "copy " << other.id() << " to " << id_ << "\n";
  }
  ~Smart() { std::cout << "destroy " << id_ << "\n"; }
  int id() const { return id_; }
private:
  void operator =(Smart) = delete;
  static unsigned counter;
  int const id_;
};

unsigned Smart::counter = 0;

std::shared_ptr<Smart> getSmart() {
  return std::make_shared<Smart>();
}

struct Interface {
  virtual void memFun(Smart const &) const {};
};

struct Wrapper: emscripten::wrapper<Interface> {
public:
  EMSCRIPTEN_WRAPPER(Wrapper);
  void memFun(Smart const & smart) const override {
      call<void>("memFun", smart);
  }
};

void useInterface(Interface const & ifc) {
  std::shared_ptr<Smart> s(getSmart());
  ifc.memFun(*s);
}

EM_JS(void, jsRun, (), {
  const Impl = Module.Interface.extend('Interface', {
    memFun(smart) { console.log('use ' + smart.id()); }
  });
  const impl = new Impl();
  const s = Module.getSmart();
  impl.memFun(s);
  impl.memFun(s);
  s.delete();
  Module.useInterface(impl);
});

EMSCRIPTEN_BINDINGS(test) {
  emscripten::class_<Smart>("Smart")
    .smart_ptr<std::shared_ptr<Smart>>("SmartPtr")
    .function("id", &Smart::id);
  emscripten::class_<Interface>("Interface")
    .function("memFun", &Interface::memFun)
    .allow_subclass<Wrapper>("Wrapper");
  emscripten::function("getSmart", &getSmart);
  emscripten::function("useInterface", &useInterface);
}

int main() {
  jsRun();
}

and

$ em++ test.cc -lembind -o test.html && emrun test.html

Until 3.1.51 (also in 3.1.47 and 3.1.48, i.e., irrespective of https://github.com/emscripten-core/emscripten/commit/12777ca1fde1d0b0de4c459019f0d5776fec18cd "[emval] Reuse method calling optimisation in regular calls (#20383)" that was merged into 3.1.48 and motivated this change), the browser console logs were

create 0
use 0
use 0
destroy 0
create 1
copy 1 to 2
use 2
destroy 2
destroy 1

That is., the instance of Smart used in the call to JS ifc.memFun(*s); from C++ useInterface is properly destroyed.

Since 3.1.52, the browser console logs instead are

create 0
use 0
use 0
destroy 0
create 1
copy 1 to 2
use 2
destroy 1

That is, the instance of Smart used in the call to JS ifc.memFun(*s); from C++ useInterface is leaked now. But even if the new behavior is intentional: Where should that instance be destroyed? I think this commit assumes that the JS implementation

    memFun(smart) { console.log('use ' + smart.id()); }

should smart.delete(); it now. But then, that would apparently break the sequence of direct JS to JS calls

  impl.memFun(s);
  impl.memFun(s);

from within jsRun.

stbergmann avatar Sep 16 '24 12:09 stbergmann

Thanks for the nice reproducer. I don't think we considered this specific case when making the changes above (nor do we have a test case that really covers it). The old behavior in this case is much nicer, but I'd still like operator() and call() to be consistent in how they handle argument lifetimes. A few things we could potentially do:

  • Add an option to call to auto delete all arguments.
  • Add an option per argument on how it should be handled.
  • Auto delete objects that were copy constructed when going from C++->JS. I think this would probably have issues like the previous automatic behavior though.

brendandahl avatar Sep 18 '24 21:09 brendandahl

Any news on this one? I am experiencing the same... The project I work in has been using 3.1.48 for more than a year now and I wanted to update emscripten to the current latest. I didn't get far.

After this release, I start seeing allocations errors because apparently there are plenty of memory leaks now 😅 .

Thanks!

Edit: The only way I managed to get the expected behaviour in the test setup that @stbergmann shared was to provide another version of the memFun that explicitly called smart.delete(); at the end. And calling this one from within void useInterface(Interface const & ifc).

Did you find any better workardound @stbergmann ?

Update: At the end, I patched my code to explicitly call .delete() now when required. It was not too hard at the end.

ricardperez avatar Dec 13 '24 22:12 ricardperez