diff --git a/bin/console-codepage.spec.ts b/bin/console-codepage.spec.ts new file mode 100644 index 00000000..5a1e10f7 --- /dev/null +++ b/bin/console-codepage.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ensureUtf8Codepage, SpawnSync } from './console-codepage.js'; + +const win32 = { platform: 'win32' as const }; +const linux = { platform: 'linux' as const }; + +const ok = (stdout: string) => ({ status: 0, stdout, error: undefined }); +const failed = () => ({ status: 1, stdout: '', error: undefined }); + +describe('ensureUtf8Codepage()', () => { + it('does nothing on non-Windows platforms', () => { + const spawnSync = vi.fn(); + ensureUtf8Codepage(spawnSync as unknown as SpawnSync, linux)(); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it('does nothing when the codepage is already UTF-8', () => { + const spawnSync = vi.fn().mockReturnValue(ok('Active code page: 65001.')); + const restore = ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32); + + expect(spawnSync).toHaveBeenCalledTimes(1); + + restore(); + expect(spawnSync).toHaveBeenCalledTimes(1); + }); + + it('sets the codepage to UTF-8 and restores the original one it read', () => { + const spawnSync = vi.fn().mockReturnValue(ok('Aktive Codepage: 850.')); + const restore = ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32); + + expect(spawnSync).toHaveBeenNthCalledWith(1, 'cmd.exe', ['/s', '/c', 'chcp'], { + encoding: 'utf8', + }); + expect(spawnSync).toHaveBeenNthCalledWith(2, 'cmd.exe', ['/s', '/c', 'chcp 65001'], { + stdio: 'ignore', + }); + + restore(); + expect(spawnSync).toHaveBeenNthCalledWith(3, 'cmd.exe', ['/s', '/c', 'chcp 850'], { + stdio: 'ignore', + }); + }); + + it('only restores once, even if called multiple times', () => { + const spawnSync = vi.fn().mockReturnValue(ok('Active code page: 850.')); + const restore = ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32); + + restore(); + restore(); + expect(spawnSync).toHaveBeenCalledTimes(3); + }); + + it('does nothing and never throws when reading the codepage fails', () => { + const spawnSync = vi.fn().mockReturnValue(failed()); + const restore = ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32); + + expect(() => restore()).not.toThrow(); + expect(spawnSync).toHaveBeenCalledTimes(1); + }); + + it('does nothing and never throws when spawnSync itself throws', () => { + const spawnSync = vi.fn().mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32)()).not.toThrow(); + }); + + it('does nothing when setting the codepage fails', () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce(ok('Active code page: 850.')) + .mockReturnValueOnce(failed()); + const restore = ensureUtf8Codepage(spawnSync as unknown as SpawnSync, win32); + + expect(spawnSync).toHaveBeenCalledTimes(2); + + restore(); + // No third call: setting UTF-8 failed, so there's nothing to restore. + expect(spawnSync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/bin/console-codepage.ts b/bin/console-codepage.ts new file mode 100644 index 00000000..634c415d --- /dev/null +++ b/bin/console-codepage.ts @@ -0,0 +1,70 @@ +import { spawnSync as baseSpawnSync } from 'node:child_process'; +import nodeProcess from 'node:process'; + +const UTF8_CODEPAGE = 65001; + +export type SpawnSync = typeof baseSpawnSync; + +/** + * On Windows, concurrently pipes child-process output through a freshly-allocated console whose + * codepage may not be UTF-8, garbling non-ASCII output from commands that rely on it (see + * open-cli-tools/concurrently#302). This sets the console's codepage to UTF-8 once, which spawned + * children then inherit, and returns a function that restores the original codepage. + * + * No-ops (and never throws) if not on Windows, if the codepage is already UTF-8, or if reading/setting + * it fails for any reason -- this is a best-effort convenience, never something that should block + * concurrently from running. + */ +export function ensureUtf8Codepage( + spawnSync: SpawnSync = baseSpawnSync, + process: Pick = nodeProcess, +): () => void { + const noop = () => {}; + if (process.platform !== 'win32') { + return noop; + } + + try { + const original = readCodepage(spawnSync); + if (original == null || original === UTF8_CODEPAGE) { + return noop; + } + if (!setCodepage(spawnSync, UTF8_CODEPAGE)) { + return noop; + } + + let restored = false; + return () => { + if (restored) { + return; + } + restored = true; + try { + setCodepage(spawnSync, original); + } catch { + // Best-effort restore; nothing more we can do. + } + }; + } catch { + return noop; + } +} + +function readCodepage(spawnSync: SpawnSync): number | null { + const result = spawnSync('cmd.exe', ['/s', '/c', 'chcp'], { encoding: 'utf8' }); + if (result.error || result.status !== 0 || !result.stdout) { + return null; + } + // Locale-dependent text (e.g. "Active code page: 65001." or "Aktive Codepage: 65001."), + // so match on the number rather than the surrounding words. + const match = /(\d+)/.exec(result.stdout); + return match ? Number(match[1]) : null; +} + +function setCodepage(spawnSync: SpawnSync, codepage: number): boolean { + // `chcp` changes the codepage of the console this process is attached to regardless of where its + // own stdio points, so output can be safely discarded here rather than inherited -- otherwise its + // confirmation text (e.g. "Active code page: 65001.") would leak into concurrently's own output. + const result = spawnSync('cmd.exe', ['/s', '/c', `chcp ${codepage}`], { stdio: 'ignore' }); + return !result.error && result.status === 0; +} diff --git a/bin/index.ts b/bin/index.ts index cfa5b836..7f8f2f44 100755 --- a/bin/index.ts +++ b/bin/index.ts @@ -7,6 +7,7 @@ import { hideBin } from 'yargs/helpers'; import * as defaults from '../lib/defaults.js'; import { concurrently } from '../lib/index.js'; import { castArray, splitOutsideParens } from '../lib/utils.js'; +import { ensureUtf8Codepage } from './console-codepage.js'; import { normalizeCliCommand } from './normalize-cli-command.js'; import { readPackageJson } from './read-package-json.js'; @@ -229,40 +230,54 @@ if (!commands.length) { process.exit(); } -concurrently( - commands.map((command, index) => ({ - command: normalizeCliCommand(String(command)), - name: names[index], - })), - { - handleInput: args.handleInput, - defaultInputTarget: args.defaultInputTarget, - killOthersOn: args.killOthers - ? ['success', 'failure'] - : args.killOthersOnFail - ? ['failure'] - : [], - killSignal: args.killSignal, - killTimeout: args.killTimeout, - maxProcesses: args.maxProcesses, - raw: args.raw, - hide: args.hide.split(','), - group: args.group, - prefix: args.prefix, - prefixColors: splitOutsideParens(args.prefixColors, ','), - prefixLength: args.prefixLength, - padPrefix: args.padPrefix, - restartDelay: - args.restartAfter === 'exponential' ? 'exponential' : Number(args.restartAfter), - restartTries: args.restartTries, - successCondition: args.success, - timestampFormat: args.timestampFormat, - timings: args.timings, - shell: args.shell, - teardown: args.teardown, - additionalArguments: args.passthroughArguments ? additionalArguments : undefined, - }, -).result.then( - () => process.exit(0), - () => process.exit(1), -); +// Piped output on Windows uses the console's active codepage, which may not be UTF-8 -- see +// open-cli-tools/concurrently#302. Raw mode inherits the real console directly instead of piping +// through concurrently, so it isn't affected and doesn't need this. +const restoreCodepage = args.raw ? () => {} : ensureUtf8Codepage(); +const exitProcess = (code: number) => { + restoreCodepage(); + process.exit(code); +}; + +try { + concurrently( + commands.map((command, index) => ({ + command: normalizeCliCommand(String(command)), + name: names[index], + })), + { + handleInput: args.handleInput, + defaultInputTarget: args.defaultInputTarget, + killOthersOn: args.killOthers + ? ['success', 'failure'] + : args.killOthersOnFail + ? ['failure'] + : [], + killSignal: args.killSignal, + killTimeout: args.killTimeout, + maxProcesses: args.maxProcesses, + raw: args.raw, + hide: args.hide.split(','), + group: args.group, + prefix: args.prefix, + prefixColors: splitOutsideParens(args.prefixColors, ','), + prefixLength: args.prefixLength, + padPrefix: args.padPrefix, + restartDelay: + args.restartAfter === 'exponential' ? 'exponential' : Number(args.restartAfter), + restartTries: args.restartTries, + successCondition: args.success, + timestampFormat: args.timestampFormat, + timings: args.timings, + shell: args.shell, + teardown: args.teardown, + additionalArguments: args.passthroughArguments ? additionalArguments : undefined, + }, + ).result.then( + () => exitProcess(0), + () => exitProcess(1), + ); +} catch (error) { + restoreCodepage(); + throw error; +}