Repository: mantoni/eslint_d.js
Pull request #318 (2024-09-26 19:33:12)

feat: launch daemon on forward failure
Based on #317 

In scope of this PR:
- Launch daemon on **first** ECONNREFUSED while executing lint command


Patch:
diff --git a/lib/forwarder.js b/lib/forwarder.js
index 8ec395e..660dd86 100644
--- a/lib/forwarder.js
+++ b/lib/forwarder.js
@@ -2,6 +2,7 @@ import net from 'node:net';
 import supportsColor from 'supports-color';
 import { removeConfig } from '../lib/config.js';
 import { LINT_COMMAND, PING_COMMAND } from './commands.js';
+import { launchDaemon } from './launcher.js';
 
 /**
  * @import { Config } from './config.js'
@@ -70,14 +71,9 @@ export async function forwardToDaemon(resolver, config) {
 
   let socket;
   try {
-    socket = await connectToDaemon(config);
-  } catch (/** @type {any} */ err) {
-    if (err['code'] === 'ECONNREFUSED') {
-      console.error(`eslint_d: ${err} - removing config`);
-      await removeConfig(resolver);
-    } else {
-      console.error(`eslint_d: ${err}`);
-    }
+    socket = await createSocket(true);
+  } catch (err) {
+    console.error(`eslint_d: ${err}`);
     // eslint-disable-next-line require-atomic-updates
     process.exitCode = 1;
     return;
@@ -140,6 +136,34 @@ export async function forwardToDaemon(resolver, config) {
       process.exitCode = 1;
     });
 
+  /**
+   * @param {boolean} retry
+   * @returns {Promise<net.Socket>}
+   */
+  async function createSocket(retry) {
+    try {
+      return await connectToDaemon(config);
+    } catch (/** @type {any} */ err) {
+      if (err['code'] === 'ECONNREFUSED' && retry) {
+        await removeConfig(resolver);
+        // @ts-expect-error we check for nullability in the line below
+        // eslint-disable-next-line require-atomic-updates
+        config = await launchDaemon(resolver, config.hash);
+        if (!config) {
+          throw new Error('unable to start daemon');
+        }
+        return createSocket(false);
+      }
+
+      if (err['code'] === 'ECONNREFUSED') {
+        await removeConfig(resolver);
+        throw new Error(`${err.message} - removing config`);
+      }
+
+      throw err;
+    }
+  }
+
   /**
    * @returns {string}
    */


Test patch:
diff --git a/lib/forwarder.test.js b/lib/forwarder.test.js
index 0819cb3..7f6b5c2 100644
--- a/lib/forwarder.test.js
+++ b/lib/forwarder.test.js
@@ -1,6 +1,9 @@
 import net from 'node:net';
+import child_process from 'node:child_process';
+import EventEmitter from 'node:events';
 import { PassThrough } from 'node:stream';
-import fs from 'node:fs/promises';
+import fs from 'node:fs';
+import fs_promises from 'node:fs/promises';
 import supportsColor from 'supports-color';
 import { assert, refute, match, sinon } from '@sinonjs/referee-sinon';
 import { forwardToDaemon, isAlive } from './forwarder.js';
@@ -17,10 +20,20 @@ describe('lib/forwarder', () => {
   let socket;
   let argv;
 
+  let child;
+  let read_file_promise;
+
   function returnThis() {
     // @ts-ignore
     return this;
   }
+  let watcher;
+
+  beforeEach(() => {
+    watcher = new EventEmitter();
+    watcher['close'] = sinon.fake();
+    sinon.replace(fs, 'watch', sinon.fake.returns(watcher));
+  });
 
   function fakeStdin(text) {
     const stdin = new PassThrough();
@@ -39,6 +52,17 @@ describe('lib/forwarder', () => {
     sinon.replace(process, 'argv', argv);
   });
 
+  beforeEach(() => {
+    read_file_promise = sinon.promise();
+    sinon.replace(
+      fs_promises,
+      'readFile',
+      sinon.fake.returns(read_file_promise)
+    );
+    child = { unref: sinon.fake() };
+    sinon.replace(child_process, 'spawn', sinon.fake.returns(child));
+  });
+
   context('isAlive', () => {
     it('connects to 127.0.0.1 on port from config', () => {
       isAlive(config);
@@ -148,6 +172,46 @@ describe('lib/forwarder', () => {
       assert.calledWith(process.stdout.write, ' daemon');
     });
 
+    it('launch daemon on first connection refused', async () => {
+      const chunks = ['response ', 'from daemon'];
+      sinon.replace(
+        socket,
+        'read',
+        sinon.fake(() => (chunks.length ? chunks.shift() : null))
+      );
+      sinon.replace(process.stdout, 'write', sinon.fake());
+      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());
+
+      forwardToDaemon(resolver, config);
+      const err = new Error('Connection refused');
+      err['code'] = 'ECONNREFUSED';
+      socket.on.getCall(1).callback(err); // connectionAttemptFailed
+      await new Promise(setImmediate);
+
+      // removeConfig
+      assert.calledOnceWith(fs_promises.unlink, `${resolver.base}/.eslint_d`);
+      await new Promise(setImmediate);
+      // spawn
+      assert.calledOnce(child_process.spawn);
+      // watch config
+      watcher.emit('change', 'rename', '.eslint_d');
+      await new Promise(setImmediate);
+      // read config
+      read_file_promise.resolve(JSON.stringify(config));
+      await new Promise(setImmediate);
+
+      assert.calledTwice(net.connect);
+      socket.on.getCall(2).callback(); // connect
+      await new Promise(setImmediate);
+      socket.on.getCall(4).callback(); // readable
+      socket.on.getCall(5).callback(); // end
+
+      assert.calledThrice(process.stdout.write);
+      assert.calledWith(process.stdout.write, 're');
+      assert.calledWith(process.stdout.write, 'sponse from');
+      assert.calledWith(process.stdout.write, ' daemon');
+    });
+
     it('handles "EXIT000" from response', async () => {
       const chunks = ['response from daemonEXIT000'];
       sinon.replace(
@@ -251,7 +315,7 @@ describe('lib/forwarder', () => {
     });
 
     it('logs error from stream', async () => {
-      sinon.replace(fs, 'unlink', sinon.fake.resolves());
+      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());
 
       forwardToDaemon(resolver, config);
       socket.on.getCall(0).callback(); // connect
@@ -260,11 +324,11 @@ describe('lib/forwarder', () => {
 
       assert.calledOnceWith(console.error, 'eslint_d: Error: Ouch!');
       assert.equals(process.exitCode, 1);
-      refute.called(fs.unlink);
+      refute.called(fs_promises.unlink);
     });
 
-    it('logs ECONNREFUSED error from stream and removes config', async () => {
-      sinon.replace(fs, 'unlink', sinon.fake.resolves());
+    it('logs error on second ECONNREFUSED', async () => {
+      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());
 
       forwardToDaemon(resolver, config);
       const err = new Error('Connection refused');
@@ -272,12 +336,28 @@ describe('lib/forwarder', () => {
       socket.on.getCall(1).callback(err); // connectionAttemptFailed
       await new Promise(setImmediate);
 
+      // removeConfig
+      assert.calledOnceWith(fs_promises.unlink, `${resolver.base}/.eslint_d`);
+      await new Promise(setImmediate);
+      // spawn
+      assert.calledOnce(child_process.spawn);
+      // watch config
+      watcher.emit('change', 'rename', '.eslint_d');
+      await new Promise(setImmediate);
+      // read config
+      read_file_promise.resolve(JSON.stringify(config));
+      await new Promise(setImmediate);
+
+      assert.calledTwice(net.connect);
+      socket.on.getCall(3).callback(err); // connectionAttemptFailed
+      await new Promise(setImmediate);
+
       assert.calledOnceWith(
         console.error,
         'eslint_d: Error: Connection refused - removing config'
       );
       assert.equals(process.exitCode, 1);
-      assert.calledOnceWith(fs.unlink, `${resolver.base}/.eslint_d`);
+      assert.calledTwice(fs_promises.unlink);
     });
 
     context('--fix-to-stdout', () => {