Repository navigation
feat(runtime): ns:swift calls Swift through generated thunks (Swift interop 2/7) - #519
Draft
NathanWalker wants to merge 10 commits into
Draft
NathanWalker wants to merge 10 commits into
NathanWalker wants to merge 10 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
This was referenced Oct 21, 2026
Draft
Draft
NathanWalker
marked this pull request as ready for review
October 21, 2026 13:50
PR 3-5 of the Swift interop plan, runtime side.
- SwiftInterop.h is the versioned binary contract with generated thunks:
24-byte value slots, the API table thunks report through, member and
type kinds, signature codes and NS_SWIFT_ABI_VERSION.
- SwiftInterop.mm loads a module on first use by dlsym-ing
ns_swift_register_<Module>, validates and keeps its registration
process-wide, and builds JS classes per isolate: constructor templates
following the Swift class hierarchy, prototype and static members,
enum namespace objects and module-level functions and variables.
Calls go straight to the thunk (no libffi) after per-argument tag,
integer-range and object-type checks; overloads resolve by arity and
accepted JS kinds, ambiguous calls name the signature keys that reach
each overload, and initializers also take a { label: value } object.
- One JS wrapper per Swift object (identity cache) owns a retain that a
weak finalizer releases; isolate teardown sweeps survivors through
IsolateTracked. Swift errors become JS errors with name SwiftError and
swiftType; out-of-range integers throw RangeError.
- ns:swift exposes module(name), info(name) and abiVersion, and
ns:swift/<Module> resolves to the module's exports for both require and
import. types/ns-swift.d.ts declares ns:swift; generated declarations
augment its SwiftModules interface.
- TestRunner links the SwiftDemo fixture with its generated thunks (new
SwiftDemo static library target) and SwiftInteropTests.js covers
classes, inheritance, structs, enums, values, errors, overloads,
identity and GC-driven release. TestRunner gains a Swift source so
Xcode links the Swift runtime libraries the fixture needs.
- docs/swift-interop.md documents the workflow and the JavaScript
semantics.
Swift interop ABI 2: calls that complete later settle a JS Promise, and isolation decides how a member is called rather than whether it can be. - The API table gains beginAsync, which makes the call's result a Promise and returns a token, and complete* entries that may be called from any thread. Outcomes carry no V8 handles; they are posted to the calling isolate's event loop and settle a resolver kept in per-isolate state, or are dropped (releasing any Swift object) if the isolate is gone. - Async thunks read their arguments synchronously, then run the call in a Task and report through the token; values reach the Task through an @unchecked Sendable box, so the thunks also compile in Swift 6 mode. - Actor members are awaited from outside the actor (and actor state is never assignable from JS); nonisolated members stay synchronous. - Synchronous @mainactor members run under MainActor.assumeIsolated after rejecting calls from other threads (Workers get an error, not a trap). - async initializers are rejected (async-initializer): new cannot await. - Probes declare async/@mainactor so verification checks these calls, and the TypeScript declarations type async members as Promise<T>. - TestRunner covers async results, rejections, wrapped objects, actors, main-actor members and a Worker using the module. Verified thunks for SwiftUI, Foundation, Charts, SwiftData, RegexBuilder, WeatherKit and Observation still all type-check.
Swift interop ABI 3 bridges [T], [String: T], Date, URL and Data. - Arguments: arrays and maps arrive as nested slot arrays (NSSwiftTagArray, NSSwiftTagMap with alternating key/value slots); Date as milliseconds, URL as its string (a JS string or anything with an href), Data as a pointer into the ArrayBuffer for the call. The runtime keeps every string and element buffer in deques so slot pointers stay valid while nested conversions add more. - Results: collections are reported element by element between returnArrayBegin/End and returnMapBegin/End (and the complete* async counterparts), through the ordinary return entries, so they nest; the value code in effect turns doubles into Dates, strings into URLs and bytes into Uint8Arrays. Async outcomes become a tree delivered once the top-level value is complete. - The classifier bridges these types (optional elements and non-String dictionary keys are reported as optional-element / dictionary-key), the thunks read elements through closures shared with top-level arguments, and the TypeScript declarations use readonly arrays, Records, Date, URL and Uint8Array. - TestRunner covers round trips, nesting, element type errors, Date, URL, Data views and an async array result. Verified thunks for the seven SDK modules still all type-check; WeatherKit goes from 177 to 212 generated members.
- Nested types are bridged and registered under their dotted JS path
("Polygon.Style"); the runtime hangs each off its parent's constructor
or enum object, or off a frozen namespace object when the parent is
not bridged. A type nested in a generic type stays swift-specialized;
one nested in an external type's extension stays unsupported.
- TypeScript declarations follow the same tree: classes merge with a
namespace of their nested types, and enums become a case-name union
plus a namespace (which, unlike a const, can hold nested types).
- Enum cases newer than their enum are reached under #available in the
name/case helpers.
- --swift-verify now type-checks the generated thunks along with the
probes, attributing thunk errors to the symbol whose `// <id>` comment
precedes them, so helpers the probes do not exercise are verified too.
Across the seven SDK modules the generated surface grows from 1,925 to
4,425 callable members (Foundation 793 -> 2,961, SwiftUI 694 -> 849,
WeatherKit 212 -> 265, SwiftData 90 -> 184); all thunks type-check.
Swift closure parameters that return Void now take JS functions (ABI 4). Generator: closures bridge when every parameter bridges and the closure neither throws nor is async. The signature code lists the closure's parameter codes, "(i64,b)". Thunks wrap the JS function in a Swift closure that reports its arguments through new callback entries in the API table. The probe and thunk declarations keep @escaping, @sendable and @mainactor, and the TypeScript output types the parameter as a function. Runtime: a JS function passed for a closure is held by a reference-counted callback handle. The call holds one reference and each Swift closure that captures the function holds another. - An invocation on the JS thread runs the function immediately. If it throws while a Swift call is in progress, that call rethrows the exception and later invocations in the same call are skipped. - An invocation from another thread is queued to the isolate's event loop, and Swift does not wait for it. The fixture adds non-escaping, escaping and background-queue closures. The TestRunner specs cover sync calls, exceptions, thread hops, optional arguments, worker isolates, and releasing the JS function. The SDK thunks for all seven modules still type-check. A snapshot output tracked by mistake is removed, and *.actual is now ignored.
Enums whose cases carry values now cross as plain JS objects that name
their case (ABI 5), e.g. { case: "area", width: 3, height: 4 } or
{ case: "length", value: 2 }. Values are copied in both directions, like
collections. Each value is keyed by its label. A case's only, unlabeled
value is keyed "value", and other unlabeled values by position.
Generator:
- An enum bridges only when every case JS can see bridges. Recursive
(indirect) enums bridge too.
- Cases register as the enum's members, with their values as parameters.
The registration order is the case index that the runtime and the
thunks share.
- Thunks get a read helper and a report helper per enum. The report helper
is written once for both calls and async completions, through a shared
reporter protocol.
- Probes construct every case, so the verifier checks each payload.
- TypeScript declares the enum as a discriminated union.
Runtime: a new case slot tag, plus case begin and end entries for sync and
async results. Overload resolution treats case objects as records.
At SDK scale, 13 enums bridge, including
URLSessionWebSocketTask.Message. All seven modules' thunks still
type-check.
An inout parameter now takes a reference object (ABI 6). JS passes
{ value }. The thunk reads `value`, calls with &local, and after a
successful call writes the final value back into `value` before
returning the result. A throw leaves the reference unchanged. Structs
come back as new copies, and class references can be reassigned. Async
members with inout parameters stay unsupported, because the call returns
before the final values exist. So do inout closures.
Signature codes mark inout parameters with '&'. The runtime keeps each
call's reference objects, and two new entries report a write-back.
Overload resolution treats reference objects as records. TypeScript
types the parameter as { value: T }.
Swift parameters and results whose type is an Objective-C class now cross as NativeScript's ordinary Objective-C wrappers (ABI 7). That covers UIKit and Foundation classes, and Swift NSObject subclasses. Results go through the same cache Objective-C calls use, so an object keeps one JS identity. Arguments are checked against the class with isKindOfClass:. The signature code is @<runtime name>. A Swift class without @objc(Name) is named Module.Class, which NSClassFromString resolves. Symbol graphs do not record which framework declares an Objective-C class, so the thunks and probes import a framework guessed from the class prefix, guarded by #if canImport. Compiler verification drops any declaration whose types still do not resolve. The SwiftDemo fixture gains Views.swift, which is also added to the TestRunner's SwiftDemo target.
A new job builds swift-symbolgraph-tests and runs it with tsc, so the generator's Swift tests gate pull requests: loading, classification, compiler verification, thunk and TypeScript snapshots, and the usage file tsc checks. The release build of the metadata generator now builds only the generator, which keeps the two test binaries (4.5 MB each) out of the npm package.
NathanWalker
force-pushed
the
swift-interop/2-runtime
branch
from
October 21, 2026 14:47
5efedd7 to
63b3a51
Compare
NathanWalker
force-pushed
the
swift-interop/1-generator
branch
from
October 21, 2026 14:47
0bba23d to
22f8e56
Compare
NathanWalker
marked this pull request as draft
October 21, 2026 16:32
This branch has not been deployed
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.
Summary
Swift interop stack — review bottom-up; each PR shows only its own commits. Merge in order; after a squash merge, rebase the next branch onto
main. Design discussion: RFC NativeScript/rfcs#57..d.tsns:swiftcore ← this PRA new
ns:swiftbuiltin loads the generated thunks and calls Swift from JS.This PR covers async functions and actors, collections and Foundation values, nested types, closures, enums with payloads, inout parameters, and Objective-C objects in Swift signatures.
NativeScript/runtime/SwiftInterop.his the binary contract between thunks and runtime. TestRunner links theSwiftDemofixture. A new CI job buildsswift-symbolgraph-testsand runs it withtsc, so the generator tests gate this PR and every PR above it.Evidence
require("ns:swift")does not exist.After: 70
ns:swiftTestRunner specs, plus the generator tests, which now also verify the thunks compile.Merge Danger
Door: two-way
Blast Radius: runtime-wide
SwiftInterop.mm, anns:swiftrow in the builtin registry, andWrapperType::SwiftObject;IsolateTrackedgains a protected accessor.ns:swift.