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..4bf3f01 --- /dev/null +++ b/test/unicode-test.js @@ -0,0 +1,143 @@ +'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; + // Give server a moment to be ready for connections + setTimeout(done, 50); + }); + }); + + afterEach((done) => { + if (srv) { + // First try to gracefully close + srv.close((_err) => { + srv = null; + // 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(); + } + }); + + /*eslint require-await: 0*/ + async function makeRequest(text) { + const json = { + cwd: '/test', + args: ['--test'], + text + }; + + 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 = await new Promise((resolve, reject) => { + const conn = net.connect({ port: serverPort, host: '127.0.0.1' }); + conn.setEncoding('utf8'); + + const timeout = setTimeout(() => { + conn.removeAllListeners(); + conn.destroy(); + reject(new Error('Connection timeout')); + }, 1000); + + conn.on('connect', () => { + clearTimeout(timeout); + resolve(conn); + }); + + conn.on('error', (err) => { + clearTimeout(timeout); + conn.removeAllListeners(); + reject(err); + }); + }); + 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) => { + const timeout = setTimeout(() => { + client.removeAllListeners(); // Remove all listeners to prevent memory leaks + client.destroy(); + reject(new Error('Request timeout')); + }, 4000); + + client.on('data', (chunk) => { + response += chunk; + }); + + client.on('error', (err) => { + clearTimeout(timeout); + client.removeAllListeners(); + reject(err); + }); + + client.on('end', () => { + clearTimeout(timeout); + client.removeAllListeners(); + 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'); + }); +}); +