Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: test

on:
push:
branches: [master]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
65 changes: 62 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ElectrumClient extends Client {
};

this.timeLastCall = 0;
this.closed = false;
}

initElectrum(electrumConfig, persistencePolicy = { retryPeriod: 10000, maxRetry: 1000, pingPeriod: 120000, callback: null }) {
Expand All @@ -22,7 +23,7 @@ class ElectrumClient extends Client {

return new Promise((resolve, reject) => {
this.connect().then(() => {
this.server_version(this.electrumConfig.client, this.electrumConfig.version).then((versionInfo) => {
this.withHandshakeTimeout(this.server_version(this.electrumConfig.client, this.electrumConfig.version)).then((versionInfo) => {
this.versionInfo = versionInfo;

if (this.onConnectCallback != null) {
Expand All @@ -40,6 +41,31 @@ class ElectrumClient extends Client {
});
}

// A server that accepts the connection but never answers server.version would otherwise
// leave initElectrum waiting for ever: give up after connectTimeout and drop the connection.
withHandshakeTimeout(promise) {
if (!(this.connectTimeout > 0)) {
return promise;
}

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.conn.destroy();

reject(new Error('Timed out waiting for the server version'));
}, this.connectTimeout);

if (timer.unref) {
timer.unref();
}

promise.then(
result => { clearTimeout(timer); resolve(result); },
err => { clearTimeout(timer); reject(err); }
);
});
}

// Override parent
request(method, params) {
this.timeLastCall = new Date().getTime();
Expand Down Expand Up @@ -68,6 +94,10 @@ class ElectrumClient extends Client {
onClose() {
super.onClose();

if (this.closed) {
return;
}

const list = [
'server.peers.subscribe',
'blockchain.numblocks.subscribe',
Expand All @@ -86,7 +116,15 @@ class ElectrumClient extends Client {
this.onCloseCallback(this);
}

setTimeout(() => {
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer);
}

this.reconnectTimer = setTimeout(() => {
if (this.closed) {
return;
}

if (this.persistencePolicy != null && this.persistencePolicy.maxRetry > 0) {
this.reconnect().catch((err) => {
this.onError(err);
Expand All @@ -103,10 +141,19 @@ class ElectrumClient extends Client {
});
}
}, retryPeriod);

// a pending reconnect must not keep the process alive on its own
if (this.reconnectTimer.unref) {
this.reconnectTimer.unref();
}
}

// ElectrumX persistancy
keepAlive() {
if (this.closed) {
return;
}

if (this.timeout != null) {
clearTimeout(this.timeout);
}
Expand All @@ -123,19 +170,31 @@ class ElectrumClient extends Client {
});
}
}, pingPeriod);

if (this.timeout.unref) {
this.timeout.unref();
}
}

close() {
this.closed = true;

super.close();

if (this.timeout != null) {
clearTimeout(this.timeout);
}

this.reconnect = this.reconnect = this.onClose = this.keepAlive = () => {}; // dirty hack to make it stop reconnecting
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer);
}
}

reconnect() {
if (this.closed) {
return Promise.reject(new Error('Client is closed'));
}

this.log("Electrum attempting reconnect...");

this.initSocket();
Expand Down
30 changes: 14 additions & 16 deletions lib/TlsSocketWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,15 @@ class TlsSocketWrapper {
this._listeners[event] = this._listeners[event] || [];
let newListeners = [];

let found = false;
for (let savedListener of this._listeners[event]) {
if (savedListener == listener) {
// found our listener
found = true;
// we just skip it
} else {
if (savedListener != listener) {
// other listeners should go back to original array
newListeners.push(savedListener);
}
}

if (found) {
this._listeners[event] = newListeners;
} else {
// something went wrong, lets just cleanup all listeners
this._listeners[event] = [];
}
// removing a listener that is not there leaves the others alone
this._listeners[event] = newListeners;
}

connect(port, host, callback) {
Expand Down Expand Up @@ -96,6 +87,9 @@ class TlsSocketWrapper {
this._socket.on('connection', data => {
this._passOnEvent('connection', data);
});
this._socket.on('timeout', data => {
this._passOnEvent('timeout', data);
});
}

_passOnEvent(event, data) {
Expand All @@ -106,18 +100,22 @@ class TlsSocketWrapper {
}

emit(event, data) {
this._socket.emit(event, data);
if (this._socket) this._socket.emit(event, data);
}

end() {
this._socket.end();
if (this._socket) this._socket.end();
}

destroy() {
this._socket.destroy();
destroy(err) {
if (this._socket) this._socket.destroy(err);
}

write(data) {
if (!this._socket) {
throw new Error('Not connected');
}

this._socket.write(data);
}
}
Expand Down
64 changes: 54 additions & 10 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* */
let net = global.net;
let tls = global.tls;
const TIMEOUT = 5000;
const DEFAULT_CONNECT_TIMEOUT = 5000;

const TlsSocketWrapper = require('./TlsSocketWrapper.js');
const EventEmitter = require('events').EventEmitter;
Expand All @@ -30,6 +30,8 @@ class Client {
});
this._protocol = protocol; // saving defaults
this._options = options;
this.connectTimeout = (options && options.connectTimeout !== undefined) ? options.connectTimeout : DEFAULT_CONNECT_TIMEOUT;
this.requestTimeout = (options && options.requestTimeout) || 0; // 0: wait as long as it takes

this.onErrorCallback = (callbacks && callbacks.onError) ? callbacks.onError : null;

Expand All @@ -56,14 +58,17 @@ class Client {
throw new Error('unknown protocol');
}

this.conn.setTimeout(TIMEOUT);
this.conn.setTimeout(this.connectTimeout);
this.conn.setEncoding('utf8');
this.conn.setKeepAlive(true, 0);
this.conn.setNoDelay(true);
this.conn.on('connect', () => {
this.conn.setTimeout(0);
this.onConnect();
});
this.conn.on('timeout', () => {
// only armed until the connection is up (see connectSocket) or data arrives
this.conn.destroy(new Error('Connection timed out'));
});
this.conn.on('close', e => {
this.onClose(e);
});
Expand All @@ -88,11 +93,17 @@ class Client {
connectSocket(conn, port, host) {
return new Promise((resolve, reject) => {
const errorHandler = e => reject(e);

const closeHandler = () => reject(new Error('Connection closed before it was established'));

conn.on('error', errorHandler);
conn.on('close', closeHandler);

conn.connect(port, host, () => {
conn.removeListener('error', errorHandler);
conn.removeListener('close', closeHandler);

// connected (for TLS: the handshake is done), so stop the connect timeout
conn.setTimeout(0);

resolve();
});
Expand All @@ -106,6 +117,42 @@ class Client {
this.conn.end();
this.conn.destroy();
this.status = 0;

this.rejectPending(new Error('Connection closed'));
}

rejectPending(err) {
Object.keys(this.callback_message_queue).forEach(key => {
const callback = this.callback_message_queue[key];
delete this.callback_message_queue[key];

callback(err);
});
}

// Register `callback` for the response to request `id`. With a requestTimeout set, fail
// it (and forget it) when no response arrives in time.
registerCallback(id, callback) {
if (!(this.requestTimeout > 0)) {
this.callback_message_queue[id] = callback;
return;
}

const timer = setTimeout(() => {
delete this.callback_message_queue[id];

callback(new Error(`Request timed out after ${this.requestTimeout} ms`));
}, this.requestTimeout);

if (timer.unref) {
timer.unref();
}

this.callback_message_queue[id] = (err, result) => {
clearTimeout(timer);

callback(err, result);
};
}

request(method, params) {
Expand All @@ -115,7 +162,7 @@ class Client {
return new Promise((resolve, reject) => {
const id = ++this.id;
const content = util.makeRequest(method, params, id);
this.callback_message_queue[id] = util.createPromiseResult(resolve, reject);
this.registerCallback(id, util.createPromiseResult(resolve, reject));
this.conn.write(content + '\n');
});
}
Expand All @@ -137,7 +184,7 @@ class Client {
arguments_far_calls[id] = param;
}
const content = '[' + contents.join(',') + ']';
this.callback_message_queue[this.id] = util.createPromiseResultBatch(resolve, reject, arguments_far_calls);
this.registerCallback(this.id, util.createPromiseResultBatch(resolve, reject, arguments_far_calls));
// callback will exist only for max id
this.conn.write(content + '\n');
});
Expand Down Expand Up @@ -188,10 +235,7 @@ class Client {
onClose(e) {
this.status = 0;

Object.keys(this.callback_message_queue).forEach(key => {
this.callback_message_queue[key](new Error('close connect'));
delete this.callback_message_queue[key];
});
this.rejectPending(new Error('close connect'));
}

onRecv(chunk) {
Expand Down
31 changes: 22 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,43 @@
"description": "Electrum protocol client for React Native & Node.js",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test test/*.test.js"
},
"dependencies": {},
"devDependencies": {},
"repository": {
"type": "git",
"url": "git://github.com/janoside/electrum-client.git"
"url": "git+https://github.com/2rdzy/electrum-client.git"
},
"bugs": {
"url": "https://github.com/janoside/electrum-client/issues"
"url": "https://github.com/2rdzy/electrum-client/issues"
},
"keywords": [
"bitcoin",
"electrum",
"electrumx"
],
"engines": {
"node": ">=6"
"node": ">=18"
},
"contributors": [
{ "name": "Yuki Akiyama" },
{ "name": "7kharov" },
{ "name": "overtorment" },
{ "name": "janoside" }
{
"name": "Yuki Akiyama"
},
{
"name": "7kharov"
},
{
"name": "overtorment"
},
{
"name": "janoside"
}
],
"license": "MIT"
"license": "MIT",
"homepage": "https://github.com/2rdzy/electrum-client#readme",
"files": [
"index.js",
"lib"
]
}
Loading
Loading