Skip to content

Instantly share code, notes, and snippets.

@MihaelIsaev
Created October 23, 2024 17:32
Show Gist options
  • Save MihaelIsaev/95cda4291552af5489be419386aa7e12 to your computer and use it in GitHub Desktop.
Save MihaelIsaev/95cda4291552af5489be419386aa7e12 to your computer and use it in GitHub Desktop.
Node.js multiple persistent shells instead of multiple spawns
///
// 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