一個(gè)影響 Node.js fs Promise API 的問題
最近有用戶提交了一個(gè) issue 報(bào)告了一個(gè) Node.js 文件模塊的問題。經(jīng)過分析,發(fā)現(xiàn)這是 Node.js 文件模塊的一個(gè) Bug,并且初步看會(huì)影響大多數(shù)的 Promise API,但是幸好正常情況下并不會(huì)觸發(fā),這可能是這么多年都沒有發(fā)現(xiàn)這個(gè)問題的原因。下面介紹這個(gè)問題相關(guān)的內(nèi)容。
發(fā)現(xiàn)問題
復(fù)現(xiàn)代碼如下:
import fs from "fs"
await fs.promises.mkdtemp('asdf')執(zhí)行 node --permission demo.mjs 預(yù)期輸出是權(quán)限相關(guān)的錯(cuò)誤,但是實(shí)際上輸出了以下錯(cuò)誤:
return await PromisePrototypeThen(
^
TypeError: Method Promise.prototype.then called on incompatible receiver undefined這看起來是 Node.js 內(nèi)部的一個(gè)錯(cuò)誤。
換一個(gè)例子。
import fs from "fs"
try {
await fs.promises.mkdtemp('asdf')
} catch(e) {
}執(zhí)行 node --permission demo.mjs 預(yù)期不會(huì)報(bào)錯(cuò),但是會(huì)輸出以下錯(cuò)誤:
binding.mkdtemp(prefix, options.encoding, kUsePromises),
^
Error: Access to this API has been restricted. Use --allow-fs-write to manage permissions.明明捕獲了錯(cuò)誤為什么還會(huì)輸出呢?這個(gè)信息又是哪里輸出的呢?
再看一個(gè)例子。
import fs from "fs"
process.on('unhandledRejection', () => {})
try {
await fs.promises.mkdtemp('asdf')
} catch(e) {
}再執(zhí)行 node --permission demo.mjs 就不會(huì)輸出任何錯(cuò)誤了,說明這個(gè)錯(cuò)誤是一個(gè) Rejected 的 Promise 導(dǎo)致的。
分析問題
我們看一下 mkdtemp 的實(shí)現(xiàn)來分析為什么會(huì)出現(xiàn)這個(gè)錯(cuò)誤。
async function mkdtemp(prefix, options) {
options = getOptions(options);
prefix = getValidatedPath(prefix, 'prefix');
return await PromisePrototypeThen(
binding.mkdtemp(prefix, options.encoding, kUsePromises),
undefined,
handleErrorFromBinding,
);
}上面代碼中,binding.mkdtemp 預(yù)期返回一個(gè) Promise,resolve 時(shí)什么都不做,reject 時(shí)執(zhí)行 handleErrorFromBinding,所以看起來 binding.mkdtemp 并沒有返回一個(gè) Promise,而是返回了一個(gè) undefined。接著看底層的實(shí)現(xiàn)。
static void Mkdtemp(const FunctionCallbackInfo<Value>& args) {
// 忽略不相關(guān)代碼
// 回調(diào)模式或 Promise模式
if (argc > 2) {
FSReqBase* req_wrap_async = GetReqWrap(args, 2);
// 權(quán)限模型相關(guān)
ASYNC_THROW_IF_INSUFFICIENT_PERMISSIONS(...);
// 發(fā)起文件操作
AsyncCall(env, req_wrap_async, ...);
} else {
// 同步模式
}
}首先看一下 GetReqWrap。
FSReqBase* GetReqWrap(const v8::FunctionCallbackInfo<v8::Value>& args,
int index,
bool use_bigint) {
v8::Local<v8::Value> value = args[index];
// 回調(diào)模式
if (value->IsObject()) {
return BaseObject::Unwrap<FSReqBase>(value.As<v8::Object>());
}
Realm* realm = Realm::GetCurrent(args);
BindingData* binding_data = realm->GetBindingData<BindingData>();
// Promise 模式
if (value->StrictEquals(realm->isolate_data()->fs_use_promises_symbol())) {
if (use_bigint) {
return FSReqPromise<AliasedBigInt64Array>::New(binding_data, use_bigint);
} else {
return FSReqPromise<AliasedFloat64Array>::New(binding_data, use_bigint);
}
}
returnnullptr;
}GetReqWrap 的作用是創(chuàng)建一個(gè) C++ 對(duì)象,用于實(shí)現(xiàn)文件操作結(jié)束后通知 JS 層的邏輯,后面再看具體的實(shí)現(xiàn)。接著看一下 AsyncCall。
template <typename Func, typename... Args>
FSReqBase* AsyncCall(Environment* env,
FSReqBase* req_wrap,
const v8::FunctionCallbackInfo<v8::Value>& args,
const char* syscall, enum encoding enc,
uv_fs_cb after, Func fn, Args... fn_args) {
return AsyncDestCall(env, req_wrap, args,
syscall, nullptr, 0, enc,
after, fn, fn_args...);
}
template <typename Func, typename... Args>
FSReqBase* AsyncDestCall(Environment* env, FSReqBase* req_wrap,
const v8::FunctionCallbackInfo<v8::Value>& args,
const char* syscall, const char* dest,
size_t len, enum encoding enc, uv_fs_cb after,
Func fn, Args... fn_args) {
CHECK_NOT_NULL(req_wrap);
req_wrap->Init(syscall, dest, len, enc);
int err = req_wrap->Dispatch(fn, fn_args..., after);
// 失敗直接通知 JS 層
if (err < 0) {
uv_fs_t* uv_req = req_wrap->req();
uv_req->result = err;
uv_req->path = nullptr;
after(uv_req); // after may delete req_wrap if there is an error
req_wrap = nullptr;
} else {
// 成功則設(shè)置返回 JS 層的值,比如返回一個(gè) Promise
req_wrap->SetReturnValue(args);
}
return req_wrap;
}AsyncCall 調(diào)了 AsyncDestCall,AsyncDestCall 最終調(diào) Libuv 發(fā)起了一個(gè)文件操作,這里的情況有三種。
- 發(fā)起操作失敗,即 err < 0。
- 發(fā)起操作成功,但是執(zhí)行底層文件操作失敗,異步通知。
- 發(fā)起操作成功,執(zhí)行底層文件操作成功,異步通知。
正常流程是 2 或 3,所以發(fā)起文件操作成功后會(huì)先設(shè)置返回值給 JS 層。
template <typename AliasedBufferT>
void FSReqPromise<AliasedBufferT>::SetReturnValue(
const v8::FunctionCallbackInfo<v8::Value>& args) {
v8::Local<v8::Value> val;
if (!object()->Get(env()->context(), env()->promise_string()).ToLocal(&val)) {
return;
}
v8::Local<v8::Promise::Resolver> resolver = val.As<v8::Promise::Resolver>();
args.GetReturnValue().Set(resolver->GetPromise());
}最終返回給 JS 的是一個(gè) Promise,JS 層會(huì) await 返回的 Promise,等到 Libuv 執(zhí)行文件操作完成后決議該 Promise。接著看文件操作結(jié)束后的處理邏輯。
void AfterStringPath(uv_fs_t* req) {
FSReqBase* req_wrap = FSReqBase::from_req(req);
FSReqAfterScope after(req_wrap, req);
MaybeLocal<Value> link;
// 操作失敗
if (req_->result < 0) {
Reject(req_);
return;
}
// 操作成功
TryCatch try_catch(req_wrap->env()->isolate());
link = StringBytes::Encode(...);
// 結(jié)果無效
if (link.IsEmpty()) {
req_wrap->Reject(try_catch.Exception());
} else { // 結(jié)果有效
Local<Value> val;
if (link.ToLocal(&val)) req_wrap->Resolve(val);
}
}AfterStringPath 會(huì)執(zhí)行 Resolve 或 Reject,看一下 Reject 的實(shí)現(xiàn)。
template <typename AliasedBufferT>
void FSReqPromise<AliasedBufferT>::Reject(v8::Local<v8::Value> reject) {
v8::Local<v8::Value> value;
if (!object()
->Get(env()->context(), env()->promise_string())
.ToLocal(&value)) {
return;
}
v8::Local<v8::Promise::Resolver> resolver = value.As<v8::Promise::Resolver>();
USE(resolver->Reject(env()->context(), reject).FromJust());
}最終 Reject 了返回給 JS 層的 Promise,這時(shí)候 JS 的 await promise 就返回了。
那么問題出在哪里呢?問題出在發(fā)起文件操作的情況 1 中,也就是當(dāng)發(fā)起文件操作失敗時(shí),C++ 層沒有把 Promise 返回給 JS 層,而僅僅是在 C++ 層把這個(gè) Promise 設(shè)置為 Rejected 狀態(tài),所以導(dǎo)致了前面例子的錯(cuò)誤。那么為什么這個(gè)問題這么多年都沒有出現(xiàn)呢?因?yàn)榘l(fā)起文件操作失敗的概率非常小,下面是一種失敗的場(chǎng)景。
int uv_fs_mkdtemp(uv_loop_t* loop,
uv_fs_t* req,
const char* tpl,
uv_fs_cb cb) {
INIT(MKDTEMP);
req->path = uv__strdup(tpl);
if (req->path == NULL)
return UV_ENOMEM;
POST;
}可以看到當(dāng)沒有內(nèi)存時(shí)會(huì)導(dǎo)致發(fā)起文件操作失敗,這個(gè)概率太小了。既然難以出現(xiàn),為什么現(xiàn)在出現(xiàn)了呢?因?yàn)?Node.js 引入的權(quán)限模型以另一種方式觸發(fā)了這個(gè) Bug。看一下文件模塊中權(quán)限模型的處理邏輯(權(quán)限模型的代碼在發(fā)起文件操作之前執(zhí)行)。
ASYNC_THROW_IF_INSUFFICIENT_PERMISSIONS(
env,
req_wrap_async,
permission::PermissionScope::kFileSystemWrite,
tmpl.ToStringView());ASYNC_THROW_IF_INSUFFICIENT_PERMISSIONS 是一個(gè)宏。
#define ASYNC_THROW_IF_INSUFFICIENT_PERMISSIONS( \
env, wrap, perm_, resource_, ...) \
do { \
if (!env->permission()->is_granted(env, perm_, resource_)) [[unlikely]] { \
node::permission::Permission::AsyncThrowAccessDenied( \
(env), wrap, perm_, resource_); \
return __VA_ARGS__; \
} \
} while (0)沒有權(quán)限時(shí)執(zhí)行 AsyncThrowAccessDenied。
void Permission::AsyncThrowAccessDenied(Environment* env,
fs::FSReqBase* req_wrap,
PermissionScope perm,
const std::string_view& res) {
Local<Value> err;
if (CreateAccessDeniedError(env, perm, res).ToLocal(&err)) {
return req_wrap->Reject(err);
}
}最終 Reject 了 Promise,但是這個(gè) Promise 并沒有返回給 JS,從而導(dǎo)致了兩個(gè)問題,第一個(gè)問題是 JS 因?yàn)?C++ 返回了 undefined 觸發(fā)了 Method Promise.prototype.then called on incompatible receiver undefined 的報(bào)錯(cuò),第二個(gè)問題是 Reject 的 Promise 無法捕獲,觸發(fā)了 unhandledRejection 事件(可能會(huì)導(dǎo)致進(jìn)程退出)。
那么為什么權(quán)限模型的單測(cè)沒有發(fā)現(xiàn)這個(gè)問題呢?首先權(quán)限模型的單測(cè)測(cè)試的場(chǎng)景大多是基于文件模塊的回調(diào)模式 API(回調(diào)模式?jīng)]有這個(gè)問題,因?yàn)樗{(diào) C++ 層時(shí)本身就不需要返回值),而基于 Promise API 的單測(cè)一共測(cè)試了 8 個(gè) API,其中 5 個(gè) API 沒有這個(gè)問題,其他 3 個(gè)觸發(fā)了這個(gè)問題,但是被注釋了。
// TODO: Uncaught Exception somehow?
// assert.rejects(async () => {
// return fs.promises.chown(blockedFile, 1541, 999);
// }, {
// code: 'ERR_ACCESS_DENIED',
// permission: 'FileSystemWrite',
// });通過分析發(fā)現(xiàn),這個(gè)問題的原因是因?yàn)?C++ 沒有設(shè)置返回值給 JS 層導(dǎo)致的,而這又是大多數(shù) Promise API 的公共邏輯,所以大多數(shù) Promise 的 API 都會(huì)受這個(gè)問題的影響,接下來看如何解決這個(gè)問題。
解決問題
我初步修復(fù)了 mkdtemp API 并驗(yàn)證成功,但是解決單個(gè) API 的問題并不難,難在解決受影響的所有 API。大致的解決思路就是在 C++ 層的每個(gè) API 核心代碼執(zhí)行前先設(shè)置給 JS 的返回值,但是這個(gè)看起來非常麻煩,經(jīng)過分析,最終發(fā)現(xiàn)在 GetReqWrap 中可以統(tǒng)一處理這個(gè)問題,修復(fù)代碼如下。
FSReqBase* GetReqWrap(const v8::FunctionCallbackInfo<v8::Value>& args,
int index,
bool use_bigint) {
v8::Local<v8::Value> value = args[index];
FSReqBase* result = nullptr;
if (value->IsObject()) {
result = BaseObject::Unwrap<FSReqBase>(value.As<v8::Object>());
} else {
Realm* realm = Realm::GetCurrent(args);
BindingData* binding_data = realm->GetBindingData<BindingData>();
if (value->StrictEquals(realm->isolate_data()->fs_use_promises_symbol())) {
if (use_bigint) {
result =
FSReqPromise<AliasedBigInt64Array>::New(binding_data, use_bigint);
} else {
result =
FSReqPromise<AliasedFloat64Array>::New(binding_data, use_bigint);
}
}
}
if (result != nullptr) {
result->SetReturnValue(args);
}
return result;
}具體可以參考這個(gè) PR,但是影響面比較大,還需要 Review 和測(cè)試。
總結(jié)
Node.js 的這個(gè)問題非常久了,但是觸發(fā)概率非常小,如果大家用到權(quán)限模型且沒有開啟文件模塊權(quán)限時(shí)才會(huì)大概率觸發(fā)這個(gè)問題。































