`ruby-lsp` hangs `vscode-server` during version manager detection
Nobody has claimed this yet.
- Dominant language
- Ruby
- Stars
- 2k
- Forks
- 281
- Avg merge
- 2h 14m
- Merged PRs (30d)
- 6
Description
Description
Ruby LSP Information
VS Code Version
1.137.0
Ruby LSP Extension Version
0.10.6
Ruby LSP Server Version
0.26.11
Ruby LSP Add-ons
Ruby Version
4.0.7
Ruby Version Manager
rbenv
Installed Extensions
Click to expand
- EditorConfig (0.18.2)
- ansible (26.8.2)
- debugpy (2026.6.0)
- python (2026.4.0)
- ruby-lsp (0.10.6)
- terraform (2.40.0)
- vscode-pylance (2026.3.1)
- vscode-yaml (1.24.0)
Ruby LSP Settings
Click to expand
Workspace
{}
User
{
"enabledFeatures": {
"codeActions": true,
"diagnostics": true,
"documentHighlights": true,
"documentLink": true,
"documentSymbols": true,
"foldingRanges": true,
"formatting": true,
"hover": true,
"inlayHint": true,
"onTypeFormatting": true,
"selectionRanges": true,
"semanticHighlighting": true,
"completion": true,
"codeLens": true,
"definition": true,
"workspaceSymbol": true,
"signatureHelp": true,
"typeHierarchy": true
},
"featuresConfiguration": {},
"addonSettings": {},
"rubyVersionManager": {
"identifier": "auto"
},
"customRubyCommand": "",
"formatter": "auto",
"linters": null,
"bundleGemfile": "",
"testTimeout": 30,
"pullDiagnosticsOn": "both",
"useBundlerCompose": false,
"bypassTypechecker": false,
"rubyExecutablePath": "",
"indexing": {},
"erbSupport": true,
"featureFlags": {},
"sigOpacityLevel": "1"
}
TLDR:
asyncExec can hang the extension (and vscode-server) when it makes multiple interactive shell calls (e.g. during version manager detection). spawn is probably a more resilient strategy.
Details
During initial version manager detection in a remote workspace, and when vscode.env.shell is set, Ruby.toolExists causes the plugin (and in fact the whole process tree) to hang.
For example, when vscode.env.shell is /bin/bash, the command passed to asyncExec looks like: /bin/bash -i -c 'rbenv --version'
Because Node's default shell is /bin/sh, this effectively invokes: /bin/sh -c /bin/bash -i -c 'rbenv --version'
Process tree (trimmed for brevity):
USER PID TTY STAT COMMAND
paul 12933 tty2 T \_ sh -c "$VSCODE_WSL_EXT_LOCATION/scripts/wslServer.sh" 645f29cc3176500b4b5762ba887cf2a7f0ffdf2c stable code-server .vscode-server
paul 12934 tty2 T \_ sh /mnt/c/Users/Paul/.vscode/extensions/ms-vscode-remote.remote-wsl-0.104.3/scripts/wslServer.sh 645f29cc3176500b4b5762ba887cf2a7f0ffdf2c
paul 12940 tty2 T \_ sh /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/bin/code-server
paul 12944 tty2 Tl \_ /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/node /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/out/server-main.js
paul 12965 tty2 Tl \_ /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/node /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/out/bootstrap-fork
paul 13023 tty2 Tl \_ /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/node --dns-result-order=ipv4first /home/paul/.vscode-server/bin/645f29cc3176500b4b5762ba887cf2a7f0ffdf2c/out/bootstrap-fork
paul 13189 tty2 T \_ /bin/sh -c /bin/bash -i -c 'rbenv --version'
paul 13190 tty2 T \_ /bin/bash -i -c rbenv --version
Now, initially, I thought this was a quoting issue -- at first glance it appears as if the -c argument is not properly quoted because of the double-shell. But /proc/<pid>/cmdline confirms the arguments are indeed being grouped correctly. (e.g. cat /proc/13189/cmdline | tr '\0' '\n').
What is actually happening here is a job control issue due to the use of -i. Note the T (stopped) flag in the process status.
From what I understand, when bash is invoked interactively, it manipulates the controlling terminal's foreground process (tcsetpgrp against tty2 above). If the extension is checking for multiple version managers (say chruby, then rbenv), the first invocation and exit of bash -i effectively taints the job control state of the process group because once it exits, that TTY's registered foreground process is now dead -- and the kernel's response to subsequent job control calls in that terminal is to suspend the whole calling process group. This causes both the extension and VS Code server to become unresponsive.
Per tcsetpgrp:
If tcsetpgrp() is called by a member of a background process group in its session, and the calling process is not blocking
or ignoring SIGTTOU, a SIGTTOU signal is sent to all members of this background process group.
I'm guessing we use -i because it's desirable to source user profiles to make version managers available. So, I believe the correct fix for this is to use child_process.spawn which, unlike exec, has a detached option for running the child process independently from the parent. With that, subprocesses won't interfere with the state of the whole process group.
Furthermore -- the nested shell calling may be unnecessary. The shell option can be used to invoke the user's shell directly instead of invoking it under /bin/sh.
Reproduction steps
PoC script replicating the behavior:
const { exec } = require('child_process');
const { promisify } = require('util');
const asyncExec = promisify(exec);
const userShell = `/bin/bash`;
(async function() {
async function run(cmd) {
let out;
try {
out = await asyncExec(`${userShell} -i -c '${cmd}'`, {
shell: '/bin/sh'
});
} catch (e) {
out = `[stderr] ${e.message}`;
}
console.log(cmd, out);
}
await run('chruby --version');
await run('rbenv --version');
await run('rvm --version');
})();
Resulting output:
$ node test.js
chruby --version [stderr] Command failed: /bin/bash -i -c 'chruby --version'
chruby: command not found
[1]+ Stopped node test.js
$ fg
node test.js
rbenv --version [stderr] Command failed: /bin/bash -i -c 'rbenv --version'
Command 'rbenv' not found, but can be installed with:
sudo apt install rbenv
[1]+ Stopped node test.js
$ fg
node test.js
rvm --version [stderr] Command failed: /bin/bash -i -c 'rvm --version'
Command 'rvm' not found, but there are 19 similar ones.
PoC for fix
Changing detached to false here replicates the same behavior as above.
const { spawn } = require('child_process');
const userShell = `/bin/bash`;
function run(cmd) {
return new Promise((resolve, reject) => {
// runs: [ '/bin/bash', '-i', '-c', 'chruby --version' ],
let args = [`-i`, `-c`, cmd];
let child = spawn(userShell, args, {
detached: true
});
let hadError = false;
child.on('error', (e) => {
hadError = true;
console.log(`${cmd} got error ${e}`);
reject(e);
});
child.on('exit', (code) => {
console.log(`${cmd} child got RC ${code}`);
// The 'exit' event may or may not fire after an error has occurred
if (hadError) {
return;
}
return code == 0 ? resolve() : reject();
});
});
}
(async function() {
let cmds = ['chruby --version', 'rbenv --version', 'rvm --version'];
for (let cmd of cmds) {
try {
await run(cmd);
console.log(`${cmd} success`);
} catch (e) {
console.log(`${cmd} failed`);
}
}
})();
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
The affected entry point is vscode/src/ruby.ts, in Ruby.toolExists; start there and compare its asyncExec invocation with the supplied Node spawn PoC. Reproduce the repeated interactive-shell checks, then verify version-manager detection completes without stopping the extension or vscode-server while still finding installed managers.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, ruby, typescript, vscode
- Domain
- developer-experience, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100