Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions NativeScript/runtime/ArgConverter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@
BaseDataWrapper* wrapper = tns::GetValue(isolate, receiver);

if (wrapper == nullptr) {
tns::ReleasedReason releasedReason = tns::GetReleasedReason(receiver);
if (releasedReason != tns::ReleasedReason::kNone) {
const char* jsNameStr = meta ? meta->jsName() : "<unknown>";
std::string operation = std::string("method '") + jsNameStr + "' receiver";
if (tns::ObjectManager::LifetimeStats* stats =
tns::ObjectManager::GetLifetimeStats(isolate)) {
stats->releasedAccesses++;
}
std::string message =
std::string(tns::ReleasedReasonMessage(releasedReason)) + " (" + operation + ")";
throw NativeScriptException(
isolate, Exception::ReferenceError(tns::ToV8String(isolate, message)), message);
}
// During fast view churn like HMR in development, JS objects can outlive their
// native wrappers briefly. In Debug, throw a catchable JS error instead of crashing.
// In Release, assert so crash reporting can capture unexpected cases.
Expand Down Expand Up @@ -542,6 +555,14 @@
*static_cast<id __strong*>(retValue) = data;
return;
} else {
// Native code receives this value outside any JS frame, so a released
// wrapper is reported (never thrown) and returned as nil.
tns::ReleasedReason releasedReason = tns::GetReleasedReason(value);
if (releasedReason != tns::ReleasedReason::kNone) {
tns::ReportReleasedObject(isolate, value, releasedReason, "method return value", false);
memset(retValue, 0, sizeof(id));
return;
}
id adapter = [[DictionaryAdapter alloc]
initWithJSObject:tns::ProxyReadSource(original, value).As<Object>()
isolate:isolate];
Expand Down
34 changes: 34 additions & 0 deletions NativeScript/runtime/Helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,40 @@ void SetReleasedObjectPolicy(ReleasedObjectPolicy policy);
// field unchecked must go through this.
BaseDataWrapper* GetValueOrReport(v8::Isolate* isolate, const v8::Local<v8::Value>& val,
const char* operation);

// Why an ObjC object's wrapper no longer has its native object. Stored as a
// small integer in internal field 0, where an External would otherwise sit:
// GetValue only reads Externals, so every existing caller sees "no wrapper",
// and writing it allocates nothing inside the finalizer drain.
enum class ReleasedReason : int32_t {
// Zero is never a reason: other templates' embedder fields can hold Smi 0.
kNone = 0,
// The wrapper's finalizer released it while JS could still reach the
// wrapper (only possible through a graph a resurrected wrapper keeps alive).
kCollected = 1,
// __releaseNativeCounterpart.
kExplicit = 2,
// Runtime teardown.
kTeardown = 3,
};
void MarkReleased(v8::Isolate* isolate, const v8::Local<v8::Object>& obj, ReleasedReason reason);
ReleasedReason GetReleasedReason(const v8::Local<v8::Value>& val);
const char* ReleasedReasonMessage(ReleasedReason reason);

// Applies ReleasedObjectPolicy to a released ObjC wrapper reaching native code
// in place of its object. Throws a ReferenceError under kThrow when `canThrow`;
// otherwise schedules the `releasednativeaccess` event (deduplicated per
// object). The caller then passes nil (or NSNull where nil is not allowed).
// Returns false when it threw.
bool ReportReleasedObject(v8::Isolate* isolate, const v8::Local<v8::Value>& val,
ReleasedReason reason, const char* operation, bool canThrow);

// Whether the runtime warns when JS state on a wrapper is lost while its native
// object lives on. Process-wide; defaults to the app's debug flag, overridden by
// the "wrapperLifetime": { "warnings": bool } app config or ns:runtime's
// `wrapperLifetimeWarnings` key.
bool WrapperLifetimeWarningsEnabled();
void SetWrapperLifetimeWarnings(bool enabled);
void DeleteValue(v8::Isolate* isolate, const v8::Local<v8::Value>& val);
bool DeleteWrapperIfUnused(v8::Isolate* isolate, const v8::Local<v8::Value>& obj,
BaseDataWrapper* value);
Expand Down
121 changes: 110 additions & 11 deletions NativeScript/runtime/Helpers.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
#include <atomic>
#include <cerrno>
#include <mutex>
#include <optional>
#include <sstream>
#include <vector>
#include "Caches.h"
#include "ErrorEvents.h"
#include "NativeScriptException.h"
#include "ObjectManager.h"
#include "Runtime.h"
#include "RuntimeConfig.h"

Expand Down Expand Up @@ -519,31 +521,57 @@ void WriteDebugLine(tns::LogCategory category, const char* message) {
va_end(ap);
}

tns::BaseDataWrapper* tns::GetValueOrReport(Isolate* isolate, const Local<Value>& val,
const char* operation) {
BaseDataWrapper* wrapper = tns::GetValue(isolate, val);
if (wrapper != nullptr) {
return wrapper;
const char* tns::ReleasedReasonMessage(ReleasedReason reason) {
switch (reason) {
case tns::ReleasedReason::kCollected:
return "The native object was released when its wrapper was garbage collected";
case tns::ReleasedReason::kExplicit:
return "The native object was released by __releaseNativeCounterpart";
case tns::ReleasedReason::kTeardown:
return "The native object was released at runtime teardown";
case tns::ReleasedReason::kNone:
break;
}
return "The native object has been released or is not a valid native object";
}

std::string message =
std::string("The native object has been released or is not a valid native object (") +
operation + ")";
namespace {

// Shared by GetValueOrReport and ReportReleasedObject: one message format, one
// event, one deduplication marker.
bool ApplyReleasedObjectPolicy(Isolate* isolate, const Local<Value>& val, const char* text,
const char* operation, bool canThrow) {
if (tns::ObjectManager::LifetimeStats* stats = tns::ObjectManager::GetLifetimeStats(isolate)) {
stats->releasedAccesses++;
}
std::string message = std::string(text) + " (" + operation + ")";

if (GetReleasedObjectPolicy() == ReleasedObjectPolicy::kThrow) {
if (canThrow && tns::GetReleasedObjectPolicy() == tns::ReleasedObjectPolicy::kThrow) {
isolate->ThrowException(v8::Exception::ReferenceError(tns::ToV8String(isolate, message)));
return nullptr;
return false;
}

Local<Context> context = isolate->GetCurrentContext();
std::optional<Context::Scope> contextScope;
if (context.IsEmpty() && val->IsObject()) {
// Collection adapters convert elements from native callbacks that may not
// have entered a context.
context = tns::GetCreationContextOrCurrent(isolate, val.As<Object>());
if (!context.IsEmpty()) {
contextScope.emplace(context);
}
}
if (context.IsEmpty()) {
return true;
}
if (val->IsObject()) {
// One report per husk object: a released buffer touched in a tight loop
// must not flood the event queue.
Local<Private> reported =
Private::ForApi(isolate, tns::ToV8String(isolate, "ns:releasedAccessReported"));
Local<Object> obj = val.As<Object>();
if (obj->HasPrivate(context, reported).FromMaybe(false)) {
return nullptr;
return true;
}
obj->SetPrivate(context, reported, v8::True(isolate)).FromMaybe(false);
}
Expand All @@ -553,9 +581,80 @@ void WriteDebugLine(tns::LogCategory category, const char* message) {
auto* report =
new ReleasedAccessReport{isolate, v8::Global<v8::Value>(isolate, error), operation};
isolate->EnqueueMicrotask(DispatchReleasedAccessMicrotask, report);
return true;
}

std::atomic<int> wrapperLifetimeWarnings_{-1};

} // namespace

tns::BaseDataWrapper* tns::GetValueOrReport(Isolate* isolate, const Local<Value>& val,
const char* operation) {
BaseDataWrapper* wrapper = tns::GetValue(isolate, val);
if (wrapper != nullptr) {
return wrapper;
}
ApplyReleasedObjectPolicy(isolate, val, ReleasedReasonMessage(GetReleasedReason(val)), operation,
true);
return nullptr;
}

void tns::MarkReleased(Isolate* isolate, const Local<Object>& obj, ReleasedReason reason) {
Local<Value> target = tns::UnwrapProxy(obj);
if (!target.IsEmpty() && target->IsObject() && target.As<Object>()->InternalFieldCount() > 0) {
target.As<Object>()->SetInternalField(0,
v8::Integer::New(isolate, static_cast<int32_t>(reason)));
return;
}
// Wrappers kept in the `metadata` private have no field for the marker; they
// are only cleared, and read as plain objects afterwards.
tns::DeleteValue(isolate, obj);
}

tns::ReleasedReason tns::GetReleasedReason(const Local<Value>& val) {
Local<Value> target = tns::UnwrapProxy(val);
if (target.IsEmpty() || !target->IsObject()) {
return ReleasedReason::kNone;
}
Local<Object> obj = target.As<Object>();
if (obj->InternalFieldCount() < 1) {
return ReleasedReason::kNone;
}
Local<Value> field = obj->GetInternalField(0).As<v8::Value>();
if (field.IsEmpty() || !field->IsInt32()) {
return ReleasedReason::kNone;
}
return static_cast<ReleasedReason>(field.As<v8::Int32>()->Value());
}

bool tns::ReportReleasedObject(Isolate* isolate, const Local<Value>& val, ReleasedReason reason,
const char* operation, bool canThrow) {
return ApplyReleasedObjectPolicy(isolate, val, ReleasedReasonMessage(reason), operation, canThrow);
}

bool tns::WrapperLifetimeWarningsEnabled() {
int value = wrapperLifetimeWarnings_.load(std::memory_order_relaxed);
if (value >= 0) {
return value != 0;
}
bool enabled = RuntimeConfig.IsDebug;
id config = Runtime::GetAppConfigValue("wrapperLifetime");
if ([config isKindOfClass:[NSDictionary class]]) {
id warnings = ((NSDictionary*)config)[@"warnings"];
if ([warnings isKindOfClass:[NSNumber class]]) {
enabled = [warnings boolValue];
}
}
int expected = -1;
wrapperLifetimeWarnings_.compare_exchange_strong(expected, enabled ? 1 : 0,
std::memory_order_relaxed);
return wrapperLifetimeWarnings_.load(std::memory_order_relaxed) != 0;
}

void tns::SetWrapperLifetimeWarnings(bool enabled) {
wrapperLifetimeWarnings_.store(enabled ? 1 : 0, std::memory_order_relaxed);
}

void tns::DeleteValue(Isolate* isolate, const Local<Value>& val) {
Local<Value> target = tns::UnwrapProxy(val);
if (target.IsEmpty() || target->IsNullOrUndefined() || !target->IsObject()) {
Expand Down
17 changes: 17 additions & 0 deletions NativeScript/runtime/Interop.mm
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,16 @@ static bool SnapshotArrayLike(Isolate* isolate, Local<Object> object, Local<v8::
return;
}

// A wrapper whose native object was released must never reach native code
// as a DictionaryAdapter standing in for it.
tns::ReleasedReason releasedReason = tns::GetReleasedReason(obj);
if (releasedReason != tns::ReleasedReason::kNone) {
tns::ReportReleasedObject(isolate, obj, releasedReason, "native argument", true);
id none = nil;
Interop::SetValue(dest, none);
return;
}

bool isNSArray = false;
if (typeEncoding->type == BinaryTypeEncodingType::InterfaceDeclarationReference) {
std::string name = typeEncoding->details.interfaceDeclarationReference.name.valuePtr();
Expand Down Expand Up @@ -866,6 +876,13 @@ static bool SnapshotArrayLike(Isolate* isolate, Local<Object> object, Local<v8::
break;
}
} else {
// nil is not a legal collection element, and this runs inside adapter
// callbacks where nothing can be thrown.
tns::ReleasedReason releasedReason = tns::GetReleasedReason(arg);
if (releasedReason != tns::ReleasedReason::kNone) {
tns::ReportReleasedObject(isolate, arg, releasedReason, "collection element", false);
return [NSNull null];
}
DictionaryAdapter* adapter = [[DictionaryAdapter alloc] initWithJSObject:source.As<Object>()
isolate:isolate];
// CFAutorelease(adapter);
Expand Down
6 changes: 6 additions & 0 deletions NativeScript/runtime/MetadataBuilder.mm
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ bool ResolveProxyReceiver(Isolate* isolate, Local<Object>& receiver, const char*
(wrapper->Type() == WrapperType::ObjCObject ||
wrapper->Type() == WrapperType::ObjCAllocObject);
if (!isClass && !isInstance) {
if (tns::GetReleasedReason(target) != tns::ReleasedReason::kNone) {
// A released wrapper: the call reports it the same way as on the
// target directly.
receiver = target.As<Object>();
return true;
}
reason = "on a Proxy whose target is not a native object";
}
}
Expand Down
51 changes: 50 additions & 1 deletion NativeScript/runtime/NsBuiltinModules.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "Helpers.h"
#include "Interop.h"
#include "Messaging.h"
#include "ObjectManager.h"
#include "ModuleInternalCallbacks.h"
#include "Runtime.h"
#include "StructuredSerialization.h"
Expand Down Expand Up @@ -101,6 +102,7 @@ std::string Canonicalize(const std::string& specifier) {
// deliberately not registered — it is boot-time nativescript.config only.
constexpr const char* kReleasedObjectPolicyKey = "releasedObjectPolicy";
constexpr const char* kDebugKey = "debug";
constexpr const char* kWrapperLifetimeWarningsKey = "wrapperLifetimeWarnings";

void ThrowTypeError(Isolate* isolate, const std::string& message) {
isolate->ThrowException(
Expand Down Expand Up @@ -139,6 +141,17 @@ void SetConfigCallback(const FunctionCallbackInfo<Value>& info) {
}
return;
}
if (key == kWrapperLifetimeWarningsKey) {
if (!EnsureMainIsolateWrite(isolate, key)) {
return;
}
if (!info[1]->IsBoolean()) {
ThrowTypeError(isolate, "'" + key + "' must be a boolean");
return;
}
tns::SetWrapperLifetimeWarnings(info[1]->IsTrue());
return;
}
if (key == kDebugKey) {
if (!EnsureMainIsolateWrite(isolate, key)) {
return;
Expand Down Expand Up @@ -187,9 +200,40 @@ void GetConfigCallback(const FunctionCallbackInfo<Value>& info) {
tns::ToV8String(isolate, tns::EnabledLogCategoryNames()));
return;
}
if (key == kWrapperLifetimeWarningsKey) {
info.GetReturnValue().Set(tns::WrapperLifetimeWarningsEnabled());
return;
}
ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'");
}

// Counters for the calling isolate; see ObjectManager::LifetimeStats.
void GetWrapperLifetimeStatsCallback(const FunctionCallbackInfo<Value>& info) {
Isolate* isolate = info.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
Local<Object> result = Object::New(isolate);
tns::ObjectManager::LifetimeStats* stats = tns::ObjectManager::GetLifetimeStats(isolate);
if (stats == nullptr) {
info.GetReturnValue().Set(result);
return;
}
const std::pair<const char*, uint64_t> fields[] = {
{"resurrections", stats->resurrections},
{"collected", stats->collected},
{"releasedExplicitly", stats->releasedExplicitly},
{"releasedAccesses", stats->releasedAccesses},
{"stateLostCandidates", stats->stateLostCandidates},
{"stateLostConfirmed", stats->stateLostConfirmed},
};
for (const auto& [name, value] : fields) {
result
->Set(context, tns::ToV8String(isolate, name),
v8::Number::New(isolate, static_cast<double>(value)))
.FromMaybe(false);
}
info.GetReturnValue().Set(result);
}

const Registration* Find(const std::string& specifier) {
for (const Registration& registration : kRegistry) {
if (specifier == registration.specifier) {
Expand Down Expand Up @@ -218,12 +262,17 @@ MaybeLocal<Object> NsModuleBinding(Local<Context> context) {
MaybeLocal<Object> NsRuntimeBinding(Local<Context> context) {
Isolate* isolate = v8::Isolate::GetCurrent();
Local<Object> binding = Object::New(isolate);
Local<v8::Function> setConfig, getConfig;
Local<v8::Function> setConfig, getConfig, getWrapperLifetimeStats;
if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) ||
!v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) ||
!v8::Function::New(context, GetWrapperLifetimeStatsCallback)
.ToLocal(&getWrapperLifetimeStats) ||
!binding->Set(context, tns::ToV8String(isolate, "setConfig"), setConfig)
.FromMaybe(false) ||
!binding->Set(context, tns::ToV8String(isolate, "getConfig"), getConfig)
.FromMaybe(false) ||
!binding->Set(context, tns::ToV8String(isolate, "getWrapperLifetimeStats"),
getWrapperLifetimeStats)
.FromMaybe(false)) {
return MaybeLocal<Object>();
}
Expand Down
Loading
Loading