Skip to content

client: reject start() if server process exits before connecting on pipe/socket transport - #1862

Merged
Dirk Bäumer (dbaeumer) merged 6 commits into
microsoft:mainfrom
pujitha24:auto/issue-1586
Oct 2, 2026
Merged

Dirk Bäumer (dbaeumer) merged 6 commits into
microsoft:mainfrom
pujitha24:auto/issue-1586

Conversation

@pujitha24

Copy link
Copy Markdown
Contributor

Motivation:
When using TransportKind.pipe or TransportKind.socket, the client creates
a pipe/socket server and waits for the spawned server process to connect
to it via transport.onConnected(). That promise (created in
createClientPipeTransport / createClientSocketTransport in
jsonrpc/src/node/main.ts) is only ever resolved from inside the
connection callback - it has no reject path. If the server process exits
before connecting (for example because it fails to start, or exits with
a nonzero code), the promise never settles and LanguageClient#start()
hangs forever instead of failing.

Approach:
Add a small waitForConnection() helper in client/src/node/main.ts that
races transport.onConnected() against the spawned child process's 'exit'
event, rejecting with a descriptive error if the process exits first.
Use it at all six pipe/socket call sites in createMessageTransports
(module+runtime, forked module, and Executable/command branches), in
place of the bare transport.onConnected().then(...) calls.

Validation:

  • npm run compile (tsc -b) is clean in jsonrpc, protocol, types,
    textDocument, server, client and client-node-tests after npm run symlink.
  • npm run lint is clean in client and client-node-tests (only a
    pre-existing, unrelated eslint-plugin deprecation warning).
  • Added two integration tests in client-node-tests/src/integration.test.ts
    ('Socket transport' and new 'Pipe transport' suites) using a new
    minimal server script (servers/exitBeforeConnectServer.ts) that exits
    immediately without ever connecting, mirroring the reporter's repro.
    Ran via npm test in client-node-tests (real VS Code Insiders +
    Electron extension host, @vscode/test-electron): before the fix the
    new socket test times out after 5s (client.start() never settles,
    reproducing the reported hang exactly); after the fix both new tests
    pass, and the full existing suite (207 tests) passes with no
    regressions.
  • Additionally reproduced the root cause directly with a standalone
    script using the repo's own built jsonrpc createClientSocketTransport
    plus a real spawned process that exits non-zero without connecting,
    confirming transport.onConnected() never settles without the fix and
    rejects quickly with the fix in place.

This only changes what happens when the server process exits before the
pipe/socket connection is established - normal startup and shutdown
behavior (including the process 'exit' logging in the finally() block)
is unaffected, since the added listener is a no-op once the connection
promise has already settled.

Report: #1586
Signed-off-by: Pujitha Paladugu 10557236+pujitha24@users.noreply.github.com
Assisted-by: claude-sonnet-5 (via Claude Code)

Fixes #1586

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The failure path leaves the underlying pipe/socket listener active and its resources occupied.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Prevents pipe/socket client startup from hanging when the server exits before connecting.

Changes:

  • Races transport connection against child-process exit.
  • Adds pipe and socket integration tests using an immediate-exit server.
File Description
client/​src/​node/​main.ts Adds connection/exit coordination.
client-node-tests/​src/​servers/​exitBeforeConnectServer.ts Adds failing test server.
client-node-tests/​src/​integration.test.ts Tests socket and pipe rejection behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread client/src/node/main.ts Outdated
@dbaeumer

Copy link
Copy Markdown
Member

Pujitha Paladugu (@pujitha24) see review comments.

…ipe/socket transport

Motivation:
When using TransportKind.pipe or TransportKind.socket, the client creates
a pipe/socket server and waits for the spawned server process to connect
to it via transport.onConnected(). That promise (created in
createClientPipeTransport / createClientSocketTransport in
jsonrpc/src/node/main.ts) is only ever resolved from inside the
connection callback - it has no reject path. If the server process exits
before connecting (for example because it fails to start, or exits with
a nonzero code), the promise never settles and LanguageClient#start()
hangs forever instead of failing.

Approach:
Add a small waitForConnection() helper in client/src/node/main.ts that
races transport.onConnected() against the spawned child process's 'exit'
event, rejecting with a descriptive error if the process exits first.
Use it at all six pipe/socket call sites in createMessageTransports
(module+runtime, forked module, and Executable/command branches), in
place of the bare transport.onConnected().then(...) calls.

Validation:
- npm run compile (tsc -b) is clean in jsonrpc, protocol, types,
  textDocument, server, client and client-node-tests after `npm run
  symlink`.
- npm run lint is clean in client and client-node-tests (only a
  pre-existing, unrelated eslint-plugin deprecation warning).
- Added two integration tests in client-node-tests/src/integration.test.ts
  ('Socket transport' and new 'Pipe transport' suites) using a new
  minimal server script (servers/exitBeforeConnectServer.ts) that exits
  immediately without ever connecting, mirroring the reporter's repro.
  Ran via `npm test` in client-node-tests (real VS Code Insiders +
  Electron extension host, @vscode/test-electron): before the fix the
  new socket test times out after 5s (client.start() never settles,
  reproducing the reported hang exactly); after the fix both new tests
  pass, and the full existing suite (207 tests) passes with no
  regressions.
- Additionally reproduced the root cause directly with a standalone
  script using the repo's own built jsonrpc createClientSocketTransport
  plus a real spawned process that exits non-zero without connecting,
  confirming transport.onConnected() never settles without the fix and
  rejects quickly with the fix in place.

This only changes what happens when the server process exits before the
pipe/socket connection is established - normal startup and shutdown
behavior (including the process 'exit' logging in the finally() block)
is unaffected, since the added listener is a no-op once the connection
promise has already settled.

Report: microsoft#1586
Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Assisted-by: claude-sonnet-5 (via Claude Code)
Address review feedback: add dispose() to the jsonrpc client
PipeTransport/SocketTransport, call it from waitForConnection when the
child exits before connecting and on the spawn-failure early returns,
and add jsonrpc tests that the port/pipe path is released.

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Spawn errors remain unhandled, and the transport method rename breaks public API compatibility.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
Resolved since last review (1)

Comment thread client/src/node/main.ts Outdated
Comment thread jsonrpc/src/node/main.ts Outdated
* other side never connects (e.g. the server process exited early).
*/
close(): void;
dispose(): void;
Comment thread jsonrpc/src/node/main.ts Outdated
* other side never connects (e.g. the server process exited early).
*/
close(): void;
dispose(): void;
Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Renaming PipeTransport/SocketTransport close() to dispose() broke the
API that main already exports (and that is re-exported through the
protocol and client node entry points). Revert jsonrpc to main's close()
and its tests, and call close() from waitForConnection instead.

waitForConnection now also removes its 'exit' and 'error' listeners from
the child process as soon as the connection is established or fails, so
a server exiting normally later no longer runs them.

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Assisted-by: claude-opus-5-5 (via Claude Code)
@pujitha24

Copy link
Copy Markdown
Contributor Author

I've addressed the Copilot review comments:

  • Listener left open when the server exits before connecting (first review): waitForConnection closes the transport on the child's exit, then rejects (3776a91).
  • Spawn errors (second review): waitForConnection also handles the child's error event, which a failed spawn emits instead of exit. It closes the transport there too and rejects (bdfb867).
  • close() → dispose() rename breaking the API (second review): reverted in 0a2015b. jsonrpc is back to main's close() and its tests, and the client calls close(), so this PR no longer touches jsonrpc.
  • 0a2015b also removes the exit/error listeners once the connection succeeds or fails, so a server that exits normally later doesn't trigger them.

Checked locally: npm run compile and client lint are clean, jsonrpc tests pass (73), and client-node-tests pass (209), including the new socket and pipe exit-before-connect tests.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The socket regression test does not exercise the executable-command path reported in issue #1586.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
Resolved since last review (1)

Comment thread client-node-tests/src/integration.test.ts
@dbaeumer

Copy link
Copy Markdown
Member

Pujitha Paladugu (@pujitha24) another review from Copilot on the lastest changes you made.

…before connecting

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The focused implementation covers all six affected paths and includes representative integration tests.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)

@pujitha24

Copy link
Copy Markdown
Contributor Author

The last Copilot comment (test only hit the forked NodeModule branch) is covered by 31ad450: there's now a socket and a pipe test that use command: process.execPath plus the exitBeforeConnectServer.js script as an argument, with ELECTRON_RUN_AS_NODE set since the tests run under Electron. That goes through the Executable path from #1586. The two close() compatibility comments were handled in 0a2015b, which keeps close() on both transports. I haven't run the integration suite locally for these new cases, so I'm relying on CI for that.

@dbaeumer

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@dbaeumer
Dirk Bäumer (dbaeumer) enabled auto-merge (squash) October 2, 2026 11:57
@dbaeumer
Dirk Bäumer (dbaeumer) merged commit 2d17738 into microsoft:main Oct 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LanguageClient get stuck in starting state with socket transport kind

5 participants