Commit f14d8b2

Anton Medvedev <anton@medv.io>
2022-07-01 20:26:13
Add error event handler
1 parent ef50a5c
src/core.ts
@@ -22,6 +22,7 @@ import { chalk, which } from './goods.js'
 import { log } from './log.js'
 import {
   Duration,
+  errnoMessage,
   exitCodeInfo,
   noop,
   parseDuration,
@@ -193,6 +194,17 @@ export class ProcessPromise extends Promise<ProcessOutput> {
       }
       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 = ''
src/util.ts
@@ -99,6 +99,135 @@ export function exitCodeInfo(exitCode: number | null): string | undefined {
   }[exitCode || -1]
 }
 
+export function errnoMessage(errno: number | undefined): string {
+  if (errno === undefined) {
+    return 'Unknown error'
+  }
+  return (
+    {
+      0: 'Success',
+      1: 'Not super-user',
+      2: 'No such file or directory',
+      3: 'No such process',
+      4: 'Interrupted system call',
+      5: 'I/O error',
+      6: 'No such device or address',
+      7: 'Arg list too long',
+      8: 'Exec format error',
+      9: 'Bad file number',
+      10: 'No children',
+      11: 'No more processes',
+      12: 'Not enough core',
+      13: 'Permission denied',
+      14: 'Bad address',
+      15: 'Block device required',
+      16: 'Mount device busy',
+      17: 'File exists',
+      18: 'Cross-device link',
+      19: 'No such device',
+      20: 'Not a directory',
+      21: 'Is a directory',
+      22: 'Invalid argument',
+      23: 'Too many open files in system',
+      24: 'Too many open files',
+      25: 'Not a typewriter',
+      26: 'Text file busy',
+      27: 'File too large',
+      28: 'No space left on device',
+      29: 'Illegal seek',
+      30: 'Read only file system',
+      31: 'Too many links',
+      32: 'Broken pipe',
+      33: 'Math arg out of domain of func',
+      34: 'Math result not representable',
+      35: 'File locking deadlock error',
+      36: 'File or path name too long',
+      37: 'No record locks available',
+      38: 'Function not implemented',
+      39: 'Directory not empty',
+      40: 'Too many symbolic links',
+      42: 'No message of desired type',
+      43: 'Identifier removed',
+      44: 'Channel number out of range',
+      45: 'Level 2 not synchronized',
+      46: 'Level 3 halted',
+      47: 'Level 3 reset',
+      48: 'Link number out of range',
+      49: 'Protocol driver not attached',
+      50: 'No CSI structure available',
+      51: 'Level 2 halted',
+      52: 'Invalid exchange',
+      53: 'Invalid request descriptor',
+      54: 'Exchange full',
+      55: 'No anode',
+      56: 'Invalid request code',
+      57: 'Invalid slot',
+      59: 'Bad font file fmt',
+      60: 'Device not a stream',
+      61: 'No data (for no delay io)',
+      62: 'Timer expired',
+      63: 'Out of streams resources',
+      64: 'Machine is not on the network',
+      65: 'Package not installed',
+      66: 'The object is remote',
+      67: 'The link has been severed',
+      68: 'Advertise error',
+      69: 'Srmount error',
+      70: 'Communication error on send',
+      71: 'Protocol error',
+      72: 'Multihop attempted',
+      73: 'Cross mount point (not really error)',
+      74: 'Trying to read unreadable message',
+      75: 'Value too large for defined data type',
+      76: 'Given log. name not unique',
+      77: 'f.d. invalid for this operation',
+      78: 'Remote address changed',
+      79: 'Can   access a needed shared lib',
+      80: 'Accessing a corrupted shared lib',
+      81: '.lib section in a.out corrupted',
+      82: 'Attempting to link in too many libs',
+      83: 'Attempting to exec a shared library',
+      84: 'Illegal byte sequence',
+      86: 'Streams pipe error',
+      87: 'Too many users',
+      88: 'Socket operation on non-socket',
+      89: 'Destination address required',
+      90: 'Message too long',
+      91: 'Protocol wrong type for socket',
+      92: 'Protocol not available',
+      93: 'Unknown protocol',
+      94: 'Socket type not supported',
+      95: 'Not supported',
+      96: 'Protocol family not supported',
+      97: 'Address family not supported by protocol family',
+      98: 'Address already in use',
+      99: 'Address not available',
+      100: 'Network interface is not configured',
+      101: 'Network is unreachable',
+      102: 'Connection reset by network',
+      103: 'Connection aborted',
+      104: 'Connection reset by peer',
+      105: 'No buffer space available',
+      106: 'Socket is already connected',
+      107: 'Socket is not connected',
+      108: "Can't send after socket shutdown",
+      109: 'Too many references',
+      110: 'Connection timed out',
+      111: 'Connection refused',
+      112: 'Host is down',
+      113: 'Host is unreachable',
+      114: 'Socket already connected',
+      115: 'Connection already in progress',
+      116: 'Stale file handle',
+      122: 'Quota exceeded',
+      123: 'No medium (in tape drive)',
+      125: 'Operation canceled',
+      130: 'Previous owner died',
+      131: 'State not recoverable',
+    }[-errno] || 'Unknown error'
+  )
+}
+
 export type Duration = number | `${number}s` | `${number}ms`
 
 export function parseDuration(d: Duration) {
@@ -137,6 +266,7 @@ export function formatCmd(cmd?: string): string {
     state = next == root ? next() : next
     buf += ch
   }
+
   function style(state: State, s: string): string {
     if (s == '') return ''
     if (reservedWords.includes(s)) {
@@ -154,9 +284,11 @@ export function formatCmd(cmd?: string): string {
     if (state?.name.startsWith('str')) return chalk.yellowBright(s)
     return s
   }
+
   function isSyntax(ch: string) {
     return '()[]{}<>;:+|&='.includes(ch)
   }
+
   function root() {
     if (/\s/.test(ch)) return space
     if (isSyntax(ch)) return syntax
@@ -165,44 +297,55 @@ export function formatCmd(cmd?: string): string {
     if (/[']/.test(ch)) return strSingle
     return word
   }
+
   function space() {
     if (/\s/.test(ch)) return space
     return root
   }
+
   function word() {
     if (/[0-9a-z/_.]/i.test(ch)) return word
     return root
   }
+
   function syntax() {
     if (isSyntax(ch)) return syntax
     return root
   }
+
   function dollar() {
     if (/[']/.test(ch)) return str
     return root
   }
+
   function str() {
     if (/[']/.test(ch)) return strEnd
     if (/[\\]/.test(ch)) return strBackslash
     return str
   }
+
   function strBackslash() {
     return strEscape
   }
+
   function strEscape() {
     return str
   }
+
   function strDouble() {
     if (/["]/.test(ch)) return strEnd
     return strDouble
   }
+
   function strSingle() {
     if (/[']/.test(ch)) return strEnd
     return strSingle
   }
+
   function strEnd() {
     return root
   }
+
   return out + '\n'
 }
 
test/core.test.js
@@ -18,7 +18,7 @@ import * as assert from 'uvu/assert'
 import { inspect } from 'node:util'
 import { Writable } from 'node:stream'
 import { Socket } from 'node:net'
-import { ProcessPromise } from '../build/index.js'
+import { ProcessPromise, ProcessOutput } from '../build/index.js'
 import '../build/globals.js'
 
 const test = suite('core')
@@ -166,49 +166,6 @@ test('ProcessPromise: inherits native Promise', async () => {
   assert.ok(p5 !== p1)
 })
 
-test('ProcessOutput thrown as error', async () => {
-  let err
-  try {
-    await $`wtf`
-  } catch (p) {
-    err = p
-  }
-  assert.ok(err.exitCode > 0)
-  assert.ok(err.stderr.includes('/bin/bash: wtf: command not found\n'))
-  assert.ok(err[inspect.custom]().includes('Command not found'))
-})
-
-test('pipe() throws if already resolved', async (t) => {
-  let ok = true
-  let p = $`echo "Hello"`
-  await p
-  try {
-    await p.pipe($`less`)
-    ok = false
-  } catch (err) {
-    assert.is(
-      err.message,
-      `The pipe() method shouldn't be called after promise is already resolved!`
-    )
-  }
-  assert.ok(ok, 'Expected failure!')
-})
-
-test('await $`cmd`.exitCode does not throw', async () => {
-  assert.is.not(await $`grep qwerty README.md`.exitCode, 0)
-  assert.is(await $`[[ -f README.md ]]`.exitCode, 0)
-})
-
-test('nothrow() do not throw', async () => {
-  let { exitCode } = await $`exit 42`.nothrow()
-  assert.is(exitCode, 42)
-  {
-    // Deprecated.
-    let { exitCode } = await nothrow($`exit 42`)
-    assert.is(exitCode, 42)
-  }
-})
-
 test('cd() works with relative paths', async () => {
   let cwd = process.cwd()
   try {
@@ -408,6 +365,62 @@ test('timeout() expiration works', async () => {
   assert.is(signal, undefined)
 })
 
+test('$ thrown as error', async () => {
+  let err
+  try {
+    await $`wtf`
+  } catch (p) {
+    err = p
+  }
+  assert.ok(err.exitCode > 0)
+  assert.ok(err.stderr.includes('/bin/bash: wtf: command not found\n'))
+  assert.ok(err[inspect.custom]().includes('Command not found'))
+})
+
+test('error event is handled', async () => {
+  await within(async () => {
+    $.cwd = 'wtf'
+    try {
+      await $`pwd`
+      assert.unreachable('should have thrown')
+    } catch (err) {
+      assert.instance(err, ProcessOutput)
+      assert.match(err.message, /No such file or directory/)
+    }
+  })
+})
+
+test('pipe() throws if already resolved', async (t) => {
+  let ok = true
+  let p = $`echo "Hello"`
+  await p
+  try {
+    await p.pipe($`less`)
+    ok = false
+  } catch (err) {
+    assert.is(
+      err.message,
+      `The pipe() method shouldn't be called after promise is already resolved!`
+    )
+  }
+  assert.ok(ok, 'Expected failure!')
+})
+
+test('await $`cmd`.exitCode does not throw', async () => {
+  assert.is.not(await $`grep qwerty README.md`.exitCode, 0)
+  assert.is(await $`[[ -f README.md ]]`.exitCode, 0)
+})
+
+test('nothrow() do not throw', async () => {
+  let { exitCode } = await $`exit 42`.nothrow()
+  assert.is(exitCode, 42)
+  {
+    // Deprecated.
+    let { exitCode } = await nothrow($`exit 42`)
+    assert.is(exitCode, 42)
+  }
+})
+
 test('malformed cmd error', async () => {
   assert.throws(() => $`\033`, /malformed/i)
 })
test/util.test.js
@@ -16,6 +16,7 @@ import { suite } from 'uvu'
 import * as assert from 'uvu/assert'
 import {
   exitCodeInfo,
+  errnoMessage,
   formatCmd,
   isString,
   noop,
@@ -27,7 +28,13 @@ import {
 const test = suite('util')
 
 test('exitCodeInfo()', () => {
-  assert.ok(exitCodeInfo(2) === 'Misuse of shell builtins')
+  assert.is(exitCodeInfo(2), 'Misuse of shell builtins')
+})
+
+test('errnoMessage()', () => {
+  assert.is(errnoMessage(-2), 'No such file or directory')
+  assert.is(errnoMessage(1e9), 'Unknown error')
+  assert.is(errnoMessage(undefined), 'Unknown error')
 })
 
 test('randomId()', () => {