Skip to content

Commit

Permalink
refactor: introduce internal bound context (#397)
Browse files Browse the repository at this point in the history
  • Loading branch information
antongolub committed May 26, 2022
1 parent 6ba2345 commit a741743
Show file tree
Hide file tree
Showing 20 changed files with 831 additions and 456 deletions.
26 changes: 26 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 Expand Up @@ -434,6 +438,28 @@ import {withTimeout} from 'zx/experimental'
await withTimeout(100, 'SIGTERM')`sleep 9999`
```
### `getCtx()` and `runInCtx()`
[async_hooks](https://nodejs.org/api/async_hooks.html) methods to manipulate bound context.
This object is used by zx inners, so it has a significant impact on the call mechanics. Please use this carefully and wisely.
```js
import {getCtx, runInCtx} from 'zx/experimental'

runInCtx({ ...getCtx() }, async () => {
await sleep(10)
cd('/foo')
// $.cwd refers to /foo
// getCtx().cwd === $.cwd
})

runInCtx({ ...getCtx() }, async () => {
await sleep(20)
// $.cwd refers to /foo
// but getCtx().cwd !== $.cwd
})
```
## FAQ
### Passing env variables
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)
}
280 changes: 280 additions & 0 deletions src/core.mjs
@@ -0,0 +1,280 @@
// 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'

const psTree = promisify(psTreeModule)

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]
}

0 comments on commit a741743

Please sign in to comment.