Skip to content
Open
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
12 changes: 6 additions & 6 deletions src/Authentication.cc
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ static const std::string PARAM_TOKEN = "token";
static const std::string PARAM_USERNAME = "username";
static const std::string PARAM_PASSWORD = "password";

void FinalizeTokenSupplierCallback(Napi::Env env, TokenSupplierCallback *cb, void *) { delete cb; }

struct TokenSupplierProxyData {
std::function<void(void)> callback;
std::string token;
Expand Down Expand Up @@ -134,14 +132,16 @@ Authentication::Authentication(const Napi::CallbackInfo &info)
}

if (obj.Get(PARAM_TOKEN).IsFunction()) {
this->tokenSupplier = new TokenSupplierCallback();
this->tokenSupplier = std::make_shared<TokenSupplierCallback>();
std::shared_ptr<TokenSupplierCallback> tokenSupplier = this->tokenSupplier;
Napi::ThreadSafeFunction callback = Napi::ThreadSafeFunction::New(
obj.Env(), obj.Get(PARAM_TOKEN).As<Napi::Function>(), "Token Supplier Callback", 1, 1,
(void *)NULL, FinalizeTokenSupplierCallback, this->tokenSupplier);
[tokenSupplier](Napi::Env) { tokenSupplier->finalized = true; });
this->tokenSupplier->callback = std::move(callback);
this->tokenSupplier->callback.Unref(env);
Comment on lines +139 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: P1

Title: Synchronize supplier finalization with native callback use

Location: src/Authentication.cc:139–141

Trigger: An idle, live producer remains reachable as the last Node event-loop handle drains, while its native connection handles a broker authentication-refresh challenge or reconnects. These background native operations do not create a ThreadSafeDeferred to keep Node alive.

Impact: The unconditional Unref permits environment teardown while a native thread is using the supplier's thread-safe function. That thread can access a freed N-API handle, causing a use-after-free or process crash during natural shutdown. Shared ownership preserves the callback container, not the underlying N-API handle.

Evidence: The finalizer only sets finalized, and only the Authentication destructor reads that flag. [TokenSupplier](<apache/pulsar-client-node/pr-510/src/Authentication.cc#L60-L76>) still calls Acquire, BlockingCall and Release without synchronization with finalization. In [Node 18's implementation](<https://raw.githubusercontent.com/nodejs/node/v18.20.8/src/node_api.cc>), environment Cleanup closes the TSFN regardless of thread_count, and Finalize deletes it after invoking the addon finalizer. Consequently, a native caller can successfully Acquire, be interrupted by environment finalization, then resume BlockingCall on the freed handle. This is reachable through [Pulsar's native authentication-challenge handling](<https://raw.githubusercontent.com/apache/pulsar-client-cpp/v4.2.0/lib/ClientConnection.cc>) and [token-supplier invocation](<https://raw.githubusercontent.com/apache/pulsar-client-cpp/v4.2.0/lib/auth/AuthToken.cc>), independently of a pending JavaScript operation. Previously, the referenced supplier prevented this natural teardown while authentication remained reachable.

Suggested fix: Coordinate supplier shutdown with native callers: prevent new calls and safely finish or cancel outstanding work before the TSFN is destroyed, or retain event-loop liveness until native users have stopped. Protect native use, not just the Authentication destructor. Add a process-exit regression involving an invoked supplier and background authentication refresh or reconnect.


this->cAuthentication =
pulsar_authentication_token_create_with_supplier(&TokenSupplier, this->tokenSupplier);
pulsar_authentication_token_create_with_supplier(&TokenSupplier, this->tokenSupplier.get());
return;
}
}
Expand Down Expand Up @@ -178,7 +178,7 @@ Authentication::Authentication(const Napi::CallbackInfo &info)
}

Authentication::~Authentication() {
if (this->tokenSupplier != nullptr) {
if (this->tokenSupplier != nullptr && !this->tokenSupplier->finalized) {
this->tokenSupplier->callback.Release();
}
if (this->cAuthentication != nullptr) {
Expand Down
3 changes: 2 additions & 1 deletion src/Authentication.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#ifndef AUTH_H
#define AUTH_H

#include <memory>
#include <napi.h>
#include <pulsar/c/authentication.h>
#include "TokenSupplier.h"
Expand All @@ -35,7 +36,7 @@ class Authentication : public Napi::ObjectWrap<Authentication> {
static Napi::FunctionReference constructor;
pulsar_authentication_t *cAuthentication;

TokenSupplierCallback *tokenSupplier;
std::shared_ptr<TokenSupplierCallback> tokenSupplier;
};

#endif
71 changes: 39 additions & 32 deletions src/Client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ static const std::string CFG_LOG_LEVEL = "logLevel";
static const std::string CFG_LISTENER_NAME = "listenerName";
static const std::string CFG_CONNECTION_TIMEOUT = "connectionTimeoutMs";

LogCallback *Client::logCallback = nullptr;
std::shared_ptr<LogCallback> Client::logCallback;
std::mutex Client::logMutex;

struct _pulsar_client_configuration {
pulsar::ClientConfiguration conf;
Expand Down Expand Up @@ -259,27 +260,34 @@ static std::unique_ptr<pulsar::ServiceInfoProvider> BuildServiceInfoProvider(
void Client::SetLogHandler(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
Napi::HandleScope scope(env);
Napi::Value jsFunction = info[0];
ConfigureLogHandler(info[0]);
}

if (jsFunction.IsNull()) {
if (Client::logCallback != nullptr) {
Client::logCallback->callback.Release();
delete Client::logCallback;
Client::logCallback = nullptr;
}
} else if (jsFunction.IsFunction()) {
Napi::ThreadSafeFunction logFunction =
Napi::ThreadSafeFunction::New(env, jsFunction.As<Napi::Function>(), "Pulsar Logging", 0, 1);
logFunction.Unref(env);
if (Client::logCallback != nullptr) {
Client::logCallback->callback.Release();
} else {
Client::logCallback = new LogCallback();
}
Client::logCallback->callback = std::move(logFunction);
} else {
void Client::ConfigureLogHandler(Napi::Value jsFunction) {
Napi::Env env = jsFunction.Env();
if (!jsFunction.IsNull() && !jsFunction.IsFunction()) {
Napi::Error::New(env, "Client log handler must be a function or null").ThrowAsJavaScriptException();
return;
}

std::shared_ptr<LogCallback> next;
if (jsFunction.IsFunction()) {
next = std::make_shared<LogCallback>();
next->callback = Napi::ThreadSafeFunction::New(env, jsFunction.As<Napi::Function>(), "Pulsar Logging", 0,
1, [next](Napi::Env) {
std::lock_guard<std::mutex> lock(logMutex);
if (logCallback == next) {
logCallback.reset();
}
});
next->callback.Unref(env);
}

std::lock_guard<std::mutex> lock(logMutex);
if (logCallback != nullptr) {
logCallback->callback.Release();
}
logCallback = std::move(next);
}

Napi::FunctionReference Client::constructor;
Expand Down Expand Up @@ -352,16 +360,7 @@ Client::Client(const Napi::CallbackInfo &info) : Napi::ObjectWrap<Client>(info)

// log config option should be deprecated in favour of static setLogHandler method
if (clientConfig.Has(CFG_LOG) && clientConfig.Get(CFG_LOG).IsFunction()) {
Napi::ThreadSafeFunction logFunction = Napi::ThreadSafeFunction::New(
env, clientConfig.Get(CFG_LOG).As<Napi::Function>(), "Pulsar Logging", 0, 1);
logFunction.Unref(env);

if (Client::logCallback != nullptr) {
Client::logCallback->callback.Release();
} else {
Client::logCallback = new LogCallback();
}
Client::logCallback->callback = std::move(logFunction);
ConfigureLogHandler(clientConfig.Get(CFG_LOG));
}

if (clientConfig.Has(CFG_AUTH) && clientConfig.Get(CFG_AUTH).IsObject()) {
Expand Down Expand Up @@ -535,7 +534,10 @@ void LogMessageProxy(Napi::Env env, Napi::Function jsCallback, struct LogMessage

void Client::LogMessage(pulsar_logger_level_t level, const char *file, int line, const char *message,
void *ctx) {
LogCallback *logCallback = Client::logCallback;
// The logging TSFN uses maxQueueSize = 0, so BlockingCall does not wait
// for JS to drain the queue while logMutex is held.
std::lock_guard<std::mutex> lock(logMutex);
auto logCallback = Client::logCallback;

if (logCallback == nullptr) {
return;
Expand All @@ -547,8 +549,13 @@ void Client::LogMessage(pulsar_logger_level_t level, const char *file, int line,

struct LogMessage *logMessage = new struct LogMessage(level, std::string(file), line, std::string(message));

logCallback->callback.BlockingCall(logMessage, LogMessageProxy);
logCallback->callback.Release();
napi_status status = logCallback->callback.BlockingCall(logMessage, LogMessageProxy);
if (status != napi_ok) {
delete logMessage;
}
if (status != napi_closing) {
logCallback->callback.Release();
}
}

Napi::Value Client::Close(const Napi::CallbackInfo &info) {
Expand Down
6 changes: 5 additions & 1 deletion src/Client.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
#ifndef CLIENT_H
#define CLIENT_H

#include <memory>
#include <mutex>
#include <napi.h>
#include <pulsar/c/client.h>
#include <vector>
Expand Down Expand Up @@ -50,13 +52,15 @@ class Client : public Napi::ObjectWrap<Client> {
~Client();

private:
static LogCallback *logCallback;
static std::shared_ptr<LogCallback> logCallback;
static std::mutex logMutex;
static Napi::FunctionReference constructor;
std::shared_ptr<pulsar_client_t> cClient;
std::shared_ptr<pulsar_client_configuration_t> cClientConfig;
pulsar_logger_level_t logLevel = pulsar_logger_level_t::pulsar_INFO;
std::vector<Napi::ObjectReference> authRefs_;

static void ConfigureLogHandler(Napi::Value jsFunction);
Napi::Value CreateProducer(const Napi::CallbackInfo &info);
Napi::Value Subscribe(const Napi::CallbackInfo &info);
Napi::Value CreateReader(const Napi::CallbackInfo &info);
Expand Down
1 change: 1 addition & 0 deletions src/TokenSupplier.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

struct TokenSupplierCallback {
Napi::ThreadSafeFunction callback;
bool finalized = false;

TokenSupplierCallback() {}
};
Expand Down
102 changes: 102 additions & 0 deletions tests/process_exit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

const path = require('path');
const { spawn } = require('child_process');

const scenariosDir = path.join(__dirname, 'process_exit');
const runner = path.join(scenariosDir, 'runner.js');

const expectNaturalExit = (script, args) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, [runner, script, ...args], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
let stdout = '';
let stderr = '';
let spawnError;
let protocolFailure;
let timedOut;
let completed = false;
let disconnectedAfterCompletion = false;
let naturalExitTimer;

const watchdogTimer = setTimeout(() => {
timedOut = completed ? 'IPC disconnect' : 'scenario completion';
child.kill('SIGKILL');
}, 60000);

child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('message', (message) => {
if (message && message.type === 'scenario-complete') {
completed = true;
}
});
child.on('disconnect', () => {
if (!completed) {
protocolFailure = 'IPC disconnected before scenario completion';
child.kill('SIGKILL');
return;
}

disconnectedAfterCompletion = true;
clearTimeout(watchdogTimer);
naturalExitTimer = setTimeout(() => {
timedOut = 'natural exit after scenario completion and IPC disconnect';
child.kill('SIGKILL');
}, 10000);
});
child.on('error', (error) => {
spawnError = error;
clearTimeout(watchdogTimer);
clearTimeout(naturalExitTimer);
});
child.on('close', (code, signal) => {
clearTimeout(watchdogTimer);
clearTimeout(naturalExitTimer);
if (timedOut || spawnError || protocolFailure || !completed
|| !disconnectedAfterCompletion || code !== 0 || signal !== null) {
reject(new Error(`Child did not exit naturally: ${JSON.stringify({
timedOut,
spawnError: spawnError && spawnError.message,
protocolFailure,
completed,
disconnectedAfterCompletion,
code,
signal,
stdout,
stderr,
})}`));
return;
}
resolve();
});
});

describe('AuthenticationToken supplier process exit', () => {
const scenario = path.join(scenariosDir, 'token_supplier.js');

test.each(['sync', 'async'])('%s supplier exits after closing a client', async (supplier) => {
await expectNaturalExit(scenario, [supplier]);
}, 75000);
});
53 changes: 53 additions & 0 deletions tests/process_exit/runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

const disconnect = () => {
if (process.connected) {
process.disconnect();
}
};

const fail = (error) => {
console.error(error);
process.exitCode = 1;
disconnect();
};

const runScenario = async () => {
if (typeof process.send !== 'function') {
throw new Error('Scenario runner requires an IPC channel');
}

// eslint-disable-next-line global-require, import/no-dynamic-require
const scenario = require(process.argv[2]);
if (typeof scenario !== 'function') {
throw new TypeError('Scenario module must export a function');
}
await scenario(...process.argv.slice(3));
};

runScenario().then(() => {
process.send({ type: 'scenario-complete' }, (error) => {
if (error) {
fail(error);
return;
}
disconnect();
});
}).catch(fail);
39 changes: 39 additions & 0 deletions tests/process_exit/token_supplier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

const Pulsar = require('../../index');

const suppliers = {
sync: () => 'dummy-token',
async: async () => 'dummy-token',
};

module.exports = async (mode) => {
const supplier = suppliers[mode];
if (!supplier) {
throw new Error(`Unknown supplier: ${mode}`);
}

global.auth = new Pulsar.AuthenticationToken({ token: supplier });
const client = new Pulsar.Client({
serviceUrl: 'pulsar://localhost:6650',
authentication: global.auth,
});
await client.close();
};
Loading