From 748a1626c440c7ec9f5ade7bc8352530f62c1b01 Mon Sep 17 00:00:00 2001 From: Alex Efros Date: Sat, 2 Aug 2025 18:28:09 +0300 Subject: [PATCH 1/4] fix: UTF8 support --- lib/client.js | 2 ++ lib/server.js | 1 + test/client-test.js | 1 + test/server-test.js | 1 + test/unicode-test.js | 71 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 test/unicode-test.js diff --git a/lib/client.js b/lib/client.js index 014a8e7..241ab9d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -20,6 +20,7 @@ function sendCommand(command, callback) { } return; } + socket.setEncoding('utf8'); socket.on('data', write); socket.end(`${token} ${command}`, () => { if (typeof callback === 'function') { @@ -56,6 +57,7 @@ function invoke(socket, token, args, text) { args = ['--no-color'].concat(args); } + socket.setEncoding('utf8'); let buf = ''; socket.on('data', (chunk) => { buf += chunk; diff --git a/lib/server.js b/lib/server.js index 47b2e48..4d72619 100644 --- a/lib/server.js +++ b/lib/server.js @@ -47,6 +47,7 @@ exports.start = function () { const server = net.createServer({ allowHalfOpen: true }, (con) => { + con.setEncoding('utf8'); let data = ''; con.on('data', (chunk) => { data += chunk; diff --git a/test/client-test.js b/test/client-test.js index 097b81d..7850e3f 100644 --- a/test/client-test.js +++ b/test/client-test.js @@ -29,6 +29,7 @@ describe('client', () => { beforeEach(() => { socket = new EventEmitter(); socket.end = sinon.fake(); + socket.setEncoding = sinon.fake(); sinon.replace(out, 'write', sinon.fake()); sinon.replace(out, 'writeError', sinon.fake()); }); diff --git a/test/server-test.js b/test/server-test.js index 442044f..4b74318 100644 --- a/test/server-test.js +++ b/test/server-test.js @@ -13,6 +13,7 @@ function createConnection() { const connection = new EventEmitter(); connection.write = sinon.fake(); connection.end = sinon.fake(); + connection.setEncoding = sinon.fake(); return connection; } diff --git a/test/unicode-test.js b/test/unicode-test.js new file mode 100644 index 0000000..ae5a9ca --- /dev/null +++ b/test/unicode-test.js @@ -0,0 +1,71 @@ +'use strict'; + +const net = require('net'); +const crypto = require('crypto'); +const { assert, refute, sinon } = require('@sinonjs/referee-sinon'); +const server = require('../lib/server'); +const portfile = require('../lib/portfile'); +const service = require('./fixture/service'); + +describe('unicode handling', () => { + let srv; + let port; + let token; + + beforeEach((done) => { + token = crypto.randomBytes(8).toString('hex'); + sinon.restore(); + sinon.replace(crypto, 'randomBytes', () => Buffer.from(token, 'hex')); + sinon.replace(service, 'invoke', (_, __, text, cb) => cb(null, text)); + sinon.replace(portfile, 'write', () => {}); + srv = server.start(); + srv.listen(0, '127.0.0.1', () => { + port = srv.address().port; + // Server started + done(); + }); + }); + + afterEach(() => { + srv.close(); + }); + + /*eslint require-await: 0*/ + async function makeRequest(text) { + const json = { + cwd: '/test', + args: ['--test'], + text + }; + const client = net.connect({ port }); + client.setEncoding('utf8'); + let response = ''; + + return new Promise((resolve, reject) => { + client.on('data', (chunk) => { + response += chunk; + }); + client.on('error', reject); + client.on('end', () => resolve(response)); + client.end(`${token} ${JSON.stringify(json)}`); + }); + } + + // This test demonstrates a bug when sending large amount of UTF-8 text + // (around 100 KB) through the server. The text gets corrupted due to + // incorrect handling of partial UTF-8 characters, and the corruption + // manifests itself as UTF-8 replacement characters (U+FFFD). + it('should not corrupt large UTF-8 data', async function() { + this.timeout(5000); + + const alphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя'; + const times = Math.ceil(100000 / alphabet.length); + const text = alphabet.repeat(times); + const response = await makeRequest(text); + + // Before the fix this assertion would fail: + refute(response.includes('\ufffd'), 'Server corrupted UTF-8 data'); + assert.equals(response, text, 'Server returned wrong data'); + }); +}); + From a8a9c446845f9d6dd7761c617387054f312397e0 Mon Sep 17 00:00:00 2001 From: Alex Efros Date: Sat, 2 Aug 2025 21:09:41 +0300 Subject: [PATCH 2/4] fix: race in test --- test/unicode-test.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/unicode-test.js b/test/unicode-test.js index ae5a9ca..861f5f2 100644 --- a/test/unicode-test.js +++ b/test/unicode-test.js @@ -37,8 +37,22 @@ describe('unicode handling', () => { args: ['--test'], text }; - const client = net.connect({ port }); - client.setEncoding('utf8'); + + async function tryConnect(attempts = 5, delay = 100) { + for (let i = 0; i < attempts; i++) { + try { + const client = net.connect({ port }); + client.setEncoding('utf8'); + return client; + } catch (err) { + if (i === attempts - 1) { throw err; } + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + return null; // Satisfies consistent-return + } + + const client = await tryConnect(); let response = ''; return new Promise((resolve, reject) => { From 3e229ed7084905d7b952e46ce8d645d41dd371cb Mon Sep 17 00:00:00 2001 From: Alex Efros Date: Sun, 3 Aug 2025 10:41:33 +0300 Subject: [PATCH 3/4] fix: race in test --- test/unicode-test.js | 57 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/test/unicode-test.js b/test/unicode-test.js index 861f5f2..03d9d4f 100644 --- a/test/unicode-test.js +++ b/test/unicode-test.js @@ -21,13 +21,20 @@ describe('unicode handling', () => { srv = server.start(); srv.listen(0, '127.0.0.1', () => { port = srv.address().port; - // Server started - done(); + // Give server a moment to be ready for connections + setTimeout(done, 50); }); }); - afterEach(() => { - srv.close(); + afterEach((done) => { + if (srv) { + srv.close(() => { + srv = null; + done(); + }); + } else { + done(); + } }); /*eslint require-await: 0*/ @@ -38,11 +45,29 @@ describe('unicode handling', () => { text }; - async function tryConnect(attempts = 5, delay = 100) { + async function tryConnect(attempts = 10, delay = 200) { + const serverPort = port; // Capture port value to avoid ESLint no-loop-func warning for (let i = 0; i < attempts; i++) { try { - const client = net.connect({ port }); - client.setEncoding('utf8'); + const client = await new Promise((resolve, reject) => { + const conn = net.connect({ port: serverPort, host: '127.0.0.1' }); + conn.setEncoding('utf8'); + + const timeout = setTimeout(() => { + conn.destroy(); + reject(new Error('Connection timeout')); + }, 1000); + + conn.on('connect', () => { + clearTimeout(timeout); + resolve(conn); + }); + + conn.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); return client; } catch (err) { if (i === attempts - 1) { throw err; } @@ -56,11 +81,25 @@ describe('unicode handling', () => { let response = ''; return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + client.destroy(); + reject(new Error('Request timeout')); + }, 4000); + client.on('data', (chunk) => { response += chunk; }); - client.on('error', reject); - client.on('end', () => resolve(response)); + + client.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + client.on('end', () => { + clearTimeout(timeout); + resolve(response); + }); + client.end(`${token} ${JSON.stringify(json)}`); }); } From 1cebf9c024e1a8d0fa68e2b4fcb7426cddbcf00d Mon Sep 17 00:00:00 2001 From: Alex Efros Date: Sun, 3 Aug 2025 10:46:47 +0300 Subject: [PATCH 4/4] fix: possible hand in test --- test/unicode-test.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/unicode-test.js b/test/unicode-test.js index 03d9d4f..4bf3f01 100644 --- a/test/unicode-test.js +++ b/test/unicode-test.js @@ -28,9 +28,23 @@ describe('unicode handling', () => { afterEach((done) => { if (srv) { - srv.close(() => { + // First try to gracefully close + srv.close((_err) => { srv = null; - done(); + // Give extra time for cleanup to prevent hanging processes + setTimeout(done, 100); + }); + + // Force close after timeout if needed + const forceTimeout = setTimeout(() => { + if (srv) { + srv.unref(); // Allow process to exit even if server is still running + srv = null; + } + }, 1000); + + srv.on('close', () => { + clearTimeout(forceTimeout); }); } else { done(); @@ -54,6 +68,7 @@ describe('unicode handling', () => { conn.setEncoding('utf8'); const timeout = setTimeout(() => { + conn.removeAllListeners(); conn.destroy(); reject(new Error('Connection timeout')); }, 1000); @@ -65,6 +80,7 @@ describe('unicode handling', () => { conn.on('error', (err) => { clearTimeout(timeout); + conn.removeAllListeners(); reject(err); }); }); @@ -82,6 +98,7 @@ describe('unicode handling', () => { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { + client.removeAllListeners(); // Remove all listeners to prevent memory leaks client.destroy(); reject(new Error('Request timeout')); }, 4000); @@ -92,11 +109,13 @@ describe('unicode handling', () => { client.on('error', (err) => { clearTimeout(timeout); + client.removeAllListeners(); reject(err); }); client.on('end', () => { clearTimeout(timeout); + client.removeAllListeners(); resolve(response); });