Skip to content

LanguageClient start mutates serverOptions, erases .env for .restart() #1716

@mortenpi

Description

@mortenpi

This code path here:

const options: cp.ForkOptions = node.options ?? Object.create(null);
options.env = getEnvironment(options.env, true);
options.execArgv = options.execArgv || [];
options.cwd = serverWorkingDir;
options.silent = true;
if (transport === TransportKind.ipc || transport === TransportKind.stdio) {
const sp = cp.fork(node.module, args || [], options);

mutates the .env property inside this._serverOptions. Furthermore, the cp.fork below seems to mutate it further (weird, but that's what I saw in the debugger).

The consequence here is that when you run .restart(), it will not remember the .env values you passed in the LanguageClient constructor. And so, when you restart language server with client.restart(), it will have different options from the original launch.

A sketch of an MWE is this:

const serverOptions = {
  run: {
    module: serverModule,
    transport: TransportKind.ipc,
    options: {
      execArgv: [],
      env: {
        SOMEOPTION: "some-option-value",
      },
    },
  },
  debug: {
    module: serverModule,
    transport: TransportKind.ipc,
    options: {
      execArgv: ["--nolazy", "--inspect=6010"],
      env: {
        DEBUG: "*",
        SOMEOPTION: "some-option-value",
      },
    },
  },
};

const client = new LanguageClient(
  "my-language-server",
  "My Language Server",
  deepFreeze(serverOptions) as ServerOptions,
  clientOptions
);

await client.start(); // picks up the .env correctly
await client.restart(); // this doesn't

One possible MWE should be this, where if you freeze the serverOptions object, it will crash:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

function deepFreeze<T extends object>(obj: T): DeepReadonly<T> {
  Object.getOwnPropertyNames(obj).forEach((name) => {
    const prop = obj[name as keyof T];

    if (prop !== null && typeof prop === "object") {
      deepFreeze(prop);
    }
  });

  return Object.freeze(obj) as DeepReadonly<T>;
}

const serverOptions = {
  run: {
    module: serverModule,
    transport: TransportKind.ipc,
    options: {
      execArgv: [],
      env: {
        SOMEOPTION: "some-option-value",
        MCP_PORT: mcpServerPort.toString(),
      },
    },
  },
  debug: {
    module: serverModule,
    transport: TransportKind.ipc,
    options: {
      execArgv: ["--nolazy", "--inspect=6010"],
      env: {
        DEBUG: "*",
        SOMEOPTION: "some-option-value",
        MCP_PORT: mcpServerPort.toString(),
      },
    },
  },
};

const client = new LanguageClient(
  "my-language-server",
  "My Language Server",
  deepFreeze(serverOptions) as ServerOptions,
  clientOptions
);

await client.start(); // should crash

Changing the first line there to make a copy of the properties seems to be enough to fix this:

const options: cp.ForkOptions = { ...node.options } ?? Object.create(null);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions