diff --git a/.size-limit.json b/.size-limit.json index 327282b975..568f910f99 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -16,7 +16,7 @@ { "name": "dts libdefs", "path": "build/*.d.ts", - "limit": "38.1 kB", + "limit": "38.7 kB", "brotli": false, "gzip": false }, @@ -30,7 +30,7 @@ { "name": "all", "path": "build/*", - "limit": "847.5 kB", + "limit": "849 kB", "brotli": false, "gzip": false } diff --git a/docs/process-promise.md b/docs/process-promise.md index d7eb400bab..8e3d95db37 100644 --- a/docs/process-promise.md +++ b/docs/process-promise.md @@ -14,6 +14,18 @@ const p = $({halt: true})`command` const o = await p.run() ``` +## `stage` + +Shows the current process stage: `initial` | `halted` | `running` | `fulfilled` | `rejected` + +```ts +const p = $`echo foo` +p.stage // 'running' +await p +p.stage // 'fulfilled' +``` + + ## `stdin` Returns a writable stream of the stdin process. Accessing diff --git a/src/core.ts b/src/core.ts index 4b228fc128..229ab7af7a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -203,6 +203,10 @@ export const $: Shell & Options = new Proxy( }, } ) +/** + * State machine stages + */ +type ProcessStage = 'initial' | 'halted' | 'running' | 'fulfilled' | 'rejected' type Resolve = (out: ProcessOutput) => void @@ -214,6 +218,7 @@ type PipeMethod = { } export class ProcessPromise extends Promise { + private _stage: ProcessStage = 'initial' private _id = randomId() private _command = '' private _from = '' @@ -225,11 +230,8 @@ export class ProcessPromise extends Promise { private _timeout?: number private _timeoutSignal?: NodeJS.Signals private _timeoutId?: NodeJS.Timeout - private _resolved = false - private _halted?: boolean private _piped = false private _pipedFrom?: ProcessPromise - private _run = false private _ee = new EventEmitter() private _stdin = new VoidStream() private _zurk: ReturnType | null = null @@ -249,12 +251,12 @@ export class ProcessPromise extends Promise { this._resolve = resolve this._reject = reject this._snapshot = { ac: new AbortController(), ...options } + if (this._snapshot.halt) this._stage = 'halted' } run(): ProcessPromise { - if (this._run) return this // The _run() can be called from a few places. - this._halted = false - this._run = true + if (this.isRunning() || this.isSettled()) return this // The _run() can be called from a few places. + this._stage = 'running' this._pipedFrom?.run() const self = this @@ -310,7 +312,6 @@ export class ProcessPromise extends Promise { $.log({ kind: 'stderr', data, verbose: !self.isQuiet(), id }) }, end: (data, c) => { - self._resolved = true const { error, status, signal, duration, ctx } = data const { stdout, stderr, stdall } = ctx.store const dto: ProcessOutputLazyDto = { @@ -341,8 +342,10 @@ export class ProcessPromise extends Promise { const output = self._output = new ProcessOutput(dto) if (error || status !== 0 && !self.isNothrow()) { + self._stage = 'rejected' self._reject(output) } else { + self._stage = 'fulfilled' self._resolve(output) } }, @@ -388,9 +391,9 @@ export class ProcessPromise extends Promise { for (const chunk of this._zurk!.store[source]) from.write(chunk) return true } - const fillEnd = () => this._resolved && fill() && from.end() + const fillEnd = () => this.isSettled() && fill() && from.end() - if (!this._resolved) { + if (!this.isSettled()) { const onData = (chunk: string | Buffer) => from.write(chunk) ee.once(source, () => { fill() @@ -495,6 +498,10 @@ export class ProcessPromise extends Promise { return this._output } + get stage(): ProcessStage { + return this._stage + } + // Configurators stdio( stdin: IOType, @@ -524,13 +531,13 @@ export class ProcessPromise extends Promise { d: Duration, signal = this._timeoutSignal || $.timeoutSignal ): ProcessPromise { - if (this._resolved) return this + if (this.isSettled()) return this this._timeout = parseDuration(d) this._timeoutSignal = signal if (this._timeoutId) clearTimeout(this._timeoutId) - if (this._timeout && this._run) { + if (this._timeout && this.isRunning()) { this._timeoutId = setTimeout( () => this.kill(this._timeoutSignal), this._timeout @@ -562,10 +569,6 @@ export class ProcessPromise extends Promise { } // Status checkers - isHalted(): boolean { - return this._halted ?? this._snapshot.halt ?? false - } - isQuiet(): boolean { return this._quiet ?? this._snapshot.quiet } @@ -578,6 +581,18 @@ export class ProcessPromise extends Promise { return this._nothrow ?? this._snapshot.nothrow } + isHalted(): boolean { + return this.stage === 'halted' + } + + private isSettled(): boolean { + return !!this.output + } + + private isRunning(): boolean { + return this.stage === 'running' + } + // Promise API then( onfulfilled?: diff --git a/test/core.test.js b/test/core.test.js index b8a7c6e807..218782c428 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -19,6 +19,7 @@ import { basename } from 'node:path' import { WriteStream } from 'node:fs' import { Readable, Transform, Writable } from 'node:stream' import { Socket } from 'node:net' +import { ChildProcess } from 'node:child_process' import { $, ProcessPromise, @@ -42,6 +43,7 @@ import { which, nothrow, } from '../build/index.js' +import { noop } from '../build/util.js' describe('core', () => { describe('resolveDefaults()', () => { @@ -392,6 +394,72 @@ describe('core', () => { }) describe('ProcessPromise', () => { + test('getters', async () => { + const p = $`echo foo` + assert.ok(p.pid > 0) + assert.ok(typeof p.id === 'string') + assert.ok(typeof p.cmd === 'string') + assert.ok(typeof p.fullCmd === 'string') + assert.ok(typeof p.stage === 'string') + assert.ok(p.child instanceof ChildProcess) + assert.ok(p.stdout instanceof Socket) + assert.ok(p.stderr instanceof Socket) + assert.ok(p.exitCode instanceof Promise) + assert.ok(p.signal instanceof AbortSignal) + assert.equal(p.output, null) + + await p + assert.ok(p.output instanceof ProcessOutput) + }) + + describe('state machine transitions', () => { + it('running > fulfilled', async () => { + const p = $`echo foo` + assert.equal(p.stage, 'running') + await p + assert.equal(p.stage, 'fulfilled') + }) + + it('running > rejected', async () => { + const p = $`foo` + assert.equal(p.stage, 'running') + + try { + await p + } catch {} + assert.equal(p.stage, 'rejected') + }) + + it('halted > running > fulfilled', async () => { + const p = $({ halt: true })`echo foo` + assert.equal(p.stage, 'halted') + p.run() + assert.equal(p.stage, 'running') + await p + assert.equal(p.stage, 'fulfilled') + }) + + it('all transition', async () => { + const { promise, resolve, reject } = Promise.withResolvers() + const process = new ProcessPromise(noop, noop) + + assert.equal(process.stage, 'initial') + process._bind('echo foo', 'test', resolve, reject, { + ...resolveDefaults(), + halt: true, + }) + + assert.equal(process.stage, 'halted') + process.run() + + assert.equal(process.stage, 'running') + await promise + + assert.equal(process.stage, 'fulfilled') + assert.equal(process.output?.stdout, 'foo\n') + }) + }) + test('inherits native Promise', async () => { const p1 = $`echo 1` const p2 = p1.then((v) => v) @@ -424,12 +492,6 @@ describe('core', () => { assert.equal(p.fullCmd, "set -euo pipefail;echo $'#bar' --t 1") }) - test('exposes pid & id', () => { - const p = $`echo foo` - assert.ok(p.pid > 0) - assert.ok(typeof p.id === 'string') - }) - test('stdio() works', async () => { const p1 = $`printf foo` await p1