Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions bin/console-codepage.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions bin/console-codepage.ts
Original file line number Diff line number Diff line change
@@ -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<NodeJS.Process, 'platform'> = 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;
}
89 changes: 52 additions & 37 deletions bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Loading