Commit 971cbfc

Anton Golub <antongolub@antongolub.com>
2024-09-19 16:44:10
perf: optimize `ProsessOutput` mem consumption (#903)
* perf: optimize `ProsessOutput` mem consumption * style: simplify process end handler * chore(types): define `ProcessOutput` overload signature
1 parent bfff0ef
src/core.ts
@@ -44,6 +44,7 @@ import {
   isString,
   isStringLiteral,
   noop,
+  once,
   parseDuration,
   preferLocalBin,
   quote,
@@ -320,52 +321,50 @@ export class ProcessPromise extends Promise<ProcessOutput> {
           // Stderr should be printed regardless of piping.
           $.log({ kind: 'stderr', data, verbose: !self.isQuiet() })
         },
-        end: (
-          { error, stdout, stderr, stdall, status, signal, duration },
-          c
-        ) => {
+        // prettier-ignore
+        end: (data, c) => {
           self._resolved = true
-          // Ensures EOL
-          if (stderr && !stderr.endsWith('\n')) c.on.stderr?.(eol, c)
-          if (stdout && !stdout.endsWith('\n')) c.on.stdout?.(eol, c)
+          const { error, status, signal, duration, ctx } = data
+          const { stdout, stderr, stdall } = ctx.store
+
+          // Lazy getters
+          const _stdout = once(() => stdout.join(''))
+          const _stderr = once(() => stderr.join(''))
+          const _stdall = once(() => stdall.join(''))
+          const _duration = () => duration
+          let _code = () => status
+          let _signal = () => signal
+          let _message = once(() => ProcessOutput.getExitMessage(
+            status,
+            signal,
+            _stderr(),
+            self._from
+          ))
 
+          // Ensures EOL
+          if (stdout.length && !stdout[stdout.length - 1]?.toString().endsWith('\n')) c.on.stdout?.(eol, c)
+          if (stderr.length && !stderr[stderr.length - 1]?.toString().endsWith('\n')) c.on.stderr?.(eol, c)
           if (error) {
-            const message = ProcessOutput.getErrorMessage(error, self._from)
-            // Should we enable this?
-            // (nothrow ? self._resolve : self._reject)(
-            const output = new ProcessOutput(
-              null,
-              null,
-              stdout,
-              stderr,
-              stdall,
-              message,
-              duration
-            )
-            self._output = output
+            _code = () => null
+            _signal = () => null
+            _message = () => ProcessOutput.getErrorMessage(error, self._from)
+          }
+
+          const output = new ProcessOutput({
+            code: _code,
+            signal: _signal,
+            stdout: _stdout,
+            stderr: _stderr,
+            stdall: _stdall,
+            message: _message,
+            duration: _duration
+          })
+          self._output = output
+
+          if (error || status !== 0 && !self.isNothrow()) {
             self._reject(output)
           } else {
-            const message = ProcessOutput.getExitMessage(
-              status,
-              signal,
-              stderr,
-              self._from
-            )
-            const output = new ProcessOutput(
-              status,
-              signal,
-              stdout,
-              stderr,
-              stdall,
-              message,
-              duration
-            )
-            self._output = output
-            if (status === 0 || self.isNothrow()) {
-              self._resolve(output)
-            } else {
-              self._reject(output)
-            }
+            self._resolve(output)
           }
         },
       },
@@ -572,14 +571,27 @@ export class ProcessPromise extends Promise<ProcessOutput> {
   }
 }
 
+type GettersRecord<T extends Record<any, any>> = { [K in keyof T]: () => T[K] }
+
+type ProcessOutputLazyDto = GettersRecord<{
+  code: number | null
+  signal: NodeJS.Signals | null
+  stdout: string
+  stderr: string
+  stdall: string
+  message: string
+  duration: number
+}>
+
 export class ProcessOutput extends Error {
-  private readonly _code: number | null
+  private readonly _code: number | null = null
   private readonly _signal: NodeJS.Signals | null
   private readonly _stdout: string
   private readonly _stderr: string
   private readonly _combined: string
   private readonly _duration: number
 
+  constructor(dto: ProcessOutputLazyDto)
   constructor(
     code: number | null,
     signal: NodeJS.Signals | null,
@@ -587,15 +599,36 @@ export class ProcessOutput extends Error {
     stderr: string,
     combined: string,
     message: string,
+    duration?: number
+  )
+  constructor(
+    code: number | null | ProcessOutputLazyDto,
+    signal: NodeJS.Signals | null = null,
+    stdout: string = '',
+    stderr: string = '',
+    combined: string = '',
+    message: string = '',
     duration: number = 0
   ) {
     super(message)
-    this._code = code
     this._signal = signal
     this._stdout = stdout
     this._stderr = stderr
     this._combined = combined
     this._duration = duration
+    if (code !== null && typeof code === 'object') {
+      Object.defineProperties(this, {
+        _code: { get: code.code },
+        _signal: { get: code.signal },
+        _duration: { get: code.duration },
+        _stdout: { get: code.stdout },
+        _stderr: { get: code.stderr },
+        _combined: { get: code.stdall },
+        message: { get: code.message },
+      })
+    } else {
+      this._code = code
+    }
   }
 
   toString() {
src/util.ts
@@ -449,3 +449,13 @@ export function getCallerLocationFromString(stackString = 'unknown') {
       ?.trim() || stackString
   )
 }
+
+export const once = <T extends (...args: any[]) => any>(fn: T) => {
+  let called = false
+  let result: ReturnType<T>
+  return (...args: Parameters<T>): ReturnType<T> => {
+    if (called) return result
+    called = true
+    return (result = fn(...args))
+  }
+}
test-d/core.test-d.ts
@@ -14,7 +14,7 @@
 
 import assert from 'node:assert'
 import { Readable, Writable } from 'node:stream'
-import { expectType } from 'tsd'
+import { expectError, expectType } from 'tsd'
 import { $, ProcessPromise, ProcessOutput, within } from 'zx'
 
 let p = $`cmd`
@@ -40,5 +40,19 @@ expectType<string>(o.stdout)
 expectType<string>(o.stderr)
 expectType<number | null>(o.exitCode)
 expectType<NodeJS.Signals | null>(o.signal)
+// prettier-ignore
+expectType<ProcessOutput>(new ProcessOutput({
+  code() { return null },
+  signal() { return null },
+  stdall() { return '' },
+  stderr() { return '' },
+  stdout() { return '' },
+  duration() { return 0 },
+  message() { return '' },
+}))
+
+expectType<ProcessOutput>(new ProcessOutput(null, null, '', '', '', '', 1))
+expectType<ProcessOutput>(new ProcessOutput(null, null, '', '', '', ''))
+expectError(new ProcessOutput(null, null))
 
 expectType<'banana'>(within(() => 'apple' as 'banana'))
.size-limit.json
@@ -2,21 +2,21 @@
   {
     "name": "zx/core",
     "path": ["build/core.cjs", "build/util.cjs", "build/vendor-core.cjs"],
-    "limit": "70 kB",
+    "limit": "71 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "zx/index",
     "path": "build/*.{js,cjs}",
-    "limit": "796 kB",
+    "limit": "797 kB",
     "brotli": false,
     "gzip": false
   },
   {
     "name": "dts libdefs",
     "path": "build/*.d.ts",
-    "limit": "35 kB",
+    "limit": "36 kB",
     "brotli": false,
     "gzip": false
   },
@@ -30,7 +30,7 @@
   {
     "name": "all",
     "path": "build/*",
-    "limit": "830 kB",
+    "limit": "832 kB",
     "brotli": false,
     "gzip": false
   }
package-lock.json
@@ -46,7 +46,7 @@
         "typescript": "^5.6.2",
         "which": "^4.0.0",
         "yaml": "^2.5.1",
-        "zurk": "^0.3.2"
+        "zurk": "^0.3.4"
       },
       "engines": {
         "node": ">= 12.17.0"
@@ -5557,9 +5557,9 @@
       }
     },
     "node_modules/zurk": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/zurk/-/zurk-0.3.2.tgz",
-      "integrity": "sha512-bMihBxEc0ECXBfZbC3se+4E8jc07QvHh8stapRvPR1hwp8Jq27B7QS/sGqCwuwFWWe7EkK3IwXhhVYEgm0ghuw==",
+      "version": "0.3.4",
+      "resolved": "https://registry.npmjs.org/zurk/-/zurk-0.3.4.tgz",
+      "integrity": "sha512-Mu0uXIAgYezo9zprkW/jzjgBo6jkCCGNPpdpQr4W5aRUTDRHCcy9KvOY+cKg6XYfPp0ArqsQsFbeZSf2f8Ka6Q==",
       "dev": true,
       "license": "MIT"
     }
package.json
@@ -125,7 +125,7 @@
     "typescript": "^5.6.2",
     "which": "^4.0.0",
     "yaml": "^2.5.1",
-    "zurk": "^0.3.2"
+    "zurk": "^0.3.4"
   },
   "publishConfig": {
     "registry": "https://wombat-dressing-room.appspot.com"