# Cancellable Promises in JavaScript with Abort Controller

Imagine having the power to pull the plug on JavaScript promises or asynchronous functions midway!

Using JavaScript's `AbortController`, you can abort fetch requests both on the browser and in Node JS. Furthermore, in Node JS, this feature enables you to effortlessly cancel file system operations, fetch requests and spawned or forked child processes. It gets better: you can use it to even cancel your own custom promises or asynchronous functions.

So let's get to it then.

## What is AbortController?

AbortController is a [standard Web Interface](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) (object) that allows you to cancel asynchronous functions before they finish executing. It is supported both on browsers and in Node JS. It behaves similarly to an event emitter such that when `.abort()` is called, it emits an `abort` event which is received by its `AbortSignal` instance. The `AbortSignal` class, which extends `EventTarget` , allows registration of `abort` event handlers which are responsible for cleaning up and then exiting the asynchronous function(s) promptly.

A ubiquitous use case of this is cancelling fetch requests on the browser. A user may change their mind about waiting around for their huge file upload to finish, so you may provide a means for upload cancellation like Google Drive does.

## Cancelling fetch request

```javascript
const abortController = new AbortController()
const abortSignal = abortController.signal

console.log('Initiating fetch')
fetch('https://google.com', { signal: abortSignal })
    .then(() => console.log('Finished fetching'))
    .catch((error) => console.log(error))

abortController.abort()
```

```bash
$ node index.js
Initiating fetch
DOMException [AbortError]: This operation was aborted
```

Cancelled async operations throw a `DOMException` error, which extends the `Error` class and has the `name` property set to `AbortError`. You might want to handle this error or the program will crash.

#### Cancelling multiple requests and error handling

A single abort signal can be used to cancel **multiple** asynchronous operations at once.

```javascript
const abortController = new AbortController()
const abortSignal = abortController.signal

console.log('Initiating fetch')
const errorHandler = (error) => {
  if (error.name === 'AbortError')
    console.log('Fetch aborted')
}
fetch('https://google.com', { signal: abortSignal })
  .then(() => console.log('Finished fetching Google'))
  .catch(errorHandler)

fetch('https://duckduckgo.com', { signal: abortSignal })
  .then(() => console.log('Finished fetching DuckDuckGo'))
  .catch(errorHandler)

abortController.abort()
```

```bash
$ node index.js
Initiating fetch
Fetch aborted
Fetch aborted
```

#### Already aborted signals

However, an abort signal instance can only be aborted **once**. If there are any asynchronous operations yet to commence and they receive an already aborted signal, they will throw an `AbortError` immediately after they start executing.

```bash
const abortController = new AbortController()
const abortSignal = abortController.signal

console.log('Initiating fetch')
const errorHandler = (error) => {
  if (error.name === 'AbortError') {
    console.log('Fetch aborted')
  }
}

abortController.abort()
console.log(abortSignal.aborted)

fetch('https://duckduckgo.com', { signal: abortSignal })
  .then(() => console.log('Finished fetching DuckDuckGo'))
  .catch(errorHandler)
```

```bash
$ node index.js 
Initiating fetch
true
Fetch aborted
```

## Cancelling custom promises

We can make our own promises cancellable. First, we have to check if the signal has already been aborted. Then we register an `abort` signal handler which rejects the promise.

```javascript
const abortController = new AbortController()
const abortSignal = abortController.signal

async function longRunningOperation (/* ..., */ { signal }) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      return reject(signal.reason)
    }
    // Register timers
    let timeout = null
    signal.addEventListener('abort', () => {
      // Cleanup
      clearTimeout(timeout)
      reject(signal.reason)
    })
    // Long running operation
    console.log('Long running operation started. Will run for 5 minutes...')
    timeout = setTimeout(() => {
      resolve()
    }, 300_000)
  })
}

const errorHandler = (error) => {
  if (error.name === 'AbortError') {
    console.log('Long running operation aborted.')
  } else {
    console.log(error.toString())
  }
}

longRunningOperation({ signal: abortSignal })
  .catch(errorHandler)

// Abort all child processes after 5 seconds
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    console.log(`Terminated by user. (${signal} signal received).`)
    abortController.abort()
  })
}
```

```bash
$ node index.js
Long running operation started. Will run for 5 minutes...
^CTerminated by user. (SIGINT signal received).
Long running operation aborted.
```

> It is possible to provide a reason when calling abort (`.abort('reason')`). However, this will change the abort error thrown to normal `Error` object with the reason as the error message.

## Cancelling Node JS file operations

The following file system operations can be cancelled using abort controllers:

* `readFile`
    
* `writeFile`
    
* `watch`
    
* `createReadStream`
    
* `createWriteStream`
    

## Terminating spawned or forked processes in Node JS

> **Notice:** *This section is detailed and it might be a bit advanced*

Creating child processes is a handy feature in Node JS. `spawn` can be used for parallel processing or for executing commands. `fork` is a variant of spawn that executes Node JS modules. Normally, child processes might not exit when the main process terminates. You might want to terminate these child processes before the main process exits.

`AbortController` offers a convenient way to terminate multiple child processes at once. It will be like calling `.kill()` on the child process(es) which will by default send a `SIGTERM` [POSIX signal](https://en.wikipedia.org/wiki/Signal_(IPC)). This allows the child process(es) to clean up and then exit gracefully when the `SIGTERM` signal is received.

However, some processes may hang during the handling of the `SIGTERM` signal or outrightly ignore the signal and continue running. Let's explore that scenario:

#### Example of hanging spawned processes

***exampleProcess.js***

```javascript
import crypto from 'crypto'

// Unique ID to identify the process
const uid = crypto.randomInt(0, 100)
console.log(`Example process (${uid}) started...`)

// Long running operation
setInterval(() => {
  console.log(`[${uid}] Still running...`)
}, 1000)

for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    console.log(`[${uid}] Received ${signal} signal`)
    console.log(`[${uid}] But I just won't quit!`)
  })
}
```

***index.js***

```javascript
import { spawn } from 'child_process'

const abortController = new AbortController()
const abortSignal = abortController.signal

console.log('Main process started...')
// Execute exampleProcess.js
const childProcess = spawn('node', ['exampleProcess.js'], {
  stdio: 'inherit',
  signal: abortSignal
})

// Register error handler
childProcess.on('error', (error) => {
  if (error.name === 'AbortError') {
    console.log('Attempting to stop child process...')
  } else {
    console.log(error.toString())
  }
})

// Register exit handler (never called)
childProcess.on('exit', (code) => {
  console.log(`Child process exited with code ${code}`)
})

// Abort the child process after 5 seconds
setTimeout(() => {
  abortController.abort()
}, 5000)
```

If you run this process, you will realize that the child process (`exampleProcess.js`) continues to run even after `index.js` exits, even when you tap `CTRL` + `C` .

```bash
$ node index.js
Main process started...
Example process (11) started...
[11] Still running...
[11] Still running...
Attempting to stop child process...
[11] Received SIGTERM signal
[11] But I just won't quit!
[11] Still running...
```

#### Forceful termination timeout

The above-described issue can be easily remedied by setting a 5-second timeout when an abort error is thrown by a process. After the timeout has lapsed, a `SIGKILL` signal is sent. This signal cannot be ignored and will forcefully terminate the child process. If the child exits before the timeout has lapsed, then the timeout is cleared. Here is the modified script:

***index.js***

```javascript
import { spawn } from 'child_process'

const abortController = new AbortController()
const abortSignal = abortController.signal
let killTimeout = null

console.log('Main process started...')
// Execute exampleProcess.js
const childProcess = spawn('node', ['exampleProcess.js'], {
  stdio: 'inherit',
  signal: abortSignal
})

// Register error handler
childProcess.on('error', (error) => {
  if (error.name === 'AbortError') {
    console.log('Attempting to stop child process...')
    killTimeout = setTimeout(() => {
      console.log('Forcefully terminating child process...')
      childProcess.kill('SIGKILL')
    }, 5000)
  } else {
    console.log(error.toString())
  }
})

// Register exit handler (never called)
childProcess.on('exit', (code) => {
  clearTimeout(killTimeout)
  console.log(`Child process exited with code ${code}`)
})

// Abort the child process after 5 seconds
setTimeout(() => {
  abortController.abort()
}, 5000)
```

> On forceful termination of a child process, the exit code may be `null` since the child process did not exit on its own.

```bash
Main process started...
Example process (17) started...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Received SIGTERM signal
Attempting to stop child process...
[17] But I just won't quit!
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
[17] Still running...
Forcefully terminating child process...
Child process exited with code null
```

#### Refactoring forceful termination timeout code

Now let's tie it all together into a beautiful function. The `exampleProcess.js` file remains unchanged.

***index.js***

```javascript
import { spawn, fork } from 'child_process'

/**
 * Spawn a cancellable process asynchonously
 * @param {String} cmd
 * @param {readonly String[]} args
 * @param {import('node:child_process').SpawnOptions & {signal: AbortSignal, stdio: import('node:child_process').StdioOptions}} opts
 * @param {Boolean} forked Whether to fork a Node JS process instead of spawning a general process (default: false)
 * @returns {Promise<void>}
 */
async function spawnProcess (cmd, args, opts, forked = false) {
  return new Promise((resolve, reject) => {
    const childProcess = forked === true
      ? fork(cmd, args, opts)
      : spawn(cmd, args, opts)
    const command = `${cmd} ${args.join(' ')}`
    let killTimeout = null

    childProcess.on('error', (error) => {
      if (error.name === 'AbortError') {
        killTimeout = setTimeout(() => {
          console.log(`Forcefully terminating $ ${command}...`)
          childProcess.kill('SIGKILL')
        }, 5000)
      } else {
        console.log(error.toString())
      }
    })

    childProcess.on('exit', (code) => {
      clearTimeout(killTimeout)
      switch (code) {
        case 0:
          resolve()
          break
        case null:
          reject(new Error(`Terminated $ ${command}`))
          break
        default:
          reject(new Error(`Command exited with code ${code}`))
          break
      }
    })
  })
}

const abortController = new AbortController()
const abortSignal = abortController.signal
const errorHandler = (error) => { console.log(error.toString()) }

console.log('Main process started...')

// Start first instance of exampleProcess.js using spawn
spawnProcess('node', ['exampleProcess.js'], {
  signal: abortSignal,
  stdio: 'inherit'
}).catch(errorHandler)

// Start second instance of exampleProcess.js using fork
spawnProcess('exampleProcess.js', [], {
  signal: abortSignal,
  stdio: 'inherit'
}, true).catch(errorHandler)

// Abort all child processes on CTRL + C
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    console.log(`Terminated by user. (${signal} signal received).`)
    abortController.abort()
  })
}
```

```bash
$ node index.js
Main process started...
Example process (43) started...
Example process (37) started...
[43] Still running...
[37] Still running...
[43] Still running...
^CTerminated by user. (SIGINT signal received).
[37] Received SIGINT signal
[43] Received SIGINT signal
[43] But I just won't quit!
[37] But I just won't quit!
[43] Received SIGTERM signal
[43] But I just won't quit!
[37] Still running...
[37] Received SIGTERM signal
[37] But I just won't quit!
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
[37] Still running...
[43] Still running...
Forcefully terminating $ node exampleProcess.js...
[37] Still running...
Forcefully terminating $ exampleProcess.js ...
Error: Terminated $ node exampleProcess.js
Error: Terminated $ exampleProcess.js
```

There you have it. Go forth and utilize cancellable promises to make awesome stuff!

A lot of research and attention to detail went into making this article. Kindly leave a like, comment or correction :)
