Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: introduce internal bound context #397

Merged
merged 20 commits into from May 26, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Expand Up @@ -359,6 +359,10 @@ outputs.

Or use a CLI argument `--quiet` to set `$.verbose = false`.

### `$.env`

Specifies env map. Defaults to `process.env`.

## Polyfills

### `__filename` & `__dirname`
Expand Down
10 changes: 7 additions & 3 deletions package.json
Expand Up @@ -9,6 +9,7 @@
"./globals": "./src/globals.mjs",
"./experimental": "./src/experimental.mjs",
"./cli": "./zx.mjs",
"./core": "./src/core.mjs",
"./package.json": "./package.json"
},
"bin": {
Expand All @@ -18,8 +19,10 @@
"node": ">= 16.0.0"
},
"scripts": {
"test:cov": "c8 --reporter=html npm run test",
"test": "node zx.mjs test/full.test.mjs",
"lint": "prettier --single-quote --no-semi --write src test",
"test": "npm run lint && npm run test:unit",
"test:unit": "node zx.mjs test/full.test.mjs",
"test:cov": "c8 --reporter=html npm run test:unit",
"test:zx": "npm run test zx",
"test:index": "npm run test index"
},
Expand All @@ -38,7 +41,8 @@
"yaml": "^2.0.1"
},
"devDependencies": {
"c8": "^7.11.2"
"c8": "^7.11.2",
"prettier": "^2.6.2"
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com"
Expand Down
33 changes: 33 additions & 0 deletions src/context.mjs
@@ -0,0 +1,33 @@
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { AsyncLocalStorage } from 'node:async_hooks'

let root

const storage = new AsyncLocalStorage()

export function getCtx() {
return storage.getStore()
}
export function setRootCtx(ctx) {
storage.enterWith(ctx)
root = ctx
}
export function getRootCtx() {
return root
}
export function runInCtx(ctx, cb) {
return storage.run(ctx, cb)
}
281 changes: 281 additions & 0 deletions src/core.mjs
@@ -0,0 +1,281 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { inspect, promisify } from 'node:util'
import { spawn } from 'node:child_process'
import { chalk, which } from './goods.mjs'
import { getCtx, runInCtx, setRootCtx } from './context.mjs'
import { printStd, printCmd } from './print.mjs'
import { formatCmd, quote } from './guards.mjs'
import psTreeModule from 'ps-tree'

export { getCtx, runInCtx }
antongolub marked this conversation as resolved.
Show resolved Hide resolved
export const psTree = promisify(psTreeModule)
antongolub marked this conversation as resolved.
Show resolved Hide resolved

export function $(...args) {
let resolve, reject
let promise = new ProcessPromise((...args) => ([resolve, reject] = args))

promise.ctx = {
...getCtx(),
cmd: formatCmd(...args),
__from: new Error().stack.split(/^\s*at\s/m)[2].trim(),
resolve,
reject,
}

setImmediate(() => promise._run()) // Make sure all subprocesses are started, if not explicitly by await or then().

return promise
}

setRootCtx($)

$.cwd = process.cwd()
$.env = process.env
$.quote = quote
$.spawn = spawn
$.verbose = true
$.maxBuffer = 200 * 1024 * 1024 /* 200 MiB*/
$.prefix = '' // Bash not found, no prefix.
try {
$.shell = which.sync('bash')
$.prefix = 'set -euo pipefail;'
} catch (e) {}

export class ProcessPromise extends Promise {
child = undefined
_resolved = false
_inheritStdin = true
_piped = false
_prerun = undefined
_postrun = undefined

get stdin() {
this._inheritStdin = false
this._run()
return this.child.stdin
}

get stdout() {
this._inheritStdin = false
this._run()
return this.child.stdout
}

get stderr() {
this._inheritStdin = false
this._run()
return this.child.stderr
}

get exitCode() {
return this.then(
(p) => p.exitCode,
(p) => p.exitCode
)
}

pipe(dest) {
if (typeof dest === 'string') {
throw new Error('The pipe() method does not take strings. Forgot $?')
}
if (this._resolved === true) {
throw new Error(
"The pipe() method shouldn't be called after promise is already resolved!"
)
}
this._piped = true
if (dest instanceof ProcessPromise) {
dest._inheritStdin = false
dest._prerun = this._run.bind(this)
dest._postrun = () => this.stdout.pipe(dest.child.stdin)
return dest
} else {
this._postrun = () => this.stdout.pipe(dest)
return this
}
}

async kill(signal = 'SIGTERM') {
this.catch((_) => _)
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)
} catch (e) {}
}

_run() {
if (this.child) return // The _run() called from two places: then() and setTimeout().
if (this._prerun) this._prerun() // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run().

const ctx = this.ctx
runInCtx(ctx, () => {
const {
nothrow,
cmd,
cwd,
env,
prefix,
shell,
maxBuffer,
__from,
resolve,
reject,
} = ctx

printCmd(cmd)

let child = spawn(prefix + cmd, {
cwd,
shell: typeof shell === 'string' ? shell : true,
stdio: [this._inheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
windowsHide: true,
maxBuffer,
env,
})

child.on('close', (code, signal) => {
let message = `${stderr || '\n'} at ${__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,
})
;(code === 0 || nothrow ? resolve : reject)(output)
this._resolved = true
})

let stdout = '',
stderr = '',
combined = ''
let onStdout = (data) => {
printStd(data)
stdout += data
combined += data
}
let onStderr = (data) => {
printStd(null, data)
stderr += data
combined += data
}
if (!this._piped) child.stdout.on('data', onStdout) // If process is piped, don't collect or print output.
child.stderr.on('data', onStderr) // Stderr should be printed regardless of piping.
this.child = child
if (this._postrun) this._postrun() // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin.
})
}
}

export class ProcessOutput extends Error {
#code = null
#signal = null
#stdout = ''
#stderr = ''
#combined = ''

constructor({ code, signal, stdout, stderr, combined, message }) {
super(message)
this.#code = code
this.#signal = signal
this.#stdout = stdout
this.#stderr = stderr
this.#combined = combined
}

toString() {
return this.#combined
}

get stdout() {
return this.#stdout
}

get stderr() {
return this.#stderr
}

get exitCode() {
return this.#code
}

get signal() {
return this.#signal
}

[inspect.custom]() {
let stringify = (s, c) => (s.length === 0 ? "''" : c(inspect(s)))
return `ProcessOutput {
stdout: ${stringify(this.stdout, chalk.green)},
stderr: ${stringify(this.stderr, chalk.red)},
signal: ${inspect(this.signal)},
exitCode: ${(this.exitCode === 0 ? chalk.green : chalk.red)(this.exitCode)}${
exitCodeInfo(this.exitCode)
? chalk.grey(' (' + exitCodeInfo(this.exitCode) + ')')
: ''
}
}`
}
}

function exitCodeInfo(exitCode) {
return {
2: 'Misuse of shell builtins',
126: 'Invoked command cannot execute',
127: 'Command not found',
128: 'Invalid exit argument',
129: 'Hangup',
130: 'Interrupt',
131: 'Quit and dump core',
132: 'Illegal instruction',
133: 'Trace/breakpoint trap',
134: 'Process aborted',
135: 'Bus error: "access to undefined portion of memory object"',
136: 'Floating point exception: "erroneous arithmetic operation"',
137: 'Kill (terminate immediately)',
138: 'User-defined 1',
139: 'Segmentation violation',
140: 'User-defined 2',
141: 'Write to pipe with no one reading',
142: 'Signal raised by alarm',
143: 'Termination (request to terminate)',
145: 'Child process terminated, stopped (or continued*)',
146: 'Continue if stopped',
147: 'Stop executing temporarily',
148: 'Terminal stop signal',
149: 'Background process attempting to read from tty ("in")',
150: 'Background process attempting to write to tty ("out")',
151: 'Urgent data available on socket',
152: 'CPU time limit exceeded',
153: 'File size limit exceeded',
154: 'Signal raised by timer counting virtual time: "virtual timer expired"',
155: 'Profiling timer expired',
157: 'Pollable event',
159: 'Bad syscall',
}[exitCode]
}