Skip to content

fix(runtime): free struct buffers marshalled from object literals when the call completes - #449

Draft
edusperoni wants to merge 1 commit into
mainfrom
fix/struct-pointer-arg-ownership
Draft

fix(runtime): free struct buffers marshalled from object literals when the call completes#449
edusperoni wants to merge 1 commit into
mainfrom
fix/struct-pointer-arg-ownership

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

What changes

Passing a plain JS object literal where a native parameter expects a pointer to a struct (e.g. CGPoint*, NSRange*):

someNativeFn({ x: 1, y: 2 }); // param is MyStruct*

previously snapshotted the literal into a malloc'd buffer that was never freed (the long-standing // TODO: How to free this? in Interop::WriteValue) — one leak per call. With this PR the buffer is owned by the FFICall driving the invocation and freed when the call completes (after ffi_call returns and the result is read).

Writes of literals into MyStruct*-typed slots of an interop.Reference are unchanged: that pointer is stored in memory that outlives any call, so those buffers deliberately remain unowned.

The lifetime contract (new, documented behavior)

You pass Buffer lifetime Use when
object literal {x, y} the call (borrowed snapshot) callee only reads during the call — the overwhelming case
struct instance (CGPointMake(...), new TNSSimpleStruct(...)) as long as the JS object lives callee keeps the pointer — you keep the object
interop.alloc(...) (+ interop.Reference) manual (interop.free or never) callee takes ownership / frees it itself

⚠️ Behavior change & risk — please test in real apps before merging

This is a deliberate behavior change with a known hazard class: code that passes a literal and relies on native retaining that pointer past the call worked before only because of the leak (the buffer was accidentally immortal). Under this PR such code gets a dangling pointer at call end — a use-after-free instead of a leak. Symmetrically, an API whose contract is "callee frees the pointer" would now double-free; interop.alloc is the correct form there.

Mitigating data — a survey of NativeScript core (packages/core + test apps):

  • Zero occurrences of the literal-as-struct-pointer pattern. Every struct literal in core feeds a by-value parameter (unaffected).
  • The single genuine struct-pointer call in core (sockaddrSCNetworkReachabilityCreateWithAddress, connectivity/index.ios.ts) already uses new interop.Reference(sockaddr, {...}) — a different, unaffected path.
  • All other interop.Reference uses are scalar out-params (CGFloat*, BOOL*, NSError**).

So exposure is limited to third-party plugins / app code using a pattern core never uses. Still: this should soak in real apps (ideally ones heavy on CoreGraphics/CoreText/AV struct-pointer APIs) before it ships, and the release notes should state the new lifetime rule.

Implementation

  • FFICall gains OwnBuffer(void*) + an owned-buffer list freed in its destructor (stack-scoped at all three drive sites: CallInitializer, CallFunctionInternal, block invoke — verified the destructor runs after the result is read).
  • Interop::WriteValue takes an optional FFICall* callOwner = nullptr; only SetFFIParams passes it. All nine other call sites — including nested ref/out-param initialization and Reference.cpp slot writes — stay owner-less on purpose (audited).
  • New fixture +[TNSTestNativeCallbacks recordsPointerEcho:] (returns the pointee) to allow per-iteration value assertions.

Tests

New specs in Marshalling/RecordTests.js:

  • literal → struct-pointer, 1000 iterations, values asserted every iteration (guards premature free/corruption);
  • wrapped struct instance → pointer param: wrapper's own buffer must survive (not call-owned);
  • interop.Reference → pointer param: reference stays readable after the call;
  • literal → by-value struct, 1000 iterations, pinned to the (allocation-free) by-value path.

Full suite passes. The leak itself is verified fixed out-of-band with the Instruments Leaks template — this path was the last remaining entry from the leaks run that 8080bc0 addressed.

…n the call completes

A plain JS object passed where native expects a struct pointer (MyStruct*)
is snapshotted into a malloc'd buffer that was never freed - the long-
standing 'How to free this?' TODO in Interop::WriteValue, and the last
remaining entry from the Instruments leaks run addressed by 8080bc0.

The buffer is now owned by the FFICall driving the invocation and freed
once the call completes, making the literal form a call-scoped borrow.
Only SetFFIParams passes the owner: writes into interop.Reference slots
(where the pointer outlives the call) and nested ref/out-param
initialization deliberately keep the unowned allocation.

BREAKING-ish: native APIs that retained such a pointer past the call only
worked because of the leak; they must now be fed a wrapped struct instance
(caller-kept) or interop.alloc memory (manual/callee-freed) instead.
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant