Skip to content

Commit

Permalink
Connect stdin to execed process (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrmarti committed Mar 10, 2023
1 parent 6ee5243 commit e4a7b11
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 53 deletions.
8 changes: 8 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ const watch = process.argv.indexOf('--watch') !== -1;
loader: 'js',
};
});
// Work around https://github.com/TooTallNate/node-pac-proxy-agent/issues/21.
build.onLoad({ filter: /node_modules[\/\\]ftp[\/\\]lib[\/\\]connection.js$/ }, async (args) => {
const text = await fs.promises.readFile(args.path, 'utf8');
return {
contents: text.replace(/\bnew Buffer\(/g, 'Buffer.from('),
loader: 'js',
};
});
},
};

Expand Down
42 changes: 42 additions & 0 deletions src/spec-common/commonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,48 @@ export async function runCommand(options: {
});
}

// From https://man7.org/linux/man-pages/man7/signal.7.html:
export const processSignals: Record<string, number | undefined> = {
SIGHUP: 1,
SIGINT: 2,
SIGQUIT: 3,
SIGILL: 4,
SIGTRAP: 5,
SIGABRT: 6,
SIGIOT: 6,
SIGBUS: 7,
SIGEMT: undefined,
SIGFPE: 8,
SIGKILL: 9,
SIGUSR1: 10,
SIGSEGV: 11,
SIGUSR2: 12,
SIGPIPE: 13,
SIGALRM: 14,
SIGTERM: 15,
SIGSTKFLT: 16,
SIGCHLD: 17,
SIGCLD: undefined,
SIGCONT: 18,
SIGSTOP: 19,
SIGTSTP: 20,
SIGTTIN: 21,
SIGTTOU: 22,
SIGURG: 23,
SIGXCPU: 24,
SIGXFSZ: 25,
SIGVTALRM: 26,
SIGPROF: 27,
SIGWINCH: 28,
SIGIO: 29,
SIGPOLL: 29,
SIGPWR: 30,
SIGINFO: undefined,
SIGLOST: undefined,
SIGSYS: 31,
SIGUNUSED: 31,
};

export function plainExec(defaultCwd: string | undefined): ExecFunction {
return async function (params: ExecParameters): Promise<Exec> {
const { cmd, args, output } = params;
Expand Down
108 changes: 80 additions & 28 deletions src/spec-common/injectHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import * as crypto from 'crypto';

import { ContainerError, toErrorText, toWarningText } from './errors';
import { launch, ShellServer } from './shellServer';
import { ExecFunction, CLIHost, PtyExecFunction, isFile } from './commonUtils';
import { Event, NodeEventEmitter } from '../spec-utils/event';
import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec } from './commonUtils';
import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event';
import { PackageConfiguration } from '../spec-utils/product';
import { URI } from 'vscode-uri';
import { containerSubstitute } from './variableSubstitution';
Expand Down Expand Up @@ -80,10 +80,14 @@ export function createNullPostCreate(enabled: boolean, skipNonBlocking: boolean,
emitter.fire(data.toString());
}
const emitter = new NodeEventEmitter<string>({
on: () => process.stdin.on('data', listener),
on: () => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.on('data', listener);
},
off: () => process.stdin.off('data', listener),
});
process.stdin.setEncoding('utf8');
return {
enabled,
skipNonBlocking,
Expand Down Expand Up @@ -470,7 +474,7 @@ async function runPostCommand({ postCreate }: ResolverParameters, containerPrope
// we need to hold output until the command is done so that the output
// doesn't get interleaved with the output of other commands.
const printMode = name ? 'off' : 'continuous';
const { cmdOutput } = await runRemoteCommand({ ...postCreate, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: await remoteEnv, print: printMode });
const { cmdOutput } = await runRemoteCommand({ ...postCreate, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: await remoteEnv, pty: true, print: printMode });

if (name) {
infoOutput.raw(`\x1b[1mRunning ${name} from devcontainer.json...\x1b[0m\r\n${cmdOutput}\r\n`);
Expand Down Expand Up @@ -529,38 +533,86 @@ export function createFileCommand(location: string) {
return `test ! -f '${location}' && set -o noclobber && mkdir -p '${path.posix.dirname(location)}' && { > '${location}' ; } 2> /dev/null`;
}

export async function runRemoteCommand(params: { output: Log; onDidInput?: Event<string> }, { remotePtyExec }: { remotePtyExec: PtyExecFunction }, cmd: string[], cwd?: string, options: { remoteEnv?: NodeJS.ProcessEnv; stdin?: Buffer | fs.ReadStream; silent?: boolean; print?: 'off' | 'continuous' | 'end'; resolveOn?: RegExp } = {}) {
const print = options.print || (options.silent ? 'off' : 'end');
const p = await remotePtyExec({
env: options.remoteEnv,
cwd,
cmd: cmd[0],
args: cmd.slice(1),
output: options.silent ? nullLog : params.output,
});
export async function runRemoteCommand(params: { output: Log; onDidInput?: Event<string>; stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream }, { remoteExec, remotePtyExec }: ContainerProperties, cmd: string[], cwd?: string, options: { remoteEnv?: NodeJS.ProcessEnv; pty?: boolean; print?: 'off' | 'continuous' | 'end' } = {}) {
const print = options.print || 'end';
let sub: Disposable | undefined;
let pp: Exec | PtyExec;
let cmdOutput = '';
let doResolveEarly: () => void;
const resolveEarly = new Promise<void>(resolve => {
doResolveEarly = resolve;
});
p.onData(chunk => {
cmdOutput += chunk;
if (print === 'continuous') {
params.output.raw(chunk);
if (options.pty) {
const p = pp = await remotePtyExec({
env: options.remoteEnv,
cwd,
cmd: cmd[0],
args: cmd.slice(1),
output: params.output,
});
p.onData(chunk => {
cmdOutput += chunk;
if (print === 'continuous') {
if (params.stdout) {
params.stdout.write(chunk);
} else {
params.output.raw(chunk);
}
}
});
if (params.onDidInput) {
params.onDidInput(data => p.write(data));
} else if (params.stdin) {
const listener = (data: Buffer): void => p.write(data.toString());
const stdin = params.stdin;
if (stdin.isTTY) {
stdin.setRawMode(true);
}
stdin.on('data', listener);
sub = { dispose: () => stdin.off('data', listener) };
}
if (options.resolveOn && options.resolveOn.exec(cmdOutput)) {
doResolveEarly();
} else {
const p = pp = await remoteExec({
env: options.remoteEnv,
cwd,
cmd: cmd[0],
args: cmd.slice(1),
output: params.output,
});
const stdout: Buffer[] = [];
if (print === 'continuous' && params.stdout) {
p.stdout.pipe(params.stdout);
} else {
p.stdout.on('data', chunk => {
stdout.push(chunk);
if (print === 'continuous') {
params.output.raw(chunk.toString());
}
});
}
});
const sub = params.onDidInput && params.onDidInput(data => p.write(data));
const exit = await Promise.race([p.exit, resolveEarly]);
const stderr: Buffer[] = [];
if (print === 'continuous' && params.stderr) {
p.stderr.pipe(params.stderr);
} else {
p.stderr.on('data', chunk => {
stderr.push(chunk);
if (print === 'continuous') {
params.output.raw(chunk.toString());
}
});
}
if (params.onDidInput) {
params.onDidInput(data => p.stdin.write(data));
} else if (params.stdin) {
params.stdin.pipe(p.stdin);
}
await pp.exit;
cmdOutput = `${Buffer.concat(stdout)}\n${Buffer.concat(stderr)}`;
}
const exit = await pp.exit;
if (sub) {
sub.dispose();
}
if (print === 'end') {
params.output.raw(cmdOutput);
}
if (exit && (exit.code || exit.signal)) {
if (exit.code || exit.signal) {
return Promise.reject({
message: `Command failed: ${cmd.join(' ')}`,
cmdOutput,
Expand Down
8 changes: 6 additions & 2 deletions src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { dockerComposeCLIConfig } from './dockerCompose';
import { Mount } from '../spec-configuration/containerFeaturesConfiguration';
import { getPackageConfig, PackageConfiguration } from '../spec-utils/product';
import { dockerBuildKitVersion } from '../spec-shutdown/dockerUtils';
import { Event } from '../spec-utils/event';

export const experimentalImageMetadataDefault = true;

Expand All @@ -34,6 +35,7 @@ export interface ProvisionOptions {
logFormat: LogFormat;
log: (text: string) => void;
terminalDimensions: LogDimensions | undefined;
onDidChangeTerminalDimensions?: Event<LogDimensions>;
defaultUserEnvProbe: UserEnvProbe;
removeExistingContainer: boolean;
buildNoCache: boolean;
Expand Down Expand Up @@ -177,7 +179,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
updateRemoteUserUIDDefault,
additionalCacheFroms: options.additionalCacheFroms,
buildKitVersion,
isTTY: process.stdin.isTTY || options.logFormat === 'json',
isTTY: process.stdout.isTTY || options.logFormat === 'json',
buildxPlatform: common.buildxPlatform,
buildxPush: common.buildxPush,
buildxOutput: common.buildxOutput,
Expand All @@ -189,19 +191,21 @@ export interface LogOptions {
logFormat: LogFormat;
log: (text: string) => void;
terminalDimensions: LogDimensions | undefined;
onDidChangeTerminalDimensions?: Event<LogDimensions>;
}

export function createLog(options: LogOptions, pkg: PackageConfiguration, sessionStart: Date, disposables: (() => Promise<unknown> | undefined)[], omitHeader?: boolean) {
const header = omitHeader ? undefined : `${pkg.name} ${pkg.version}. Node.js ${process.version}. ${os.platform()} ${os.release()} ${os.arch()}.`;
const output = createLogFrom(options, sessionStart, header);
output.dimensions = options.terminalDimensions;
output.onDidChangeDimensions = options.onDidChangeTerminalDimensions;
disposables.push(() => output.join());
return output;
}

function createLogFrom({ log: write, logLevel, logFormat }: LogOptions, sessionStart: Date, header: string | undefined = undefined): Log & { join(): Promise<void> } {
const handler = logFormat === 'json' ? createJSONLog(write, () => logLevel, sessionStart) :
process.stdin.isTTY ? createTerminalLog(write, () => logLevel, sessionStart) :
process.stdout.isTTY ? createTerminalLog(write, () => logLevel, sessionStart) :
createPlainLog(write, () => logLevel);
const log = {
...makeLog(createCombinedLog([handler], header)),
Expand Down
60 changes: 38 additions & 22 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createDockerParams, createLog, experimentalImageMetadataDefault, launch
import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels } from './utils';
import { URI } from 'vscode-uri';
import { ContainerError } from '../spec-common/errors';
import { Log, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log';
import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log';
import { probeRemoteEnv, runPostCreateCommands, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless';
import { bailOut, buildNamedImageAndExtend } from './singleContainer';
import { extendImage } from './containerFeatures';
Expand All @@ -23,7 +23,7 @@ import { workspaceFromPath } from '../spec-utils/workspaces';
import { readDevContainerConfigFile } from './configContainer';
import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils';
import { getCLIHost } from '../spec-common/cliHost';
import { loadNativeModule } from '../spec-common/commonUtils';
import { loadNativeModule, processSignals } from '../spec-common/commonUtils';
import { FeaturesConfig, generateFeaturesConfig, getContainerFeaturesFolder } from '../spec-configuration/containerFeaturesConfiguration';
import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test';
import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package';
Expand All @@ -35,6 +35,7 @@ import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContain
import { templatesPublishHandler, templatesPublishOptions } from './templatesCLI/publish';
import { templateApplyHandler, templateApplyOptions } from './templatesCLI/apply';
import { featuresInfoManifestHandler, featuresInfoManifestOptions } from './featuresCLI/infoManifest';
import { Event, NodeEventEmitter } from '../spec-utils/event';

const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';

Expand Down Expand Up @@ -1075,8 +1076,9 @@ function execHandler(args: ExecArgs) {

async function exec(args: ExecArgs) {
const result = await doExec(args);
const exitCode = result.outcome === 'error' ? 1 : 0;
console.log(JSON.stringify(result));
const exitCode = typeof result.code === 'number' && (result.code || !result.signal) ? result.code :
typeof result.signal === 'number' && result.signal > 0 ? 128 + result.signal : // 128 + signal number convention: https://tldp.org/LDP/abs/html/exitcodes.html
typeof result.signal === 'string' && processSignals[result.signal] ? 128 + processSignals[result.signal]! : 1;
await result.dispose();
process.exit(exitCode);
}
Expand Down Expand Up @@ -1107,6 +1109,8 @@ export async function doExec({
const dispose = async () => {
await Promise.all(disposables.map(d => d()));
};
let output: Log | undefined;
const isTTY = process.stdin.isTTY && process.stdout.isTTY || logFormat === 'json'; // If stdin or stdout is a pipe, we don't want to use a PTY.
try {
const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined;
const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined;
Expand All @@ -1125,7 +1129,8 @@ export async function doExec({
logLevel: mapLogLevel(logLevel),
logFormat,
log: text => process.stderr.write(text),
terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined,
terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : isTTY ? { columns: process.stdout.columns, rows: process.stdout.rows } : undefined,
onDidChangeTerminalDimensions: terminalColumns && terminalRows ? undefined : isTTY ? createStdoutResizeEmitter(disposables) : undefined,
defaultUserEnvProbe,
removeExistingContainer: false,
buildNoCache: false,
Expand All @@ -1151,7 +1156,8 @@ export async function doExec({
}, disposables);

const { common } = params;
const { cliHost, output } = common;
const { cliHost } = common;
output = common.output;
const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined;
const configPath = configFile ? configFile : workspace
? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath)
Expand All @@ -1178,29 +1184,39 @@ export async function doExec({
const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig);
const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig);
const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder;
const infoOutput = makeLog(output, LogLevel.Info);
await runRemoteCommand({ ...common, output: infoOutput }, containerProperties, restArgs || [], remoteCwd, { remoteEnv: await remoteEnv, print: 'continuous' });

await runRemoteCommand({ ...common, output, stdin: process.stdin, ...(logFormat !== 'json' ? { stdout: process.stdout, stderr: process.stderr } : {}) }, containerProperties, restArgs || [], remoteCwd, { remoteEnv: await remoteEnv, pty: isTTY, print: 'continuous' });
return {
outcome: 'success' as 'success',
code: 0,
dispose,
};

} catch (originalError) {
const originalStack = originalError?.stack;
const err = originalError instanceof ContainerError ? originalError : new ContainerError({
description: 'An error occurred running a command in the container.',
originalError
});
if (originalStack) {
console.error(originalStack);
} catch (err) {
if (!err?.code && !err?.signal) {
if (output) {
output.write(err?.stack || err?.message || String(err), LogLevel.Error);
} else {
console.error(err?.stack || err?.message || String(err));
}
}
return {
outcome: 'error' as 'error',
message: err.message,
description: err.description,
containerId: err.containerId,
code: err?.code as number | undefined,
signal: err?.signal as string | number | undefined,
dispose,
};
}
}

function createStdoutResizeEmitter(disposables: (() => Promise<unknown> | void)[]): Event<LogDimensions> {
const resizeListener = () => {
emitter.fire({
rows: process.stdout.rows,
columns: process.stdout.columns
});
};
const emitter = new NodeEventEmitter<LogDimensions>({
on: () => process.stdout.on('resize', resizeListener),
off: () => process.stdout.off('resize', resizeListener),
});
disposables.push(() => emitter.dispose());
return emitter.event;
}
2 changes: 1 addition & 1 deletion src/spec-node/featuresCLI/testCommandImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ async function exec(testCommandArgs: FeaturesTestCommandInput, cmd: string, args
]
};
const result = await doExec(execArgs);
return (result.outcome === 'success');
return (!result.code && !result.signal);
}

async function generateDockerParams(workspaceFolder: string, args: FeaturesTestCommandInput): Promise<DockerResolverParameters> {
Expand Down

0 comments on commit e4a7b11

Please sign in to comment.