Skip to content

Fix v8::External::Value/New for the new ExternalPointerTypeTag API#1015

Open
gdavidkov wants to merge 1 commit into
nodejs:mainfrom
gdavidkov:fix/external-value-v8-13
Open

Fix v8::External::Value/New for the new ExternalPointerTypeTag API#1015
gdavidkov wants to merge 1 commit into
nodejs:mainfrom
gdavidkov:fix/external-value-v8-13

Conversation

@gdavidkov
Copy link
Copy Markdown
Contributor

@gdavidkov gdavidkov commented May 5, 2026

Problem

V8 added an ExternalPointerTypeTag parameter to v8::External::New and v8::External::Value and removed the prior overloads:

static Local<External> New(Isolate*, void*, ExternalPointerTypeTag);
void* Value(ExternalPointerTypeTag) const;

The new signatures are gated by V8's external-pointer-tagging build configuration, signaled by the public macro V8_EXTERNAL_POINTER_TAG_COUNT. A V8_MAJOR_VERSION check is unreliable here because Node and Chromium ship divergent V8 builds — the feature can be enabled in Electron's V8 and not in upstream Node's V8 at the same nominal major version.

nan still calls the removed forms — 28 sites in nan_callbacks_12_inl.h (the imp::*CallbackWrapper trampolines) and 3 in nan_implementation_12_inl.h (the External / Function / FunctionTemplate factories). Both are header-inline, so any addon that includes <nan.h> fails to build against a tag-enabled V8. Reproduced against Electron 42.0.0-beta.8 (Node 24.15.0) on Windows + MSVC 2022:

nan_callbacks_12_inl.h(189): error C2664:
  cannot convert from 'v8::Isolate *' to 'v8::ExternalPointerTypeTag'
nan_implementation_12_inl.h(79): error C2660:
  'v8::External::New' does not take 2 arguments

Fix

Two private inline helpers in the existing imp:: namespace, one per file next to its callers:

inline void* GetExternalPointer(v8::Local<v8::External> ext);
inline v8::Local<v8::External> NewExternal(v8::Isolate*, void*);

Each selects the tag-taking signature when V8_EXTERNAL_POINTER_TAG_COUNT is defined, passing v8::kExternalPointerTypeTagDefault (V8's no-tagging default for embedders that do not partition externals by type), and falls back to the legacy form otherwise. All 31 callsites are routed through the helpers. Both use the same tag, so create/read tags match by construction.

Backwards compatibility

Every change is gated on #ifdef V8_EXTERNAL_POINTER_TAG_COUNT. The #else branches reproduce the prior code byte-for-byte — no-op on every V8 build that doesn't define the macro.

Tests

test/cpp/news.cpp::NewExternal and test/cpp/nannew.cpp::testExternal round-trip a void* through Nan::New<v8::External> and read it back via raw v8::External::Value(). They hit the same compile break as nan's headers; the same gate is applied at each test site. NewExternal exercises imp::NewExternal directly; imp::GetExternalPointer is exercised every time any NAN_METHOD test runs from JS, since imp::FunctionCallbackWrapper unpacks the registered callback through it before dispatch.

Out of scope

V8 14 work already landed (#1010, #1013); this PR addresses only the External API change. nan_callbacks_pre_12_inl.h / nan_implementation_pre_12_inl.h target Node ≤ 0.12 (pre-V8-3.x), which uses a still-older single-arg form, so a tag-enabled V8 never selects those files.

Verified

Builds clean against Electron 42.0.0-beta.8 (Node 24.15.0) on Windows + MSVC 2022. CI will exercise both branches across the existing matrix.

@gdavidkov gdavidkov marked this pull request as draft May 5, 2026 19:30
V8 13 added an ExternalPointerTypeTag parameter (uint16_t pointer-tag for the
sandbox) to both v8::External::New and v8::External::Value:

  static Local<External> New(Isolate*, void*, ExternalPointerTypeTag);
  void* Value(ExternalPointerTypeTag) const;

The old zero-arg Value() and two-arg New(Isolate*, void*) overloads are gone,
so any nan-using addon fails to compile against Electron 42 / Node 24's V8:

  nan_callbacks_12_inl.h(189): error C2664:
    'void *v8::External::Value(v8::ExternalPointerTypeTag) const':
    cannot convert argument 1 from 'v8::Isolate *' to 'v8::ExternalPointerTypeTag'
  nan_implementation_12_inl.h(79): error C2660:
    'v8::External::New': function does not take 2 arguments

Add two private inline helpers in the existing imp:: namespace:

  imp::GetExternalPointer(v8::Local<v8::External>)
  imp::NewExternal(v8::Isolate*, void*)

Each selects the V8 13+ signature when V8_MAJOR_VERSION >= 13 (passing
v8::kExternalPointerTypeTagDefault, the documented no-tagging default for
embedders that do not partition externals by type) and falls back to the
legacy signature otherwise.  All 28 Value() trampoline callsites in
nan_callbacks_12_inl.h and all 3 New() factory callsites in
nan_implementation_12_inl.h are routed through the helpers.  Both helpers
share kExternalPointerTypeTagDefault so the tag matches between create and
read.  No new macros are introduced.

Tests
-----

The existing news.cpp::NewExternal and nannew.cpp::testExternal cases call
v8::External::Value() directly (consumer-side raw-V8 calls, not through nan),
so they failed to compile against V8 13 in the same way as nan's headers.
Added the same V8 13 guard inline at those two sites so the tests build and
run on V8 13 while remaining identical on older V8.  Together these two
existing tests already cover the round-trip exercised by this fix:

  - news.cpp::NewExternal exercises the factory path:
      Nan::New<v8::External>(...)  ->  Factory<v8::External>::New(value)
                                   ->  imp::NewExternal(isolate, value)
                                   ->  v8::External::New(isolate, value, tag)
  - The trampoline path (imp::GetExternalPointer) is exercised every time
    any NAN_METHOD test is invoked from JS, including news.cpp::NewExternal
    itself, since FunctionCallbackWrapper unpacks the registered callback
    pointer through that helper before dispatching.

Scope and what is not changed
-----------------------------

This patch addresses the v8::External break specifically.  Other unrelated
V8 13 API changes in nan, if any, are out of scope (mirroring the prior
nan_weak.h fix which was a separate, surgical commit).

nan_callbacks_pre_12_inl.h and nan_implementation_pre_12_inl.h are
intentionally untouched: they target NODE_MODULE_VERSION <=
NODE_0_12_MODULE_VERSION (Node <= 0.12, pre-V8-3.x), where neither overload
exists.

Backwards compatibility
-----------------------

Every change is gated on `defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 13`.
On V8 <= 12 the else branch reproduces the original code byte-for-byte, so
the patch is a no-op for every V8 version nan currently supports.
@gdavidkov gdavidkov force-pushed the fix/external-value-v8-13 branch from 7cfa358 to 59d874d Compare May 5, 2026 19:34
@gdavidkov gdavidkov changed the title Fix v8::External::Value/New for V8 13 (ExternalPointerTypeTag) Fix v8::External::Value/New for the new ExternalPointerTypeTag API May 5, 2026
@gdavidkov gdavidkov marked this pull request as ready for review May 5, 2026 19:44
agracio pushed a commit to agracio/electron-edge-js that referenced this pull request May 6, 2026
Recent V8 versions (currently shipped by Chromium/Electron; not yet in
released Node.js) added an ExternalPointerTypeTag parameter to
v8::External::Value():

  void* Value(ExternalPointerTypeTag tag) const;   // V8 with sandbox tagging
  void* Value() const;                             // earlier V8

The old zero-arg overload was removed, so edge-js fails to compile against
Electron 42's V8 with errors like:

  src/dotnet/clrfunc.cpp(15): error C2660:
    'v8::External::Value': function does not take 0 arguments

Six callsites of the form
  (SomeWrap*)(correlator->Value())
in src/{CoreCLREmbedding,mono,dotnet}/{coreclrfunc,clrfunc,
coreclrnodejsfuncinvokecontext,nodejsfuncinvokecontext}.cpp are guarded on
the V8_EXTERNAL_POINTER_TAG_COUNT macro (defined in <v8-internal.h>
alongside the new signature) and pass v8::kExternalPointerTypeTagDefault
when the new API is in scope. The else branch reproduces the prior source
line-for-line, so the change is a no-op on every released Node.js V8 today.

The companion fix for nan's own callsites (which edge-js depends on) is in
nodejs/nan#1015; both fixes are needed for end-to-end builds against
Electron 42.
@agracio
Copy link
Copy Markdown
Contributor

agracio commented May 6, 2026

nan Github CI no longer runs on Node 16 and 17.
This RP addresses issues with v8 14.8.

@agracio agracio mentioned this pull request May 6, 2026
agracio added a commit to agracio/nan that referenced this pull request May 6, 2026
agracio added a commit to agracio/nan that referenced this pull request May 7, 2026
@kkoopa
Copy link
Copy Markdown
Collaborator

kkoopa commented May 11, 2026

I tried looking at the CI output, but do not understand why 16 and 17 fail, yet the others work.

@kkoopa
Copy link
Copy Markdown
Collaborator

kkoopa commented May 12, 2026

There should not be a need to (manually) do any preprocessor conditionals in the tests, since that would require doing so in actual code. It has been a long time, so I do not remember all the internals anymore, but it seems like v8::External needs to be duplicated by a nan::External, exposing the current (old) API and internally sticking on a tag when needed. One could possibly do it with free functions, Nan::New for the constructor and a new function NanGetExternal or something for getting the value, but that seems very ugly. What are your thoughts on this?

@gdavidkov
Copy link
Copy Markdown
Contributor Author

You're right that the #ifdefs in the tests are a smell — if the tests need them, addon code needs them too, and that's exactly what NAN should be hiding. Thanks for pushing back on that.

I see three plausible directions, with different tradeoffs:

  1. Public read-side helper. The current PR already routes Nan::Newv8::External(value) through an imp::NewExternal helper that applies the tag, so the write side is already abstracted. The only remaining gap on the public surface is ->Value() — there's no factory analog for the read. Promoting the second helper from this PR to something like Nan::ExternalValue(local) would close that gap, and the tests drop their #ifdefs. NAN already uses this exact inline-helper pattern internally for the analogous v8::String::GetExternal* / IsExternal* renames (in imp::), so the mechanism isn't novel — this would just expose it publicly. Smallest diff, fully backwards compatible. You called this "very ugly" though, and I take the point: it leaves v8::Localv8::External as the carried type, so users still have to remember to call Nan::ExternalValue(e) instead of e->Value().
  2. Nan::External wrapper class along the lines you described — a thin value type holding a v8::Localv8::External, exposing the old New(value) / Value() shape and internally adding the tag on V8 ≥ 13. Cleanest from a user-migration standpoint (literal v8::External → Nan::External find-and-replace), but a couple of constraints worth flagging:
    • We can't change the return type of the existing Factoryv8::External::New (Nan::Newv8::External returning v8::Localv8::External) without breaking every addon that already assigns the result to a v8::Localv8::External. So Nan::Newv8::External stays as-is for compat, and Nan::NewNan::External becomes the new opt-in path via a FactoryNan::External specialization — two parallel factories.
    • The wrapper doesn't cover externals that don't come from Nan::New — e.g., info[0].Asv8::External() from JS-passed arguments, or obj->GetInternalField(i).Asv8::External() inside our own callback wrappers. Those callers already hold a raw v8::Localv8::External and need either the free helper from (1) or a Nan::External(rawLocal).Value() constructor path to read it tagged.
  3. Hybrid — both of the above. Public read helper as the underlying mechanism (covers raw-Local sites, including NAN's own callback wrappers), and Nan::External as an optional sugar wrapper for users who want the drop-in feel. This is what I'd lean toward — strict superset of (1) and (2), and avoids choosing between "covers all sites" and "ergonomic drop-in."

Sketch of the wrapper if it helps anchor the discussion:

namespace Nan {
class External {
  v8::Local<v8::External> handle_;
 public:
  External() = default;
  explicit External(v8::Local<v8::External> h) : handle_(h) {}

  static External New(void* value) {
    v8::Isolate* iso = v8::Isolate::GetCurrent();
#ifdef V8_EXTERNAL_POINTER_TAG_COUNT
    return External(v8::External::New(iso, value,
                                      v8::kExternalPointerTypeTagDefault));
#else
    return External(v8::External::New(iso, value));
#endif
  }

  void* Value() const {
#ifdef V8_EXTERNAL_POINTER_TAG_COUNT
    return handle_->Value(v8::kExternalPointerTypeTagDefault);
#else
    return handle_->Value();
#endif
  }

  bool IsEmpty() const { return handle_.IsEmpty(); }
  operator v8::Local<v8::External>() const { return handle_; }
};
}

Isolate is resolved via v8::Isolate::GetCurrent() to match the existing Factoryv8::External::New. Only the conversion to v8::Localv8::External is needed — Local already converts to Local in V8, so a second operator would just invite overload ambiguity. Call site is e.Value() (dot, not arrow), which matches Nan::Callback / Nan::Persistent style.

Happy to take this PR in whichever direction you prefer. If you're good with the hybrid, I'll drop the #ifdefs in the tests, add the public helper + wrapper, and route the internal callback code through them.

@kkoopa
Copy link
Copy Markdown
Collaborator

kkoopa commented May 12, 2026

Yes, that wrapper class looks exactly like what I had in mind, but your second point does list genuine constraints I had not thought of, making the wrapper class approach somewhat less nice. Looking through the API documentation, I see a lot of free functions working on v8 types, so free functions one must remember to use are not at all uncommon. I made it like that, I still think it is ugly, but I had forgotten how much ugliness there already was. Thinking back, the use of free functions probably stemmed from saving effort on wrapping entire classes. I am therefore now leaning towards option 1 over option 2. Option 3 sounds interesting if you think it would be genuinely useful to have a sugar class.

I do not know how many addons explicitly use Externals, but I believe there should not be that many call sites to e.Value() in total and the compiler will catch them all, so at least there is no risk of runtime errors. The documentation would also need updating (edit individual files under doc/, then run make docs to update README.md).

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.

3 participants