Created
October 23, 2024 17:32
-
-
Save MihaelIsaev/95cda4291552af5489be419386aa7e12 to your computer and use it in GitHub Desktop.
Node.js multiple persistent shells instead of multiple spawns
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// | |
// This file is MIT licensed. | |
// This implementation in real life is actually slower than calling `spawn` multiple times. | |
// But I just want to save it as a concept. | |
// If you have ideas of how to improve its speed please feel free to contribute. | |
/// | |
import { ChildProcessWithoutNullStreams, exec, spawn } from 'child_process' | |
import { isNull } from 'util' | |
import { LogLevel, print } from './webber' | |
import { TimeMeasure } from './helpers/timeMeasureHelper' | |
export class Bash { | |
whichCache: {} = {} | |
async which(program: string): Promise<string | undefined> { | |
return new Promise<string | undefined>((resolve, reject) => { | |
const cachedPath = this.whichCache[program] | |
if (cachedPath && cachedPath.length > 0) { | |
return resolve(cachedPath) | |
} | |
exec(`/usr/bin/which ${program}`, (error, stdout, stderr) => { | |
if (error) { | |
// console.error(`Error: ${error.message}`) | |
// console.error(`Exit code: ${error.code}`) | |
// console.error(`stderr: ${stderr}`) | |
return resolve(undefined) | |
} | |
resolve(stdout.replace(/^\s+|\s+$/g, '')) | |
}) | |
}) | |
} | |
// Start a persistent shells (As many as needed) | |
shells: { isBusy: boolean, process: ChildProcessWithoutNullStreams }[] = [ | |
{ isBusy: false, process: spawn('/bin/bash') }, | |
{ isBusy: false, process: spawn('/bin/bash') }, | |
{ isBusy: false, process: spawn('/bin/bash') }, | |
{ isBusy: false, process: spawn('/bin/bash') } | |
] | |
freeShellQueue: {} = {} | |
queueNum = 0 | |
// Function to send a command with dynamic `cwd` and `env` | |
async runCommand(program: { path?: string | undefined, name?: string | undefined, description: string | undefined, stdoutHandler?: (data: string) => void | undefined, cwd?: string | undefined, env?: NodeJS.ProcessEnv | undefined }, args: string[] = []): Promise<BashResult> { | |
function executor(bash: Bash, shell: ChildProcessWithoutNullStreams, program): Promise<BashResult> { | |
return new Promise(async (resolve, reject) => { | |
var commandPath: string | undefined | |
if (program.path) { | |
commandPath = program.path | |
} else if (program.name) { | |
commandPath = await bash.which(program.name) | |
if (!commandPath) { | |
const bashError = new BashError({ error: `${program.name} is not available` }) | |
print(bashError.description) | |
return reject(bashError) | |
} | |
} | |
// Construct the environment variable string | |
const envCommands = program.env | |
? Object.keys(program.env).map((key) => `export ${key}="${program.env[key]}"`) | |
: [] | |
// Construct the full command, optionally changing the working directory and environment variables | |
let fullCommand = '' | |
if (program.cwd) { | |
fullCommand += `cd ${program.cwd} && ` | |
} | |
if (envCommands.length > 0) { | |
fullCommand += `${envCommands.join(' && ')} && ` | |
} | |
fullCommand += `${commandPath} ${args.join(' ')} ; echo ExitCode: $? ; echo __COMMAND_DONE__` | |
let timeMeasure = new TimeMeasure() | |
let output = '' | |
let errorOutput = '' | |
// Listen for stdout | |
const onData = (data) => { | |
const str = data.toString().trimEnd()//.replace(/^\s+|\s+$/g, '') | |
output += str | |
if (output.includes('__COMMAND_DONE__')) { | |
shell.stdout.off('data', onData) // Stop listening | |
const preresult = output.split('__COMMAND_DONE__')[0].trim() | |
const exitCodeMatch = output.match(/ExitCode: (\d+)/) | |
if (exitCodeMatch) { | |
timeMeasure.finish() | |
const exitCode = parseInt(exitCodeMatch[1], 10) | |
const result = output.split(`ExitCode: ${exitCode}`)[0].trim()//.replace(/^\s+|\s+$/g, '') | |
if (exitCode === 0) { | |
return resolve(new BashResult( | |
commandPath!, | |
timeMeasure.time, | |
exitCode, | |
errorOutput.trimEnd(),//.replace(/^\s+|\s+$/g, ''), | |
result.trimEnd(),//.replace(/^\s+|\s+$/g, ''), | |
program.description | |
)) | |
} else { | |
const bashError = new BashError({ error: `${program.name} is not available` }) | |
print(bashError.description) | |
return reject(bashError) | |
} | |
} | |
} else { | |
if (program.stdoutHandler) { | |
program.stdoutHandler(str) | |
} | |
} | |
} | |
// Listen for stderr | |
shell.stderr.on('data', (data) => { | |
errorOutput += data.toString() | |
}) | |
// Attach the stdout listener | |
shell.stdout.on('data', onData) | |
// Write the full command to the shell | |
shell.stdin.write(`${fullCommand}\n`) | |
}) | |
} | |
const freeShells = this.shells.filter((x) => !x.isBusy) | |
if (freeShells.length == 0) { | |
return new Promise((resolve ,reject) => { | |
const queueNum = this.queueNum + 1 | |
this.queueNum = queueNum | |
this.freeShellQueue[queueNum] = async (c: ChildProcessWithoutNullStreams) => { | |
this.freeShellQueue[queueNum] = undefined | |
try { | |
let bashResult = await executor(this, c, program) | |
resolve(bashResult) | |
} catch (error) { | |
reject(error) | |
} | |
} | |
}) | |
} else { | |
var shell = freeShells[0] | |
shell.isBusy = true | |
let bashResult = await executor(this, shell.process, program) | |
const queueKeys = Object.keys(this.freeShellQueue) | |
if (queueKeys.length > 0) { | |
const key = queueKeys[0] | |
this.freeShellQueue[key](shell.process) | |
} else { | |
shell.isBusy = false | |
} | |
return bashResult | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment