Skip to content

setup: plist/systemd service uses ephemeral fnm node path, causing ERR_DLOPEN_FAILED after npm rebuild #731

@agent-tre

Description

@agent-tre

Problem

setup/platform.ts resolves the Node binary path using command -v node:

export function getNodePath(): string {
  try {
    return execSync('command -v node', { encoding: 'utf-8' }).trim();
  } catch {
    return process.execPath;
  }
}

When the user's version manager is fnm, command -v node returns an ephemeral multishell path like:

/Users/<user>/.local/state/fnm_multishells/42545_1771219131969/bin/node

This path is per-shell-session — it becomes invalid as soon as that shell exits. The launchd plist (or systemd unit) bakes in this ephemeral path at setup time.

Impact

After running npm rebuild (e.g. when updating packages that include native modules like better-sqlite3), the rebuild targets the version in .nvmrc, but the service continues running via the stale ephemeral path — which may point to a different Node version entirely. This causes:

Error: dlopen failed: /path/to/better_sqlite3.node: cannot open shared object file

The service fails to start until the plist is regenerated with a fresh path — which becomes ephemeral again next session.

Expected behaviour

The plist/unit file should reference a stable, session-independent Node binary path that matches the project's .nvmrc.

Suggested fix

In setup/platform.ts, enhance getNodePath() to detect the active version manager and resolve a stable path from it:

export function getNodePath(): string {
  const nvmrcVersion = readNvmrc(); // read .nvmrc or .node-version from project root

  if (nvmrcVersion) {
    // fnm: stable path under node-versions/
    const fnmPath = path.join(
      os.homedir(),
      '.local/share/fnm/node-versions',
      `v${nvmrcVersion}`,
      'installation/bin/node'
    );
    if (fs.existsSync(fnmPath)) return fnmPath;

    // nvm: stable path under .nvm/versions/
    const nvmPath = path.join(
      os.homedir(),
      '.nvm/versions/node',
      `v${nvmrcVersion}`,
      'bin/node'
    );
    if (fs.existsSync(nvmPath)) return nvmPath;
  }

  // Fallback: current behaviour (may be ephemeral under fnm)
  try {
    return execSync('command -v node', { encoding: 'utf-8' }).trim();
  } catch {
    return process.execPath;
  }
}

function readNvmrc(): string | null {
  for (const file of ['.nvmrc', '.node-version']) {
    const p = path.join(projectRoot, file);
    if (fs.existsSync(p)) {
      return fs.readFileSync(p, 'utf-8').trim().replace(/^v/, '');
    }
  }
  return null;
}

This approach:

  • Checks for fnm and nvm stable paths first, using the version pinned in .nvmrc
  • Falls back to current behaviour for other version managers or when no .nvmrc is present
  • Is safe: falls back gracefully if stable paths don't exist

Workaround

Run npm run setup again from a shell where node points to the correct version. The new plist will have the current session's path — which is correct until the shell exits.

Environment

  • macOS with launchd
  • fnm as Node version manager
  • .nvmrc present in project root

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions