Skip to content

fix(🐛): ref the typeface returned by Font.getTypeface() - #4001

Open
giaBaoJS wants to merge 2 commits into
Shopify:mainfrom
giaBaoJS:fix/font-gettypeface-refcount
Open

fix(🐛): ref the typeface returned by Font.getTypeface()#4001
giaBaoJS wants to merge 2 commits into
Shopify:mainfrom
giaBaoJS:fix/font-gettypeface-refcount

Conversation

@giaBaoJS

Copy link
Copy Markdown
Contributor

Fixes #3983

The bug

JsiSkFont::getTypeface() wraps the pointer returned by SkFont::getTypeface() in an owning sk_sp<SkTypeface> without ref'ing it:

std::shared_ptr<JsiSkTypeface> getTypeface() {
  return std::make_shared<JsiSkTypeface>(
      getContext(), sk_sp<SkTypeface>(getObject()->getTypeface()));
}

Two Skia contracts collide there. Both quoted from the Skia this repo currently pins — externals/skia at 2a9b593bab4b2fd019fa494c8d401ff1fab0b883 (chrome/m152):

  • SkFont::getTypeface() lends. include/core/SkFont.h:208-215"Does not alter SkTypeface SkRefCnt", return fTypeface.get();.
  • sk_sp's raw-pointer constructor adopts. include/core/SkRefCnt.h:246-250"Adopt the bare pointer into the newly created sk_sp. No call to ref() or unref() will be made." Its destructor then calls SkSafeUnref (SkRefCnt.h:255-258).

JsiSkTypeface moves that sk_sp into JsiSkWrappingSkPtrNativeObject<JsiSkTypeface, SkTypeface>, which holds it as an owning member and releases it on dispose() or GC. So each font.getTypeface() call from JS ends up costing the typeface one net reference — a reference the wrapper never acquired.

Once that drain crosses the typeface's real refcount, the typeface is destroyed while live SkFonts still point at it, and the next draw or measurement dereferences freed memory. That is the SkTypeface::getBounds() / SkTypeface::textToGlyphs() crash shape reported in #3983.

The fix

sk_ref_sp is Skia's idiom for taking shared ownership of a borrowed pointer — SkRefCnt.h:383-385, sk_sp<T>(SkSafeRef(obj)).

Null behaviour is unchanged: SkSafeRef(nullptr) returns nullptr, so a null typeface still produces a wrapper holding null, exactly as today. (On m152 SkFont::getTypeface() is documented and SkASSERTed non-null anyway.)

This is the only site of its kind

$ grep -rn "sk_sp<[A-Za-z]*>(getObject()->" packages/skia/cpp/
packages/skia/cpp/api/JsiSkFont.h:139:        getContext(), sk_sp<SkTypeface>(getObject()->getTypeface()));

Widening to every explicit sk_sp<T>(...) construction under packages/skia/cpp/ returns three hits; the other two are sk_sp<SkottieAssetProvider>(new SkottieAssetProvider(...)) and sk_sp<SVGAssetProvider>(new SVGAssetProvider(...)), which correctly adopt a freshly allocated object. So this is an isolated slip, not a pattern, and the change stays a one-liner.

Verification

1. Runtime experiment against the real prebuilt Skia. I linked a small harness against the m152 binary this repo consumes (node_modules/react-native-skia-apple-macos/.../libskia.a), reproducing what the JS path does: create a typeface, build an SkFont from it, then create and destroy one wrapper that owns the sk_sp the way JsiSkTypeface does. It reads the refcount with SkRefCntBase::unique() only, so it never touches freed memory and the result is deterministic rather than UB:

[fixed] fresh typeface           unique=1 (want 1)
[fixed] after SkFont(typeface)   unique=0 (want 0)
[fixed] after one getTypeface()  unique=0 (want 0)
[fixed] => OK: SkFont still holds its reference

[buggy] fresh typeface           unique=1 (want 1)
[buggy] after SkFont(typeface)   unique=0 (want 0)
[buggy] after one getTypeface()  unique=1 (want 0)
[buggy] => REFERENCE STOLEN: SkFont's reference is gone

A variant that runs the wrapper create/destroy cycle twice and then keeps using the font aborts (SIGABRT) on the current code and exits cleanly with the fix.

2. Compilation. packages/skia/cpp/api/JsiSkFont.h compiles clean on its own (clang++ -std=c++20 -fsyntax-only) against the m152 headers produced by yarn install-skia, and the iOS example app builds end to end with the change:

cd apps/example/ios && xcodebuild -workspace example.xcworkspace -scheme example \
  -sdk iphonesimulator -configuration Debug \
  -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO
** BUILD SUCCEEDED **

What I did not run: the device E2E suite (E2E=true yarn test -i e2e). I stopped at the app build rather than driving the Tests screen on a simulator, so please treat CI as the authority there. Nothing in the existing suite exercises this path anyway (Font.spec.ts runs against CanvasKit, not the native binding).

On a regression test: I did not add one, because the stolen reference is not observable from JS without provoking the use-after-free itself — any JS-level test for this would have to crash the app (and take the E2E harness down with it) rather than fail. The C++ probe above is the non-UB way to assert it. Happy to add either an E2E test or a native check if you would like one.

SkFont::getTypeface() returns a borrowed pointer ("Does not alter
SkTypeface SkRefCnt"), but sk_sp's raw-pointer constructor adopts it
without calling ref(). JsiSkTypeface then owns that sk_sp and unrefs it
when the JS wrapper is disposed or collected, so every getTypeface()
call from JS costs the typeface one net reference. Once the count
crosses the real refcount the typeface is freed while live SkFonts still
point at it, crashing the next draw or measurement in
SkTypeface::getBounds() / textToGlyphs().

Use sk_ref_sp, Skia's idiom for sharing a borrowed pointer.

Fixes Shopify#3983
@giaBaoJS

Copy link
Copy Markdown
Contributor Author

I have signed the CLA!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant