Skip to content

Commit

Permalink
refactor: separate core (#733)
Browse files Browse the repository at this point in the history
* chore: inject zurk

* chore: handle timeouts via zurk

* chore: override zurk quote

* chore: use zurk response to build ProcessOutput

* chore: linting

* chore: use buildCmd from zurk

* feat: provide preset api

closes #600

* docs: add some comments

* build: move zurk to vendor bundle

* chore: move zurk to dev deps

* feat: process quiet as preset option

* refactor: use zurk/spawn

* chore: linting

* build: update esbuild
  • Loading branch information
antongolub committed Mar 17, 2024
1 parent be0d674 commit aeec7ae
Show file tree
Hide file tree
Showing 7 changed files with 367 additions and 431 deletions.
556 changes: 215 additions & 341 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Expand Up @@ -55,7 +55,7 @@
},
"optionalDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": ">=20.11.19"
"@types/node": ">=20.11.28"
},
"devDependencies": {
"@stryker-mutator/core": "^6.4.2",
Expand All @@ -67,9 +67,9 @@
"c8": "^7.13.0",
"chalk": "^5.3.0",
"dts-bundle-generator": "^9.3.1",
"esbuild": "^0.20.1",
"esbuild": "^0.20.2",
"esbuild-node-externals": "^1.13.0",
"esbuild-plugin-entry-chunks": "^0.1.8",
"esbuild-plugin-entry-chunks": "^0.1.11",
"fs-extra": "^11.2.0",
"fx": "*",
"globby": "^14.0.1",
Expand All @@ -82,7 +82,8 @@
"typescript": "^5.0.4",
"webpod": "^0",
"which": "^3.0.0",
"yaml": "^2.3.4"
"yaml": "^2.3.4",
"zurk": "^0.0.27"
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com"
Expand Down
1 change: 1 addition & 0 deletions scripts/build-dts.mjs
Expand Up @@ -37,6 +37,7 @@ const entry = {
'@types/minimist',
'@types/ps-tree',
'@types/which',
'zurk',
], // args['external-inlines'],
},
output: {
Expand Down
221 changes: 137 additions & 84 deletions src/core.ts
Expand Up @@ -13,11 +13,13 @@
// limitations under the License.

import assert from 'node:assert'
import { ChildProcess, spawn, StdioNull, StdioPipe } from 'node:child_process'
import { spawn, StdioNull, StdioPipe } from 'node:child_process'
import { AsyncLocalStorage, createHook } from 'node:async_hooks'
import { Readable, Writable } from 'node:stream'
import { inspect } from 'node:util'
import {
exec,
buildCmd,
chalk,
which,
type ChalkInstance,
Expand All @@ -36,10 +38,10 @@ import {
quotePowerShell,
} from './util.js'

export type Shell = (
pieces: TemplateStringsArray,
...args: any[]
) => ProcessPromise
export interface Shell {
(pieces: TemplateStringsArray, ...args: any[]): ProcessPromise
(opts: Partial<Options>): Shell
}

const processCwd = Symbol('processCwd')

Expand All @@ -49,8 +51,10 @@ export interface Options {
verbose: boolean
env: NodeJS.ProcessEnv
shell: string | boolean
nothrow: boolean
prefix: string
quote: typeof quote
quiet: boolean
spawn: typeof spawn
log: typeof log
}
Expand All @@ -70,20 +74,22 @@ export const defaults: Options = {
verbose: true,
env: process.env,
shell: true,
nothrow: false,
quiet: false,
prefix: '',
quote: () => {
throw new Error('No quote function is defined: https://ï.at/no-quote-func')
},
spawn,
log,
}

const isWin = process.platform == 'win32'
try {
defaults.shell = which.sync('bash')
defaults.prefix = 'set -euo pipefail;'
defaults.quote = quote
} catch (err) {
if (process.platform == 'win32') {
if (isWin) {
try {
defaults.shell = which.sync('powershell.exe')
defaults.quote = quotePowerShell
Expand All @@ -97,25 +103,28 @@ function getStore() {
return storage.getStore() || defaults
}

export const $ = new Proxy<Shell & Options>(
export const $: Shell & Options = new Proxy<Shell & Options>(
function (pieces, ...args) {
if (!Array.isArray(pieces)) {
return function (this: any, ...args: any) {
const self = this
return within(() => {
return Object.assign($, pieces).apply(self, args)
})
}
}
const from = new Error().stack!.split(/^\s*at\s/m)[2].trim()
if (pieces.some((p) => p == undefined)) {
throw new Error(`Malformed command at ${from}`)
}
let resolve: Resolve, reject: Resolve
const promise = new ProcessPromise((...args) => ([resolve, reject] = args))
let cmd = pieces[0],
i = 0
while (i < args.length) {
let s
if (Array.isArray(args[i])) {
s = args[i].map((x: any) => $.quote(substitute(x))).join(' ')
} else {
s = $.quote(substitute(args[i]))
}
cmd += s + pieces[++i]
}
const cmd = buildCmd(
$.quote,
pieces as TemplateStringsArray,
args
) as string

promise._bind(cmd, from, resolve!, reject!, getStore())
// Postpone run to allow promise configuration.
setImmediate(() => promise.isHalted || promise.run())
Expand Down Expand Up @@ -145,20 +154,20 @@ type Resolve = (out: ProcessOutput) => void
type IO = StdioPipe | StdioNull

export class ProcessPromise extends Promise<ProcessOutput> {
child?: ChildProcess
private _command = ''
private _from = ''
private _resolve: Resolve = noop
private _reject: Resolve = noop
private _snapshot = getStore()
private _stdio: [IO, IO, IO] = ['inherit', 'pipe', 'pipe']
private _nothrow = false
private _quiet = false
private _nothrow?: boolean
private _quiet?: boolean
private _timeout?: number
private _timeoutSignal?: string
private _timeoutSignal = 'SIGTERM'
private _resolved = false
private _halted = false
private _piped = false
private zurk: ReturnType<typeof exec> | null = null
_prerun = noop
_postrun = noop

Expand All @@ -178,80 +187,89 @@ export class ProcessPromise extends Promise<ProcessOutput> {

run(): ProcessPromise {
const $ = this._snapshot
const self = this
if (this.child) return this // The _run() can be called from a few places.
this._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run().

$.log({
kind: 'cmd',
cmd: this._command,
verbose: $.verbose && !this._quiet,
verbose: self.isVerbose(),
})
this.child = $.spawn($.prefix + this._command, {

this.zurk = exec({
cmd: $.prefix + this._command,
cwd: $.cwd ?? $[processCwd],
shell: typeof $.shell === 'string' ? $.shell : true,
stdio: this._stdio,
windowsHide: true,
env: $.env,
spawn: $.spawn,
stdio: this._stdio as any,
sync: false,
detached: !isWin,
run: (cb) => cb(),
on: {
start: () => {
if (self._timeout) {
const t = setTimeout(
() => self.kill(self._timeoutSignal),
self._timeout
)
self.finally(() => clearTimeout(t)).catch(noop)
}
},
stdout: (data) => {
// If process is piped, don't print output.
if (self._piped) return
$.log({ kind: 'stdout', data, verbose: self.isVerbose() })
},
stderr: (data) => {
// Stderr should be printed regardless of piping.
$.log({ kind: 'stderr', data, verbose: self.isVerbose() })
},
end: ({ error, stdout, stderr, stdall, status, signal }) => {
self._resolved = true

if (error) {
const message = ProcessOutput.getErrorMessage(error, self._from)
// Should we enable this?
// (nothrow ? self._resolve : self._reject)(
self._reject(
new ProcessOutput(null, null, stdout, stderr, stdall, message)
)
} else {
const message = ProcessOutput.getExitMessage(
status,
signal,
stderr,
self._from
)
const output = new ProcessOutput(
status,
signal,
stdout,
stderr,
stdall,
message
)
if (status === 0 || (self._nothrow ?? $.nothrow)) {
self._resolve(output)
} else {
self._reject(output)
}
}
},
},
})
this.child.on('close', (code, signal) => {
let message = `exit code: ${code}`
if (code != 0 || signal != null) {
message = `${stderr || '\n'} at ${this._from}`
message += `\n exit code: ${code}${
exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : ''
}`
if (signal != null) {
message += `\n signal: ${signal}`
}
}
let output = new ProcessOutput(
code,
signal,
stdout,
stderr,
combined,
message
)
if (code === 0 || this._nothrow) {
this._resolve(output)
} else {
this._reject(output)
}
this._resolved = true
})
this.child.on('error', (err: NodeJS.ErrnoException) => {
const message =
`${err.message}\n` +
` errno: ${err.errno} (${errnoMessage(err.errno)})\n` +
` code: ${err.code}\n` +
` at ${this._from}`
this._reject(
new ProcessOutput(null, null, stdout, stderr, combined, message)
)
this._resolved = true
})
let stdout = '',
stderr = '',
combined = ''
let onStdout = (data: any) => {
$.log({ kind: 'stdout', data, verbose: $.verbose && !this._quiet })
stdout += data
combined += data
}
let onStderr = (data: any) => {
$.log({ kind: 'stderr', data, verbose: $.verbose && !this._quiet })
stderr += data
combined += data
}
if (!this._piped) this.child.stdout?.on('data', onStdout) // If process is piped, don't collect or print output.
this.child.stderr?.on('data', onStderr) // Stderr should be printed regardless of piping.

this._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin.
if (this._timeout && this._timeoutSignal) {
const t = setTimeout(() => this.kill(this._timeoutSignal), this._timeout)
this.finally(() => clearTimeout(t)).catch(noop)
}

return this
}

get child() {
return this.zurk?.child
}

get stdin(): Writable {
this.stdio('pipe')
this.run()
Expand Down Expand Up @@ -340,14 +358,15 @@ export class ProcessPromise extends Promise<ProcessOutput> {
if (!this.child)
throw new Error('Trying to kill a process without creating one.')
if (!this.child.pid) throw new Error('The process pid is undefined.')

let children = await psTree(this.child.pid)
for (const p of children) {
try {
process.kill(+p.PID, signal)
} catch (e) {}
}
try {
process.kill(this.child.pid, signal)
process.kill(-this.child.pid, signal)
} catch (e) {}
}

Expand All @@ -366,6 +385,11 @@ export class ProcessPromise extends Promise<ProcessOutput> {
return this
}

isVerbose(): boolean {
const { verbose, quiet } = this._snapshot
return verbose && !(this._quiet ?? quiet)
}

timeout(d: Duration, signal = 'SIGTERM'): ProcessPromise {
this._timeout = parseDuration(d)
this._timeoutSignal = signal
Expand Down Expand Up @@ -425,6 +449,35 @@ export class ProcessOutput extends Error {
return this._signal
}

static getExitMessage(
code: number | null,
signal: NodeJS.Signals | null,
stderr: string,
from: string
) {
let message = `exit code: ${code}`
if (code != 0 || signal != null) {
message = `${stderr || '\n'} at ${from}`
message += `\n exit code: ${code}${
exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : ''
}`
if (signal != null) {
message += `\n signal: ${signal}`
}
}

return message
}

static getErrorMessage(err: NodeJS.ErrnoException, from: string) {
return (
`${err.message}\n` +
` errno: ${err.errno} (${errnoMessage(err.errno)})\n` +
` code: ${err.code}\n` +
` at ${from}`
)
}

[inspect.custom]() {
let stringify = (s: string, c: ChalkInstance) =>
s.length === 0 ? "''" : c(inspect(s))
Expand Down

0 comments on commit aeec7ae

Please sign in to comment.