Repository: mantoni/eslint_d.js (js)

Issue:
Debug mode
I sometimes have issues with eslint_d. To understand what is happening, I'm adding some log instructions to the code. It would be nice if eslint_d had a debug mode builtin. This can be done by writing log to stderr if that doesn't conflict with the client/daemon communication. Otherwise, this can be done by writing log to a temporary file.

Pull request description:
Add support for `--debug`
This uses the `debug` module that ships with `eslint` to print debug output to a separate terminal.

Usage notes are in the second commit.

Fixes #322

Patch:
diff --git a/README.md b/README.md
index ab6dd43..24c0243 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,26 @@ The server is also automatically restarted if one of the following files
 changed: `package.json`, `package-lock.json`, `npm-shrinkwrap.json`,
 `yarn.lock`, `pnpm-lock.yaml`.
 
+## Debug mode
+
+> Added in `v14.2.0`.
+
+Pass the `--debug` flag to `eslint_d` to enable debug output. Use the `DEBUG`
+environment variable to limit debug output to `eslint_d`:
+
+```bash
+❯ DEBUG="eslint_d:*"
+```
+
+For server side debug output, restart with `--debug` in a separate terminal:
+
+```bash
+❯ eslint_d restart --debug # eslint and eslint_d logs
+❯ DEBUG="eslint_d:*" eslint_d restart --debug # eslint_d logs only
+```
+
+This will keep the process attached to the terminal and print debug output.
+
 ## Compatibility
 
 - `14.0.0`: eslint 4 - 9, node 18 - 22 (ships with eslint 9) (see [^1])
diff --git a/bin/eslint_d.js b/bin/eslint_d.js
index a000910..8755bf0 100755
--- a/bin/eslint_d.js
+++ b/bin/eslint_d.js
@@ -1,11 +1,19 @@
 #!/usr/bin/env node
 
+import debug from 'debug';
 import { loadConfig, removeConfig } from '../lib/config.js';
 import { createResolver } from '../lib/resolver.js';
 import { forwardToDaemon, isAlive } from '../lib/forwarder.js';
 import { launchDaemon, stopDaemon } from '../lib/launcher.js';
 import { filesHash } from '../lib/hash.js';
 
+const is_debug_mode = process.argv.includes('--debug');
+if (is_debug_mode) {
+  debug.enable(
+    process.env.DEBUG || 'eslint_d:*,eslint:*,-eslint:code-path,eslintrc:*'
+  );
+}
+
 const command = process.argv[2];
 
 (async () => {
@@ -40,7 +48,7 @@ const command = process.argv[2];
         await removeConfig(resolver);
       }
 
-      await launchDaemon(resolver, hash);
+      await launchDaemon(resolver, hash, is_debug_mode);
 
       return;
     case 'stop':
@@ -54,7 +62,7 @@ const command = process.argv[2];
       if (config) {
         await stopDaemon(resolver, config);
       }
-      await launchDaemon(resolver, hash);
+      await launchDaemon(resolver, hash, is_debug_mode);
       return;
     case 'status':
       (await import('../lib/status.js')).status(resolver, config);
@@ -65,7 +73,7 @@ const command = process.argv[2];
         config = null;
       }
       if (!config) {
-        config = await launchDaemon(resolver, hash);
+        config = await launchDaemon(resolver, hash, false);
         if (!config) {
           return;
         }
diff --git a/lib/config.js b/lib/config.js
index 0e1528d..c0b8328 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -1,5 +1,8 @@
+import debug from 'debug';
 import fs from 'node:fs/promises';
 
+const log = debug('eslint_d:config');
+
 /**
  * @import { Resolver} from './resolver.js'
  */
@@ -18,6 +21,8 @@ import fs from 'node:fs/promises';
  */
 export async function loadConfig(resolver) {
   const filename = configFile(resolver);
+  log('Reading config from %s', filename);
+
   try {
     let raw = await fs.readFile(filename, 'utf8');
     if (!raw) {
@@ -27,6 +32,7 @@ export async function loadConfig(resolver) {
     const [token, port, pid, hash] = raw.split(' ');
     return { token, port: Number(port), pid: Number(pid), hash };
   } catch {
+    log('Config not found');
     return null;
   }
 }
@@ -36,16 +42,21 @@ export async function loadConfig(resolver) {
  * @param {Config} config
  */
 export async function writeConfig(resolver, config) {
+  const filename = configFile(resolver);
+  log('Writing config to %s', filename);
+
   const { token, port, pid, hash } = config;
-  await fs.writeFile(configFile(resolver), `${token} ${port} ${pid} ${hash}`);
+  await fs.writeFile(filename, `${token} ${port} ${pid} ${hash}`);
 }
 
 /**
  * @param {Resolver} resolver
  */
 export async function removeConfig(resolver) {
+  const filename = configFile(resolver);
+  log('Removing config at %s', filename);
   try {
-    await fs.unlink(configFile(resolver));
+    await fs.unlink(filename);
   } catch {
     // ignore
   }
diff --git a/lib/daemon.js b/lib/daemon.js
index 8d84f91..33697a1 100644
--- a/lib/daemon.js
+++ b/lib/daemon.js
@@ -3,6 +3,7 @@ import net from 'node:net';
 import path from 'node:path';
 import crypto from 'node:crypto';
 import { createRequire } from 'node:module';
+import debug from 'debug';
 import { configFile, writeConfig, removeConfig } from './config.js';
 import { createService } from './service.js';
 
@@ -18,6 +19,8 @@ const resolver = {
 };
 const hash = argv[3];
 
+const log = debug('eslint_d:daemon');
+
 const token = crypto.randomBytes(8).toString('hex');
 
 let ppid_interval = null;
@@ -27,29 +30,37 @@ let watcher = null;
 
 let service = createService(resolver, token, shutdown);
 if (idle) {
+  log('Using idle %d', idle);
   service = watchConnection(service, idle * 60000);
 }
-const server = net.createServer({ allowHalfOpen: true }, service);
 
+const server = net.createServer({ allowHalfOpen: true }, service);
 server.listen(0, '127.0.0.1', () => {
   const port = server.address()?.['port'];
+  log('Listening on port %d', port);
   writeConfig(resolver, { token, port, pid: process.pid, hash })
     .then(() => {
       watchConfig();
     })
     .catch((err) => {
+      log('Error writing config: %o', err);
       console.error(`eslint_d: ${err}`);
       shutdown();
     });
 });
 
-process.on('SIGTERM', shutdown);
+process.on('SIGTERM', () => {
+  log('Shutting down due to SIGTERM');
+  shutdown();
+});
 
 if (ppid) {
+  log('Using ppid %d', ppid);
   ppid_interval = setInterval(() => {
     try {
       process.kill(ppid, 0);
     } catch {
+      log('Shutting down due to parent exit');
       shutdown();
     }
   }, 1000);
@@ -61,16 +72,24 @@ fs.readFile(project_pkg, 'utf8', (err, data) => {
     try {
       const { name } = JSON.parse(data);
       if (name) {
-        process.title = `eslint_d - ${name === 'eslint_d' ? 'global' : name}`;
+        setProcessTitle(`eslint_d - ${name === 'eslint_d' ? 'global' : name}`);
         return;
       }
     } catch {
       // Fall through
     }
   }
-  process.title = `eslint_d - ${process.cwd()}`;
+  setProcessTitle(`eslint_d - ${process.cwd()}`);
 });
 
+/**
+ * @param {string} title
+ */
+function setProcessTitle(title) {
+  log('Setting process title to "%s"', process.title);
+  process.title = title;
+}
+
 function shutdown() {
   if (idle_timeout) {
     clearTimeout(idle_timeout);
@@ -84,12 +103,16 @@ function shutdown() {
     watcher.close();
     watcher = null;
   }
-  server.close(() =>
+  server.close((err) => {
+    if (err) {
+      log('Error closing server: %o', err);
+    }
     removeConfig(resolver).then(() => {
+      log('Shutdown complete');
       // eslint-disable-next-line n/no-process-exit
       process.exit(0);
-    })
-  );
+    });
+  });
 }
 
 function watchConfig() {
@@ -97,10 +120,12 @@ function watchConfig() {
     .watch(configFile(resolver), { persistent: false })
     .on('change', (type) => {
       if (type === 'rename') {
+        log('Shutting down due to config removal');
         shutdown();
       }
     })
     .on('error', (err) => {
+      log('Error watching config: %o', err);
       console.error(`eslint_d: ${err}`);
       shutdown();
     });
diff --git a/lib/forwarder.js b/lib/forwarder.js
index 9d00ac2..74ace54 100644
--- a/lib/forwarder.js
+++ b/lib/forwarder.js
@@ -1,3 +1,4 @@
+import debug from 'debug';
 import net from 'node:net';
 import supportsColor from 'supports-color';
 import { removeConfig } from '../lib/config.js';
@@ -12,6 +13,8 @@ import { launchDaemon } from './launcher.js';
 const EXIT_TOKEN_REGEXP = new RegExp(/EXIT([0-9]{3})/);
 const EXIT_TOKEN_LENGTH = 7;
 
+const log = debug('eslint_d:forwarder');
+
 /**
  * @param {Config | null} config
  * @returns {Promise<boolean>}
@@ -29,15 +32,16 @@ export function isAlive(config) {
  */
 export async function forwardCommandToDaemon(config, command) {
   if (!config) {
-    return Promise.reject(new Error('config not found'));
+    return Promise.reject(new Error('Config not found'));
   }
 
   const socket = await connectToDaemon(config);
+  log('Request: %o', { token: config.token, command });
   socket.write(JSON.stringify([config.token, command]));
   socket.end();
 
   return new Promise((resolve, reject) => {
-    socket.on('end', () => resolve()).on('error', (err) => reject(err));
+    socket.on('end', resolve).on('error', reject);
   });
 }
 
@@ -79,15 +83,28 @@ export async function forwardToDaemon(resolver, config) {
     return;
   }
 
+  const color_level = stdout ? stdout.level : 0;
+  const cwd = process.cwd();
+  const DEBUG = process.env.DEBUG || '';
+  log('Request: %o', {
+    token: config.token,
+    command: LINT_COMMAND,
+    color_level,
+    cwd,
+    args: eslint_args,
+    DEBUG
+  });
   const args = [
     config.token,
     LINT_COMMAND,
-    stdout ? stdout.level : 0,
-    process.cwd(),
-    eslint_args
+    color_level,
+    cwd,
+    eslint_args,
+    DEBUG
   ];
   socket.write(JSON.stringify(args));
   if (text) {
+    log('Request stdin: %d', text.length);
     socket.write('\n');
     socket.write(text);
   }
@@ -116,7 +133,9 @@ export async function forwardToDaemon(resolver, config) {
       }
 
       if (!fix_to_stdout) {
-        process.exitCode = Number(match[1]);
+        const exit_code = Number(match[1]);
+        log('Exit %d', exit_code);
+        process.exitCode = exit_code;
         return;
       }
 
@@ -124,6 +143,7 @@ export async function forwardToDaemon(resolver, config) {
         const { output } = JSON.parse(flushMessage())[0];
         process.stdout.write(output || text);
         // Exit code from eslint is deliberately ignored in this case
+        log('Exit %d', 0);
         process.exitCode = 0;
       } catch (err) {
         process.stdout.write(text);
@@ -144,13 +164,15 @@ export async function forwardToDaemon(resolver, config) {
     try {
       return await connectToDaemon(config);
     } catch (/** @type {any} */ err) {
+      log('Failed to connect %o', 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');
+          throw new Error('Unable to start daemon');
         }
         return createSocket(false);
       }
@@ -182,6 +204,7 @@ export async function forwardToDaemon(resolver, config) {
  * @returns {Promise<net.Socket>}
  */
 function connectToDaemon(config) {
+  log('Connecting to daemon 127.0.0.1:%d', config.port);
   return new Promise((resolve, reject) => {
     const socket = net.connect(config.port, '127.0.0.1');
     socket
diff --git a/lib/help.js b/lib/help.js
index 2a5a2f2..4ec8ff6 100644
--- a/lib/help.js
+++ b/lib/help.js
@@ -7,9 +7,9 @@ All arguments are passed to eslint, except for the following commands:
   stop            Stop the daemon
   restart         Restart the daemon
   status          Show daemon status, process id and resolved eslint version
-  --fix-to-stdout Print fixed file to stdout (requires --stdin)
   --help, -h      Show this help
   --version, -v   Show version number of eslint_d and bundled eslint
+  --fix-to-stdout Print fixed file to stdout (requires --stdin)
 
 Environment variables:
 
diff --git a/lib/launcher.js b/lib/launcher.js
index c7db4a2..08f2c05 100644
--- a/lib/launcher.js
+++ b/lib/launcher.js
@@ -1,6 +1,7 @@
 import fs from 'node:fs';
 import os from 'node:os';
 import child_process from 'node:child_process';
+import debug from 'debug';
 import { loadConfig, removeConfig } from './config.js';
 import { forwardCommandToDaemon } from './forwarder.js';
 import { SHUTDOWN_COMMAND } from './commands.js';
@@ -10,25 +11,45 @@ import { SHUTDOWN_COMMAND } from './commands.js';
  * @import { Resolver } from './resolver.js'
  */
 
+const log = debug('eslint_d:launcher');
+
 /**
  * @param {Resolver} resolver
  * @param {string} hash
+ * @param {boolean} is_debug_mode
  * @returns {Promise<Config | null>}
  */
-export async function launchDaemon(resolver, hash) {
+export async function launchDaemon(resolver, hash, is_debug_mode) {
   let config = null;
   let error;
   try {
     const ppid = getPPID();
     const idle = getIDLE(ppid);
+    const base = resolver.base;
 
     const daemon = resolver.require.resolve('./daemon.js');
-    child_process
-      .spawn(process.argv0, [daemon, ppid, idle, resolver.base, hash], {
-        detached: true,
-        stdio: 'ignore'
-      })
-      .unref();
+
+    log('Launching daemon %o', { daemon, ppid, idle, base, hash });
+    const daemon_process = child_process.spawn(
+      process.argv0,
+      [daemon, ppid, idle, base, hash],
+      {
+        detached: !is_debug_mode,
+        stdio: is_debug_mode ? 'inherit' : 'ignore',
+        env: Object.assign({}, process.env, {
+          DEBUG: is_debug_mode ? process.env.DEBUG : '',
+          DEBUG_COLORS: 1
+        })
+      }
+    );
+    if (is_debug_mode) {
+      process.on('SIGINT', () => {
+        log('Process SIGINT');
+        removeConfig(resolver);
+      });
+    } else {
+      daemon_process.unref();
+    }
 
     await waitForConfig(resolver.base);
     config = await loadConfig(resolver);
@@ -48,6 +69,7 @@ export async function launchDaemon(resolver, hash) {
  * @param {Config} config
  */
 export async function stopDaemon(resolver, config) {
+  log('Stopping daemon %o', config);
   try {
     await Promise.all([
       waitForConfig(resolver.base),
diff --git a/lib/service.js b/lib/service.js
index 150aa24..b82b9dd 100644
--- a/lib/service.js
+++ b/lib/service.js
@@ -17,9 +17,18 @@ const stderr_write = process.stderr.write;
  */
 export function createService(resolver, token, shutdown) {
   const eslint = resolver.require(`${resolver.base}/lib/cli.js`);
-  const chalk = createRequire(resolver.base)('chalk');
+  const require = createRequire(resolver.base);
+  const chalk = require('chalk');
+  const debug = require('debug');
+  const log = debug('eslint_d:service');
+  const debug_global = process.env.DEBUG || '';
+  if (debug_global) {
+    debug.enable(debug_global);
+  }
+  let debug_last = debug_global;
 
   return (con) => {
+    log('New connection');
     let content = '';
     con
       .setEncoding('utf8')
@@ -31,6 +40,7 @@ export function createService(resolver, token, shutdown) {
       })
       .on('end', async () => {
         if (!content) {
+          log('Empty request');
           con.end();
           return;
         }
@@ -41,14 +51,48 @@ export function createService(resolver, token, shutdown) {
           text = content.slice(newline + 1);
           content = content.slice(0, newline);
         }
-        const [request_token, command, color_level, cwd, argv] =
+        const [request_token, command, color_level, cwd, argv, DEBUG] =
           JSON.parse(content);
+
+        const debug_invoke = DEBUG || debug_global;
+        if (debug_invoke !== debug_last) {
+          if (debug_invoke === '') {
+            debug.disable();
+          } else {
+            debug.enable(debug_invoke);
+          }
+          debug_last = debug_invoke;
+        }
+
+        if (!debug_global) {
+          process.stderr.write = (chunk) => con.write(chunk);
+        }
+
+        log('Request: %o', {
+          request_token,
+          command,
+          color_level,
+          cwd,
+          argv,
+          DEBUG
+        });
+        if (text) {
+          log('Request stdin: %d', text.length);
+        }
+
         if (request_token !== token) {
+          log('Invalid token');
+          process.stderr.write = stderr_write;
           con.end();
           return;
         }
         if (command !== LINT_COMMAND) {
-          onCommand(con, command);
+          log('Executing command %s', command);
+          try {
+            onCommand(con, command);
+          } finally {
+            process.stderr.write = stderr_write;
+          }
           return;
         }
 
@@ -56,13 +100,16 @@ export function createService(resolver, token, shutdown) {
         process.chdir(cwd);
 
         process.stdout.write = (chunk) => con.write(chunk);
-        process.stderr.write = (chunk) => con.write(chunk);
+
+        log('Executing eslint');
         let code = 1;
         try {
           code = await eslint.execute(argv, text, true);
         } catch (e) {
+          log('Error from eslint: %o', e);
           con.write(String(e));
         } finally {
+          log('Exit code from eslint: %d', code);
           /* eslint-disable require-atomic-updates */
           process.stdout.write = stdout_write;
           process.stderr.write = stderr_write;
@@ -70,7 +117,8 @@ export function createService(resolver, token, shutdown) {
           con.end(`EXIT${String(code).padStart(3, '0')}`);
         }
       })
-      .on('error', () => {
+      .on('error', (e) => {
+        log('Error from connection: %o', e);
         process.stdout.write = stdout_write;
         process.stderr.write = stderr_write;
       });
diff --git a/package-lock.json b/package-lock.json
index ccc5ebe..8d25c89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
       "version": "14.1.1",
       "license": "MIT",
       "dependencies": {
+        "debug": "^4.3.7",
         "eslint": "^9.8.0",
         "supports-color": "^9.4.0"
       },
@@ -985,12 +986,12 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.3.6",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
-      "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+      "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
       "license": "MIT",
       "dependencies": {
-        "ms": "2.1.2"
+        "ms": "^2.1.3"
       },
       "engines": {
         "node": ">=6.0"
@@ -3066,13 +3067,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/mocha/node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/mocha/node_modules/string-width": {
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3188,9 +3182,9 @@
       "license": "MIT"
     },
     "node_modules/ms": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
       "license": "MIT"
     },
     "node_modules/natural-compare": {
diff --git a/package.json b/package.json
index 5864907..9fc8269 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
     "ignore": "node_modules/**"
   },
   "dependencies": {
+    "debug": "^4.3.7",
     "eslint": "^9.8.0",
     "supports-color": "^9.4.0"
   },
@@ -54,6 +55,9 @@
     "prettier": "^3.3.3",
     "typescript": "^5.5.4"
   },
+  "overrides": {
+    "debug": "$debug"
+  },
   "files": [
     "bin",
     "lib",


Test patch:
diff --git a/lib/forwarder.test.js b/lib/forwarder.test.js
index 7f6b5c2..17c9591 100644
--- a/lib/forwarder.test.js
+++ b/lib/forwarder.test.js
@@ -112,7 +112,22 @@ describe('lib/forwarder', () => {
 
       assert.calledOnceWith(
         socket.write,
-        `["token","${LINT_COMMAND}",${color_level},"the/cwd",[${JSON.stringify(process.argv0)},"eslint_d"]]`
+        `["token","${LINT_COMMAND}",${color_level},"the/cwd",[${JSON.stringify(process.argv0)},"eslint_d"],""]`
+      );
+      assert.calledOnce(socket.end);
+    });
+
+    it('writes DEBUG env to socket', async () => {
+      sinon.replace(process, 'cwd', sinon.fake.returns('the/cwd'));
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      forwardToDaemon(resolver, config);
+      socket.on.getCall(0).callback(); // connect
+      await new Promise(setImmediate);
+
+      assert.calledOnceWith(
+        socket.write,
+        `["token","${LINT_COMMAND}",${color_level},"the/cwd",[${JSON.stringify(process.argv0)},"eslint_d"],"eslint_d:*"]`
       );
       assert.calledOnce(socket.end);
     });
@@ -389,7 +404,7 @@ describe('lib/forwarder', () => {
         assert.calledThrice(socket.write);
         assert.calledWith(
           socket.write,
-          `["token","${LINT_COMMAND}",${color_level},"cwd",[${JSON.stringify(process.argv0)},"eslint_d","--stdin","--fix-dry-run","--format","json","--other","--options"]]`
+          `["token","${LINT_COMMAND}",${color_level},"cwd",[${JSON.stringify(process.argv0)},"eslint_d","--stdin","--fix-dry-run","--format","json","--other","--options"],""]`
         );
         assert.calledWith(socket.write, '\n');
         assert.calledWith(socket.write, 'text from stdin');
diff --git a/lib/launcher.test.js b/lib/launcher.test.js
index 129b810..6535ef8 100644
--- a/lib/launcher.test.js
+++ b/lib/launcher.test.js
@@ -4,7 +4,7 @@ import os from 'node:os';
 import net from 'node:net';
 import child_process from 'node:child_process';
 import EventEmitter from 'node:events';
-import { assert, refute, sinon } from '@sinonjs/referee-sinon';
+import { assert, refute, match, sinon } from '@sinonjs/referee-sinon';
 import { createResolver } from './resolver.js';
 import { launchDaemon, stopDaemon } from './launcher.js';
 import { strictEqual } from 'node:assert';
@@ -47,21 +47,31 @@ describe('lib/launcher', () => {
     });
 
     it('spawns with defaults and unrefs child', () => {
-      launchDaemon(resolver, 'hash');
+      sinon.replace(process, 'on', sinon.fake());
+
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledWith(
         child_process.spawn,
         process.argv0,
         [daemon, '0', '15', resolver.base, 'hash'],
-        { detached: true, stdio: 'ignore' }
+        {
+          detached: true,
+          stdio: 'ignore',
+          env: Object.assign({}, process.env, {
+            DEBUG: '',
+            DEBUG_COLORS: 1
+          })
+        }
       );
       assert.calledOnce(child.unref);
+      refute.calledWith(process.on, 'SIGINT');
     });
 
     it('spawns with ppid "auto" and idle "0"', () => {
       process.env.ESLINT_D_PPID = 'auto';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledWith(child_process.spawn, process.argv0, [
         daemon,
@@ -75,7 +85,7 @@ describe('lib/launcher', () => {
     it('spawns with specified ppid and idle "0"', () => {
       process.env.ESLINT_D_PPID = '12345';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledWith(child_process.spawn, process.argv0, [
         daemon,
@@ -89,7 +99,7 @@ describe('lib/launcher', () => {
     it('fails if ppid is not a number', () => {
       process.env.ESLINT_D_PPID = 'test';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledOnceWith(
         console.error,
@@ -101,7 +111,7 @@ describe('lib/launcher', () => {
     it('spawns with specified idle', () => {
       process.env.ESLINT_D_IDLE = '30';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledWith(child_process.spawn, process.argv0, [
         daemon,
@@ -116,7 +126,7 @@ describe('lib/launcher', () => {
       process.env.ESLINT_D_PPID = '12345';
       process.env.ESLINT_D_IDLE = '30';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledWith(child_process.spawn, process.argv0, [
         daemon,
@@ -127,10 +137,43 @@ describe('lib/launcher', () => {
       ]);
     });
 
+    it('spawns in debug mode and does not unref child', () => {
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      launchDaemon(resolver, 'hash', true);
+
+      assert.calledWith(
+        child_process.spawn,
+        process.argv0,
+        [daemon, '0', '15', resolver.base, 'hash'],
+        {
+          detached: false,
+          stdio: 'inherit',
+          env: Object.assign({}, process.env, {
+            DEBUG: 'eslint_d:*',
+            DEBUG_COLORS: 1
+          })
+        }
+      );
+      refute.called(child.unref);
+    });
+
+    it('removes config on SIGINT in debug mode', () => {
+      sinon.replace(fs_promises, 'unlink', sinon.fake.resolves());
+      sinon.replace(process, 'on', sinon.fake());
+
+      launchDaemon(resolver, 'hash', true);
+
+      assert.calledWith(process.on, 'SIGINT', match.func);
+      process.on['callback']();
+
+      assert.calledOnceWith(fs_promises.unlink, `${resolver.base}/.eslint_d`);
+    });
+
     it('fails if idle is not a number', () => {
       process.env.ESLINT_D_IDLE = 'test';
 
-      launchDaemon(resolver, 'hash');
+      launchDaemon(resolver, 'hash', false);
 
       assert.calledOnceWith(
         console.error,
@@ -140,7 +183,7 @@ describe('lib/launcher', () => {
     });
 
     it('waits for config file to appear and resolves', async () => {
-      const promise = launchDaemon(resolver, 'hash');
+      const promise = launchDaemon(resolver, 'hash', false);
 
       assert.calledOnceWith(fs.watch, resolver.base);
 
@@ -161,7 +204,7 @@ describe('lib/launcher', () => {
     });
 
     it('resolves with null and logs error from watcher', async () => {
-      const promise = launchDaemon(resolver, 'hash');
+      const promise = launchDaemon(resolver, 'hash', false);
       const error = new Error('watcher error');
 
       watcher.emit('error', error);
@@ -175,7 +218,7 @@ describe('lib/launcher', () => {
     });
 
     it('resolves with null and logs "No config"', async () => {
-      const promise = launchDaemon(resolver, 'hash');
+      const promise = launchDaemon(resolver, 'hash', false);
       const error = new Error('read error');
 
       watcher.emit('change', 'rename', '.eslint_d');
diff --git a/lib/service.test.js b/lib/service.test.js
index 381f35b..5bbd7f9 100644
--- a/lib/service.test.js
+++ b/lib/service.test.js
@@ -1,4 +1,5 @@
 import { Socket } from 'node:net';
+import debug from 'debug';
 import { assert, refute, sinon } from '@sinonjs/referee-sinon';
 import { createResolver } from './resolver.js';
 import { createService } from './service.js';
@@ -20,19 +21,25 @@ describe('lib/service', () => {
     let service;
     let con;
 
+    function connect() {
+      con = new Socket({ allowHalfOpen: true });
+      sinon.replace(con, 'write', sinon.fake());
+      sinon.replace(con, 'end', sinon.fake());
+      service(con);
+    }
+
     beforeEach(() => {
       eslint_promise = sinon.promise();
       shutdown_promise = sinon.promise();
       sinon.replace(eslint, 'execute', sinon.fake.returns(eslint_promise));
       sinon.replace(process, 'chdir', sinon.fake());
+      sinon.replace(debug, 'enable', sinon.fake());
+      sinon.replace(debug, 'disable', sinon.fake());
       service = createService(resolver, token, () =>
         shutdown_promise.resolve()
       );
       chalk.level = '-';
-      con = new Socket({ allowHalfOpen: true });
-      sinon.replace(con, 'write', sinon.fake());
-      sinon.replace(con, 'end', sinon.fake());
-      service(con);
+      connect();
     });
 
     afterEach(() => {
@@ -40,6 +47,20 @@ describe('lib/service', () => {
       assert.same(process.stderr.write, original_stderr_write);
     });
 
+    it('does not enable debug by default', () => {
+      refute.called(debug.enable);
+    });
+
+    it('enables debug with DEBUG environment variable', () => {
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      service = createService(resolver, token, () =>
+        shutdown_promise.resolve()
+      );
+
+      assert.calledOnceWith(debug.enable, 'eslint_d:*');
+    });
+
     /**
      * @param {string} request_token
      * @param {string} command
@@ -47,11 +68,20 @@ describe('lib/service', () => {
      * @param {string} cwd
      * @param {string[]} argv
      * @param {string} [text]
+     * @param {string} [DEBUG]
      */
-    function send(request_token, command, color_level, cwd, argv, text) {
+    function send(
+      request_token,
+      command,
+      color_level,
+      cwd,
+      argv,
+      text,
+      DEBUG = ''
+    ) {
       const chunks = [
         `["${request_token}","${command}",${color_level},`,
-        `${JSON.stringify(cwd)},${JSON.stringify(argv)}]`
+        `${JSON.stringify(cwd)},${JSON.stringify(argv)},"${DEBUG}"]`
       ];
       if (text !== undefined) {
         chunks.push(`\n${text}`);
@@ -162,5 +192,86 @@ describe('lib/service', () => {
 
       await shutdown_promise;
     });
+
+    it('does not enable or disable debug by default', async () => {
+      send(token, LINT_COMMAND, '3', '/', []);
+      await eslint_promise.resolve(0);
+
+      refute.called(debug.enable);
+      refute.called(debug.disable);
+    });
+
+    it('enables debug if DEBUG is passed', async () => {
+      send(token, LINT_COMMAND, '3', '/', [], undefined, 'eslint_d:*');
+      await eslint_promise.resolve(0);
+
+      assert.calledOnceWith(debug.enable, 'eslint_d:*');
+      refute.called(debug.disable);
+    });
+
+    it('disables debug if DEBUG is not passed on subsequent request', async () => {
+      send(token, LINT_COMMAND, '3', '/', [], undefined, 'eslint_d:*');
+      await eslint_promise.resolve(0);
+      debug.enable.resetHistory();
+
+      connect();
+      send(token, LINT_COMMAND, '3', '/', []);
+      await new Promise(setImmediate);
+
+      assert.calledOnce(debug.disable);
+      refute.called(debug.enable);
+    });
+
+    it('does not enable debug if DEBUG is passed and global debug is on', async () => {
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      service = createService(resolver, token, () =>
+        shutdown_promise.resolve()
+      );
+      debug.enable.resetHistory();
+
+      connect();
+      send(token, LINT_COMMAND, '3', '/', [], undefined, 'eslint_d:*');
+      await eslint_promise.resolve(0);
+
+      refute.called(debug.enable);
+      refute.called(debug.disable);
+    });
+
+    it('enables debug if DEBUG is passed and global debug is different', async () => {
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      service = createService(resolver, token, () =>
+        shutdown_promise.resolve()
+      );
+      debug.enable.resetHistory();
+
+      connect();
+      send(token, LINT_COMMAND, '3', '/', [], undefined, 'eslint:*');
+      await eslint_promise.resolve(0);
+
+      assert.calledOnceWith(debug.enable, 'eslint:*');
+      refute.called(debug.disable);
+    });
+
+    it('resets debug to global is not passed on subsequent request', async () => {
+      sinon.define(process.env, 'DEBUG', 'eslint_d:*');
+
+      service = createService(resolver, token, () =>
+        shutdown_promise.resolve()
+      );
+
+      connect();
+      send(token, LINT_COMMAND, '3', '/', [], undefined, 'eslint:*');
+      await eslint_promise.resolve(0);
+      debug.enable.resetHistory();
+
+      connect();
+      send(token, LINT_COMMAND, '3', '/', []);
+      await new Promise(setImmediate);
+
+      assert.calledOnceWith(debug.enable, 'eslint_d:*');
+      refute.called(debug.disable);
+    });
   });
 });