From 9a5be006aab64eee861b6c1687a743df03c87a0e Mon Sep 17 00:00:00 2001 From: Pierrick Gourlain Date: Sun, 27 Sep 2026 08:22:51 +0200 Subject: [PATCH] Fix debugger launch when erlang.useShell='auto' --- CHANGELOG.md | 7 +++ lib/ErlangShellDebugger.ts | 2 +- lib/GenericShell.ts | 69 ++++++++++++++++++++++++++ lib/lsp/ErlangShellLSP.ts | 6 ++- test/test-suite/erlangShellLSP.test.ts | 9 ++++ test/test-suite/useShell.test.ts | 37 +++++++++++++- 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf44d75..6a0e154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change log +## Version 1.2.5 (September 27, 2026) + +* [358](https://github.com/pgourlain/vscode_erlang/issues/358) : Launch (Run / Debug) broken on Linux and macOS since 1.2.4 + - Since `erl` is spawned without a shell (`erlang.useShell` = `auto`), the launch.json `arguments` string was handed to `erl` as a single word (e.g. `-s myapp start`), which `erl` silently ignores, and a missing `arguments` became the literal word `undefined`, which crashed the node at boot. `arguments` and `erlang.erlangArgs` items are now split into words like `/bin/sh` does (quotes and backslash escapes included), and an empty value adds nothing to the command line + +--- + ## Version 1.2.4 (September 26, 2026) * [356](https://github.com/pgourlain/vscode_erlang/issues/356) : Header files (`.hrl`) treated as modules diff --git a/lib/ErlangShellDebugger.ts b/lib/ErlangShellDebugger.ts index 4d8ead4..1291e0f 100644 --- a/lib/ErlangShellDebugger.ts +++ b/lib/ErlangShellDebugger.ts @@ -99,7 +99,7 @@ export class ErlangShellForDebugging extends GenericShell { debugStartArgs.push("-s", "vscode_connection", "start"); var argsFile = this.createArgsFilev1(startDir, launchArguments.noDebug, launchArguments.addEbinsToCodepath, launchArguments.verbose); - var processArgs = debugStartArgs.concat(argsFile).concat([launchArguments.arguments]); + var processArgs = debugStartArgs.concat(argsFile).concat(this.commandLineArgs(launchArguments.arguments)); this.started = true; var result = this.LaunchProcess(erlPath, startDir, processArgs, !launchArguments.verbose); return result; diff --git a/lib/GenericShell.ts b/lib/GenericShell.ts index 68632fd..df749f5 100644 --- a/lib/GenericShell.ts +++ b/lib/GenericShell.ts @@ -38,6 +38,71 @@ export function quoteForShell(value: string, useShell: boolean): string { return useShell ? `"${value}"` : value; } +// Splits a free-form command line (launch.json "arguments", erlang.erlangArgs +// items) into argv words the way /bin/sh would: whitespace separates words, +// '...' is literal, "..." allows \" \\ \$ \` escapes, a bare \ escapes the +// next character. Needed when useShell is false (#358): spawn would otherwise +// hand "-s myapp start" to erl as one word, which erl silently ignores. +export function splitCommandLine(value: string): string[] { + const words: string[] = []; + let current = ''; + let inWord = false; + let i = 0; + while (i < value.length) { + const c = value[i]; + if (c === "'") { + const end = value.indexOf("'", i + 1); + const stop = end === -1 ? value.length : end; + current += value.substring(i + 1, stop); + inWord = true; + i = stop + 1; + } else if (c === '"') { + inWord = true; + i++; + while (i < value.length && value[i] !== '"') { + if (value[i] === '\\' && i + 1 < value.length && '"\\$`'.indexOf(value[i + 1]) !== -1) { + i++; + } + current += value[i]; + i++; + } + i++; + } else if (c === '\\' && i + 1 < value.length) { + current += value[i + 1]; + inWord = true; + i += 2; + } else if (/\s/.test(c)) { + if (inWord) { + words.push(current); + current = ''; + inWord = false; + } + i++; + } else { + current += c; + inWord = true; + i++; + } + } + if (inWord) { + words.push(current); + } + return words; +} + +// argv words for a user-supplied command line. Through a shell it is passed +// as-is (the shell splits it); otherwise it is split here. A missing or blank +// value yields no word at all: without a shell, spawn would pass undefined as +// the literal word "undefined" and "" as an empty word, and erl appends either +// to the preceding flag (-eval ... / -s vscode_connection start), which kills +// the node at boot (#358). +export function commandLineArgs(value: string | undefined, useShell: boolean): string[] { + if (value === undefined || value === null || value.trim() === '') { + return []; + } + return useShell ? [value] : splitCommandLine(value); +} + export class GenericShell extends EventEmitter { protected childProcess: ChildProcess; protected logOutput: ILogOutput; @@ -91,6 +156,10 @@ export class GenericShell extends EventEmitter { return quoteForShell(value, this.useShell); } + protected commandLineArgs(value: string | undefined): string[] { + return commandLineArgs(value, this.useShell); + } + protected RunProcess(processName, startDir: string, args: string[]): Promise { return new Promise((resolve, reject) => { diff --git a/lib/lsp/ErlangShellLSP.ts b/lib/lsp/ErlangShellLSP.ts index 8eca73e..61ed375 100644 --- a/lib/lsp/ErlangShellLSP.ts +++ b/lib/lsp/ErlangShellLSP.ts @@ -41,7 +41,9 @@ export class ErlangShellLSP extends GenericShell { } // Use special command line arguments if (this.erlangArgs) { - debugStartArgs = debugStartArgs.concat(this.erlangArgs) + this.erlangArgs.forEach(arg => { + debugStartArgs = debugStartArgs.concat(this.commandLineArgs(arg)); + }); } debugStartArgs.push( "-noshell", @@ -50,7 +52,7 @@ export class ErlangShellLSP extends GenericShell { "-s", "int", "-vscode_port", listen_port.toString(), "-s", "vscode_lsp_entry", "start", listen_port.toString()); - var processArgs = debugStartArgs.concat([args]); + var processArgs = debugStartArgs.concat(this.commandLineArgs(args)); var result = this.LaunchProcess("erl", startDir, processArgs); return result; diff --git a/test/test-suite/erlangShellLSP.test.ts b/test/test-suite/erlangShellLSP.test.ts index 5cd69df..4e9c649 100644 --- a/test/test-suite/erlangShellLSP.test.ts +++ b/test/test-suite/erlangShellLSP.test.ts @@ -42,3 +42,12 @@ suite('ErlangShellLSP distribution hardening', () => { assert.ok(!args.includes('ERL_EPMD_ADDRESS'), 'no epmd binding when not distributed'); }); }); + +suite('ErlangShellLSP argv', () => { + test('blank extra args add no empty word to the erl command line (#358)', async () => { + const { args } = await captureStartArgs(false); + assert.ok(!args.includes(''), 'no empty argv word'); + assert.ok(!args.includes(undefined as any), 'no undefined argv word'); + assert.strictEqual(args[args.length - 1], '12345', '-s vscode_lsp_entry start ends the command line'); + }); +}); diff --git a/test/test-suite/useShell.test.ts b/test/test-suite/useShell.test.ts index 37cce06..e17116e 100644 --- a/test/test-suite/useShell.test.ts +++ b/test/test-suite/useShell.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { quoteForShell } from '../../lib/GenericShell'; +import { quoteForShell, splitCommandLine, commandLineArgs } from '../../lib/GenericShell'; import { resolveUseShell } from '../../lib/ErlangConfigurationProvider'; suite('erlang.useShell resolution', () => { @@ -27,3 +27,38 @@ suite('quoteForShell', () => { assert.strictEqual(quoteForShell('{127,0,0,1}', false), '{127,0,0,1}'); }); }); + +suite('splitCommandLine', () => { + test('splits on whitespace like /bin/sh', () => { + assert.deepStrictEqual(splitCommandLine(' -s myapp start '), ['-s', 'myapp', 'start']); + }); + + test('keeps quoted words together and strips the quotes', () => { + assert.deepStrictEqual(splitCommandLine(`-eval 'application:start(x), ok.' -pa "/a b/ebin"`), + ['-eval', 'application:start(x), ok.', '-pa', '/a b/ebin']); + }); + + test('handles escapes inside double quotes and bare backslashes', () => { + assert.deepStrictEqual(splitCommandLine(`-eval "io:format(\\"hi\\")" a\\ b`), + ['-eval', 'io:format("hi")', 'a b']); + }); + + test('keeps an explicitly quoted empty word', () => { + assert.deepStrictEqual(splitCommandLine(`-x ''`), ['-x', '']); + }); +}); + +suite('commandLineArgs', () => { + test('missing or blank value yields no argv word (#358)', () => { + for (const useShell of [true, false]) { + assert.deepStrictEqual(commandLineArgs(undefined, useShell), []); + assert.deepStrictEqual(commandLineArgs('', useShell), []); + assert.deepStrictEqual(commandLineArgs(' ', useShell), []); + } + }); + + test('passes the value as-is through a shell, splits it otherwise', () => { + assert.deepStrictEqual(commandLineArgs('-s myapp', true), ['-s myapp']); + assert.deepStrictEqual(commandLineArgs('-s myapp', false), ['-s', 'myapp']); + }); +});