thedotmack/claude-mem · warning

Port did not free up after shutdown

Error message

Port did not free up after shutdown

What it means

During worker shutdown (stop flow), after httpShutdown the code waits up to the platform-adjusted 15s for the port to become free. If the port is still occupied it logs this warning but continues: it removes the PID file only if it belongs to the just-stopped PID and exits 0, leaving the port possibly still held.

Solutions

  1. Find and kill the lingering process: lsof -i :<port> then kill -9 the PID.
  2. Wait a few seconds and retry the stop/start — the port usually frees once the process exits.
  3. Use the platform timeout env/tuning if 15s is too short for slow shutdowns on your platform.
  4. Check for orphaned child processes of the worker (chroma/uv) that keep the socket alive and terminate them.

Example fix

// before: blind restart
claude-mem stop && claude-mem start
// after: ensure the port is free first
claude-mem stop
lsof -ti :37777 | xargs -r kill -9
claude-mem start
Defensive patterns

Strategy: retry

Validate before calling

async function isPortFree(port) {
  return new Promise(res => {
    const s = require('net').createServer();
    s.once('error', () => res(false));
    s.once('listening', () => s.close(() => res(true)));
    s.listen(port);
  });
}

Try / catch

const freed = await waitForPortFree(port, 15000);
if (!freed) {
  const pids = execSync(`lsof -ti :${port}`).toString().trim();
  if (pids) process.kill(Number(pids.split('\n')[0]), 'SIGKILL');
}

Prevention

When it happens

Trigger: The stop command's waitForPortFree times out because the worker process (or a child) ignores SIGTERM and lingers, another process grabbed the port, or the socket sits in a lingering state.

Common situations: Unresponsive worker due to a hung request or stuck background thread; orphaned child process holding the listening socket; restarting too quickly so TIME_WAIT/old process still owns the port.

Related errors


AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17). Data as JSON: /api/errors/e951faa5ec23c281. Report an issue: GitHub.

Appendix: source

Thrown at src/services/worker-service.ts:1194

        exitWithStatus(
          'error',
          bootFailure ? `Failed to start worker: ${bootFailure}` : 'Failed to start worker'
        );
      } else {
        exitWithStatus('ready', result === 'warming' ? 'Worker started; still warming up' : undefined);
      }
      break;
    }

    case 'stop': {
      // Capture the dying worker's pid BEFORE shutdown so the PID-file
      // cleanup below can prove it deletes THAT worker's file (or a dead
      // pid's leftover) — never a live successor's (Phase 5).
      const stoppedPid = await getCurrentWorkerPid(port, 2000);
      await httpShutdown(port);
      const freed = await waitForPortFree(port, getPlatformTimeout(15000));
      if (!freed) {
        logger.warn('SYSTEM', 'Port did not free up after shutdown', { port });
      }
      removePidFileIfOwner(stoppedPid);
      logger.info('SYSTEM', 'Worker stopped successfully');
      process.exit(0);
      break;
    }

    case 'restart': {
      logger.info('SYSTEM', 'Restarting worker');
      // Capture the old worker's pid BEFORE shutdown so we can later prove
      // the worker answering health checks is a NEW process, not the corpse.
      const oldPid = await getCurrentWorkerPid(port, 2000);
      // Track whether the worker accepted the shutdown POST: an accepted
      // reason=restart shutdown means the dying worker spawns its OWN
      // successor the moment its port frees (worker-shutdown.ts handoff).
      // That handoff is the PRIMARY restart path; this CLI defers to it.
      const shutdownAccepted = await httpShutdown(port, 'restart');

View on GitHub (pinned to d8bc9755e7)