From 23bdccdddc9b310b3373553f92e3cb0eb3093d0b Mon Sep 17 00:00:00 2001
From: Anna Henningsen <anna@addaleax.net>
Date: Fri, 19 Jun 2020 20:39:23 +0200
Subject: [PATCH] worker: add public method for marking objects as
 untransferable

We currently mark a number of `ArrayBuffer`s as not transferable,
including the `Buffer` pool and ones with finalizers provided
by C++ addons.

There is no good reason to assume that userland code might not
encounter similar problems, for example when doing `ArrayBuffer`
pooling similar to ours. Therefore, provide an API that lets
userland code also mark objects as not transferable.
---
 doc/api/worker_threads.md                     | 40 +++++++++++++++++++
 lib/buffer.js                                 |  6 +--
 lib/internal/buffer.js                        | 15 ++++++-
 lib/worker_threads.js                         |  7 +++-
 src/env.h                                     |  2 +-
 src/node_api.cc                               |  2 +-
 src/node_buffer.cc                            |  4 +-
 src/node_messaging.cc                         | 26 +++++++-----
 ...ge-transfer-port-mark-as-untransferable.js | 37 +++++++++++++++++
 9 files changed, 118 insertions(+), 21 deletions(-)
 create mode 100644 test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js

diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md
index 67823a527509fb..1dd2fc23d93a6b 100644
--- a/doc/api/worker_threads.md
+++ b/doc/api/worker_threads.md
@@ -80,6 +80,42 @@ if (isMainThread) {
 }
 ```
 
+## `worker.markAsUntransferable(object)`
+<!-- YAML
+added: REPLACEME
+-->
+
+Mark an object as not transferable. If `object` occurs in the transfer list of
+a [`port.postMessage()`][] call, it will be ignored.
+
+In particular, this makes sense for objects that can be cloned, rather than
+transferred, and which are used by other objects on the sending side.
+For example, Node.js marks the `ArrayBuffer`s it uses for its
+[`Buffer` pool][`Buffer.allocUnsafe()`] with this.
+
+This operation cannot be undone.
+
+```js
+const { MessageChannel, markAsUntransferable } = require('worker_threads');
+
+const pooledBuffer = new ArrayBuffer(8);
+const typedArray1 = new Uint8Array(pooledBuffer);
+const typedArray2 = new Float64Array(pooledBuffer);
+
+markAsUntransferable(pooledBuffer);
+
+const { port1 } = new MessageChannel();
+port1.postMessage(typedArray1, [ typedArray1.buffer ]);
+
+// The following line prints the contents of typedArray1 -- it still owns its
+// memory and has been cloned, not transfered. Without `markAsUntransferable()`,
+// this would print an empty Uint8Array. typedArray2 is intact as well.
+console.log(typedArray1);
+console.log(typedArray2);
+```
+
+There is no equivalent to this API in browsers.
+
 ## `worker.moveMessagePortToContext(port, contextifiedSandbox)`
 <!-- YAML
 added: v11.13.0
@@ -439,6 +475,9 @@ For `Buffer` instances, specifically, whether the underlying
 `ArrayBuffer` can be transferred or cloned depends entirely on how
 instances were created, which often cannot be reliably determined.
 
+An `ArrayBuffer` can be marked with [`markAsUntransferable()`][] to indicate
+that it should always be cloned and never transferred.
+
 Depending on how a `Buffer` instance was created, it may or may
 not own its underlying `ArrayBuffer`. An `ArrayBuffer` must not
 be transferred unless it is known that the `Buffer` instance
@@ -855,6 +894,7 @@ active handle in the event system. If the worker is already `unref()`ed calling
 [`WebAssembly.Module`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly/Module
 [`Worker`]: #worker_threads_class_worker
 [`cluster` module]: cluster.html
+[`markAsUntransferable()`]: #worker_threads_worker_markasuntransferable_object
 [`port.on('message')`]: #worker_threads_event_message
 [`port.onmessage()`]: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/onmessage
 [`port.postMessage()`]: #worker_threads_port_postmessage_value_transferlist
diff --git a/lib/buffer.js b/lib/buffer.js
index 991c601072c585..90ebe8866b582c 100644
--- a/lib/buffer.js
+++ b/lib/buffer.js
@@ -59,13 +59,11 @@ const {
   zeroFill: bindingZeroFill
 } = internalBinding('buffer');
 const {
-  arraybuffer_untransferable_private_symbol,
   getOwnNonIndexProperties,
   propertyFilter: {
     ALL_PROPERTIES,
     ONLY_ENUMERABLE
   },
-  setHiddenValue,
 } = internalBinding('util');
 const {
   customInspectSymbol,
@@ -83,7 +81,6 @@ const {
 } = require('internal/util/inspect');
 const { encodings } = internalBinding('string_decoder');
 
-
 const {
   codes: {
     ERR_BUFFER_OUT_OF_BOUNDS,
@@ -104,6 +101,7 @@ const {
 
 const {
   FastBuffer,
+  markAsUntransferable,
   addBufferPrototypeMethods
 } = require('internal/buffer');
 
@@ -156,7 +154,7 @@ function createUnsafeBuffer(size) {
 function createPool() {
   poolSize = Buffer.poolSize;
   allocPool = createUnsafeBuffer(poolSize).buffer;
-  setHiddenValue(allocPool, arraybuffer_untransferable_private_symbol, true);
+  markAsUntransferable(allocPool);
   poolOffset = 0;
 }
 createPool();
diff --git a/lib/internal/buffer.js b/lib/internal/buffer.js
index c344a994181b9b..be509088ada1b6 100644
--- a/lib/internal/buffer.js
+++ b/lib/internal/buffer.js
@@ -27,6 +27,10 @@ const {
   ucs2Write,
   utf8Write
 } = internalBinding('buffer');
+const {
+  untransferable_object_private_symbol,
+  setHiddenValue,
+} = internalBinding('util');
 
 // Temporary buffers to convert numbers.
 const float32Array = new Float32Array(1);
@@ -1007,7 +1011,16 @@ function addBufferPrototypeMethods(proto) {
   proto.utf8Write = utf8Write;
 }
 
+// This would better be placed in internal/worker/io.js, but that doesn't work
+// because Buffer needs this and that would introduce a cyclic dependency.
+function markAsUntransferable(obj) {
+  if ((typeof obj !== 'object' && typeof obj !== 'function') || obj === null)
+    return;  // This object is a primitive and therefore already untransferable.
+  setHiddenValue(obj, untransferable_object_private_symbol, true);
+}
+
 module.exports = {
   FastBuffer,
-  addBufferPrototypeMethods
+  addBufferPrototypeMethods,
+  markAsUntransferable,
 };
diff --git a/lib/worker_threads.js b/lib/worker_threads.js
index 4b72bf2711bdcc..1b9c6ae945e530 100644
--- a/lib/worker_threads.js
+++ b/lib/worker_threads.js
@@ -12,13 +12,18 @@ const {
   MessagePort,
   MessageChannel,
   moveMessagePortToContext,
-  receiveMessageOnPort
+  receiveMessageOnPort,
 } = require('internal/worker/io');
 
+const {
+  markAsUntransferable,
+} = require('internal/buffer');
+
 module.exports = {
   isMainThread,
   MessagePort,
   MessageChannel,
+  markAsUntransferable,
   moveMessagePortToContext,
   receiveMessageOnPort,
   resourceLimits,
diff --git a/src/env.h b/src/env.h
index ba3306a43347b8..72e18b48a30fad 100644
--- a/src/env.h
+++ b/src/env.h
@@ -146,12 +146,12 @@ constexpr size_t kFsStatsBufferLength =
 // "node:" prefix to avoid name clashes with third-party code.
 #define PER_ISOLATE_PRIVATE_SYMBOL_PROPERTIES(V)                              \
   V(alpn_buffer_private_symbol, "node:alpnBuffer")                            \
-  V(arraybuffer_untransferable_private_symbol, "node:untransferableBuffer")   \
   V(arrow_message_private_symbol, "node:arrowMessage")                        \
   V(contextify_context_private_symbol, "node:contextify:context")             \
   V(contextify_global_private_symbol, "node:contextify:global")               \
   V(decorated_private_symbol, "node:decorated")                               \
   V(napi_wrapper, "node:napi:wrapper")                                        \
+  V(untransferable_object_private_symbol, "node:untransferableObject")        \
 
 // Symbols are per-isolate primitives but Environment proxies them
 // for the sake of convenience.
diff --git a/src/node_api.cc b/src/node_api.cc
index fe24eca1b8e2d8..314e2c57a0493f 100644
--- a/src/node_api.cc
+++ b/src/node_api.cc
@@ -29,7 +29,7 @@ struct node_napi_env__ : public napi_env__ {
       v8::Local<v8::ArrayBuffer> ab) const override {
     return ab->SetPrivate(
         context(),
-        node_env()->arraybuffer_untransferable_private_symbol(),
+        node_env()->untransferable_object_private_symbol(),
         v8::True(isolate));
   }
 };
diff --git a/src/node_buffer.cc b/src/node_buffer.cc
index 75d38289546820..020b570cea3304 100644
--- a/src/node_buffer.cc
+++ b/src/node_buffer.cc
@@ -419,7 +419,7 @@ MaybeLocal<Object> New(Environment* env,
   Local<ArrayBuffer> ab =
       CallbackInfo::CreateTrackedArrayBuffer(env, data, length, callback, hint);
   if (ab->SetPrivate(env->context(),
-                     env->arraybuffer_untransferable_private_symbol(),
+                     env->untransferable_object_private_symbol(),
                      True(env->isolate())).IsNothing()) {
     return Local<Object>();
   }
@@ -1179,7 +1179,7 @@ void Initialize(Local<Object> target,
         ArrayBuffer::New(env->isolate(), std::move(backing));
     array_buffer->SetPrivate(
         env->context(),
-        env->arraybuffer_untransferable_private_symbol(),
+        env->untransferable_object_private_symbol(),
         True(env->isolate())).Check();
     CHECK(target
               ->Set(env->context(),
diff --git a/src/node_messaging.cc b/src/node_messaging.cc
index 22d2ff7257234a..91927ebbd9ebb1 100644
--- a/src/node_messaging.cc
+++ b/src/node_messaging.cc
@@ -399,7 +399,21 @@ Maybe<bool> Message::Serialize(Environment* env,
   std::vector<Local<ArrayBuffer>> array_buffers;
   for (uint32_t i = 0; i < transfer_list_v.length(); ++i) {
     Local<Value> entry = transfer_list_v[i];
-    // Currently, we support ArrayBuffers and MessagePorts.
+    if (entry->IsObject()) {
+      // See https://github.com/nodejs/node/pull/30339#issuecomment-552225353
+      // for details.
+      bool untransferable;
+      if (!entry.As<Object>()->HasPrivate(
+              context,
+              env->untransferable_object_private_symbol())
+              .To(&untransferable)) {
+        return Nothing<bool>();
+      }
+      if (untransferable) continue;
+    }
+
+    // Currently, we support ArrayBuffers and BaseObjects for which
+    // GetTransferMode() does not return kUntransferable.
     if (entry->IsArrayBuffer()) {
       Local<ArrayBuffer> ab = entry.As<ArrayBuffer>();
       // If we cannot render the ArrayBuffer unusable in this Isolate,
@@ -411,16 +425,6 @@ Maybe<bool> Message::Serialize(Environment* env,
       // is always going to outlive any Workers it creates, and so will its
       // allocator along with it.
       if (!ab->IsDetachable()) continue;
-      // See https://github.com/nodejs/node/pull/30339#issuecomment-552225353
-      // for details.
-      bool untransferrable;
-      if (!ab->HasPrivate(
-              context,
-              env->arraybuffer_untransferable_private_symbol())
-              .To(&untransferrable)) {
-        return Nothing<bool>();
-      }
-      if (untransferrable) continue;
       if (std::find(array_buffers.begin(), array_buffers.end(), ab) !=
           array_buffers.end()) {
         ThrowDataCloneException(
diff --git a/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js b/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js
new file mode 100644
index 00000000000000..240928263871a4
--- /dev/null
+++ b/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js
@@ -0,0 +1,37 @@
+'use strict';
+const common = require('../common');
+const assert = require('assert');
+const { MessageChannel, markAsUntransferable } = require('worker_threads');
+
+{
+  const ab = new ArrayBuffer(8);
+
+  markAsUntransferable(ab);
+  assert.strictEqual(ab.byteLength, 8);
+
+  const { port1, port2 } = new MessageChannel();
+  port1.postMessage(ab, [ ab ]);
+
+  assert.strictEqual(ab.byteLength, 8);  // The AB is not detached.
+  port2.once('message', common.mustCall());
+}
+
+{
+  const channel1 = new MessageChannel();
+  const channel2 = new MessageChannel();
+
+  markAsUntransferable(channel2.port1);
+
+  assert.throws(() => {
+    channel1.port1.postMessage(channel2.port1, [ channel2.port1 ]);
+  }, /was found in message but not listed in transferList/);
+
+  channel2.port1.postMessage('still works, not closed/transferred');
+  channel2.port2.once('message', common.mustCall());
+}
+
+{
+  for (const value of [0, null, false, true, undefined, [], {}]) {
+    markAsUntransferable(value);  // Has no visible effect.
+  }
+}