├── README.md ├── domain.js ├── double-fulfill.js ├── timeout.js └── uncaught.js /README.md: -------------------------------------------------------------------------------- 1 | # ES6 Promise Debugging Techniques 2 | 3 | In my [blog post](http://blog.soareschen.com/the-problem-with-es6-promises) I highlighted a few of the potential problems of using promises incorrectly. In particular ES6 Promise silently ignore many errors that may make it difficult to debug promise-based applications. 4 | 5 | Now I have come out with a few solutions to expose these ignored errors, and hopefully make ease of debugging. The solution involves wrapping the promise constructor so that additional probes can be attached to detect incorrect promise usage. 6 | 7 | In the examples promises are constructed using a constructor function `createPromise()` instead of the canonical `new Promise()` expression. This is so that the promise constructor can be wrapped and changed at runtime to detect promise-related bugs during development. A default implementation of `createPromise()` is simply calls the native Promise constructor: 8 | 9 | ```javascript 10 | var createPromise = function(construct) { 11 | return new Promise(construct) 12 | } 13 | ``` 14 | 15 | ## Timeout 16 | 17 | The simplest kind of promise bug is having the promise never being fulfilled by the creator. This could for example happen when a promise creator include an empty constructor body: 18 | 19 | ```javascript 20 | createPromise(function(resolve, reject) { 21 | // forget to fulfill 22 | }).then(...) 23 | ``` 24 | 25 | This will cause the entire promise chain to halt, and user will have hard time determining the source of the bug. In this way Promise has the same problem as the async callback in which the async function implementor may forget to call the callback: 26 | 27 | ```javascript 28 | var doSomething = function(callback) { 29 | // forget to call callback 30 | } 31 | ``` 32 | 33 | However such bug can easily be detected if we modify the promise constructor and set a timeout limit: 34 | 35 | ```javascript 36 | var timeoutPromise = function(timeout, construct) { 37 | return new Promise(function(resolve, reject) { 38 | construct(resolve, reject) 39 | 40 | setTimeout(function() { 41 | reject(new Error('timeout error')) 42 | }, timeout) 43 | }) 44 | } 45 | ``` 46 | 47 | In this example the timeout promise intercept the `reject` function before forwarding to the constructor caller. It then set a timeout function that reject the promise. In this case the nature of Promise ignoring errors actually work in our favor: if the caller fulfill the promise before timeout, then calling reject inside the timeout function is simply silently ignored. 48 | 49 | With this simple trick, if a promise chain ever halt a user can simply detect the bug by changing the `createPromise()` function: 50 | 51 | ```javascript 52 | createPromise = function(construct) { 53 | return timeoutPromise(1000, construct) 54 | } 55 | ``` 56 | 57 | Full source at [timeout.js](timeout.js), with an example in the end. 58 | 59 | 60 | ## Double Fulfill 61 | 62 | Another potential promise bug is when a user try to fulfill a promise more than once: 63 | 64 | ```javascript 65 | createPromise(function(resolve, reject) { 66 | resolve(1) 67 | resolve(2) 68 | }) 69 | ``` 70 | 71 | Such mistake could for example happen inside a badly written control flow. However because `Promise` simply ignore subsequent fulfillment, a program may simply behave in unexpected way without giving clue on the source of error. 72 | 73 | The mistake is equivalent to calling callback multiple times in async functions, albeit with less negative side effect: 74 | 75 | ```javascript 76 | var doSomething = function(callback) { 77 | callback(null, 1) 78 | callback(null, 2) 79 | } 80 | ``` 81 | 82 | The double fulfillment error can again be detected by wrapping the promise constructor. In this example an error handler is provided so that the error can be gracefully handled. 83 | 84 | ```javascript 85 | var detectDoubleFulfilledPromise = function(construct, errHandler) { 86 | return new Promise(function(resolve, reject) { 87 | var fulfilled = false 88 | 89 | var wrap = function(fulfill) { 90 | return function(val) { 91 | if(fulfilled) errHandler(new Error( 92 | 'promise is fulfilled multiple time')) 93 | 94 | fulfilled = true 95 | fulfill(val) 96 | } 97 | } 98 | 99 | construct(wrap(resolve), wrap(reject)) 100 | }) 101 | } 102 | ``` 103 | 104 | With that one can for example report the error to the console: 105 | 106 | ```javascript 107 | var createPromise = function(construct) { 108 | return detectDoubleFulfilledPromise(construct, console.trace) 109 | } 110 | ``` 111 | 112 | Ideally though we want such error detection to built right into the native Promise implementation. The `Promise` class should allow error handler to be attached somewhere, so that all promise-related errors can be reported. 113 | 114 | Full source at [double-fulfill.js](double-fulfill.js) 115 | 116 | ## Domain 117 | 118 | Promise could also be the perfect replacement of Node's domain. By putting domain inside a promise constructor, one can safely wrap any async functions and ensure all errors being caught and handled as rejection. 119 | 120 | ```javascript 121 | var domainLib = require('domain') 122 | 123 | var domainProtectedPromise = function (construct, errorHandler) { 124 | return new Promise(function(resolve, reject) { 125 | var domain = domainLib.create() 126 | 127 | domain.on('error', function(err) { 128 | reject(err) 129 | errorHandler(err) 130 | }) 131 | 132 | domain.run(function() { 133 | construct(resolve, reject) 134 | }) 135 | }) 136 | } 137 | ``` 138 | 139 | It would be great if this can be added into the ES6 standard, but I suspect it is not easy to standardize how async errors should be captured. At least this could be independenly implemented in Node first and set as a use case to be standardized in ES7. 140 | 141 | For a proper implementation, I'd recommend the Node core team to make a new implementation independent of the existing domain library and add it inside the native Promise implementation. The implementation could be much more simpler than the original domain implementation, because it become an internal part of promise that cannot be manipulated by users. 142 | 143 | Full source at [domain.js](domain.js) 144 | 145 | ## Uncaught Error 146 | 147 | The last but probably most common promise bug is on improper handling of rejected promises. It will be a very common mistake for one to never attach a catch handler: 148 | 149 | ```javascript 150 | createPromise(function(resolve, reject) { 151 | reject(1) 152 | }).then(function(res) { 153 | console.log('should never get result') 154 | }) 155 | ``` 156 | 157 | But even if a catch handler is attached, exception can still occur inside the catch handler: 158 | 159 | ```javascript 160 | createPromise(function(resolve, reject) { 161 | reject(1) 162 | }).catch(function(err) { 163 | console.log('trying to recover from error', err) 164 | throw new Error('error inside error handler') 165 | console.log('should never managed to recover fully') 166 | }) 167 | ``` 168 | 169 | In such case the error recover failed but is silently ignored, making it almost impossible to detect and debug. 170 | 171 | One way to solve this in the userland is to attach two catch handlers, with the second catch handler used to signal fatal error: 172 | 173 | ```javascript 174 | createPromise(function(resolve, reject) { 175 | reject(1) 176 | }).catch(function(err) { 177 | throw new Error('error inside error handler') 178 | 179 | }).catch(function(err) { 180 | console.log('A fatal error has occured!', err) 181 | // Abort program or close down cluster instance 182 | abort() 183 | }) 184 | ``` 185 | 186 | I'd call this the _double catch pattern_ and would recommend everyone to use that at the end of a promise chain. 187 | 188 | Nevertheless, not everyone would use such pattern and it is too tempting to not attach any catch handler at all. Hence I'd recommend another promise wrapper used to detect the lack of error handling at the end of a promise chain: 189 | 190 | ```javascript 191 | var detectUncaughtPromise = function(promise, timeout, prevCaught) { 192 | var wrappedPromise = Object.create(promise) 193 | 194 | var chained = false 195 | var stack = new Error().stack 196 | 197 | wrappedPromise.then = function(onResolved, onRejected) { 198 | chained = true 199 | var nextCaught = onRejected ? true : false 200 | 201 | var newPromise = promise.then(onResolved, onRejected) 202 | return detectUncaughtPromise(newPromise, timeout, nextCaught) 203 | } 204 | 205 | wrappedPromise.catch = function(errHandler) { 206 | chained = true 207 | 208 | var newPromise = promise.catch(errHandler) 209 | return detectUncaughtPromise(newPromise, timeout, true) 210 | } 211 | 212 | setTimeout(function() { 213 | if(chained) return 214 | 215 | if(!prevCaught) { 216 | console.log('uncaught terminal promise detected.', 217 | 'last then() was on:', stack) 218 | } else { 219 | promise.catch(function(err) { 220 | console.log('exception occured inside error handler', 221 | 'of last promise chain:', err) 222 | }) 223 | } 224 | }, timeout) 225 | 226 | return wrappedPromise 227 | } 228 | ``` 229 | 230 | The implementation is a bit long, but what it essentially does is to wrap around a promise's `.then()` and `.catch()` methods to detect whether catch handler is attached to the end of a promise chain. Because a promise might not be chained immediately, a timeout is set before the wrapper checks whether it reach the end of a promise chain. 231 | 232 | If the wrapper finds itself at the end of promise chain and no catch handler is attached, an error is reported to the error handler together with the stack location of the last `.then()` chain. Otherwise the wrapper attach an additional catch handler at the end of promise chain, and use it to report any fatal error to the error handler. 233 | 234 | Unlike earlier examples, this function wraps around promise instances. However it needs to be called inside the promise constructor to debug all promises created. 235 | 236 | ```javascript 237 | var createPromise = function(construct) { 238 | var promise = new Promise(construct) 239 | return detectUncaughtPromise(promise, 1000) 240 | } 241 | ``` 242 | 243 | A native implementation may be much more efficient in detecting the end of promise chain. 244 | 245 | Full source at [uncaught.js](uncaught.js) 246 | 247 | # Conclusion 248 | 249 | I presented four common bugs that can occur when using promises, all of which are either silently ignored or very hard to debug with current standard. I also come out with non-intrusive solutions that will make such debugging much easier. Some simplified example code is shown here to demonstrate how the solution could be implemented. Ultimately these solutions should be standardized and implemented natively in ES6 Promise. 250 | 251 | This article is intended as a start to spark discussion with the JavaScript community to improve Promise before ES6 is finalized. -------------------------------------------------------------------------------- /domain.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var domainLib = require('domain') 4 | 5 | var domainProtectedPromise = function (construct, errorHandler) { 6 | return new Promise(function(resolve, reject) { 7 | var domain = domainLib.create() 8 | 9 | domain.on('error', function(err) { 10 | reject(err) 11 | errorHandler(err) 12 | }) 13 | 14 | domain.run(function() { 15 | construct(resolve, reject) 16 | }) 17 | }) 18 | } 19 | 20 | var createPromise = function(construct) { 21 | return domainProtectedPromise(construct, console.trace) 22 | } 23 | 24 | createPromise(function(resolve, reject) { 25 | process.nextTick(function() { 26 | throw new Error('async error') 27 | 28 | resolve(1) 29 | }) 30 | }).then(function(res) { 31 | console.log('should never get result', res) 32 | }, function(err) { 33 | console.log('got error', err) 34 | }) -------------------------------------------------------------------------------- /double-fulfill.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var detectDoubleFulfilledPromise = function(construct, errHandler) { 4 | return new Promise(function(resolve, reject) { 5 | var fulfilled = false 6 | 7 | var wrap = function(fulfill) { 8 | return function(val) { 9 | if(fulfilled) errHandler(new Error( 10 | 'promise is fulfilled multiple time')) 11 | 12 | fulfilled = true 13 | fulfill(val) 14 | } 15 | } 16 | 17 | construct(wrap(resolve), wrap(reject)) 18 | }) 19 | } 20 | 21 | var createPromise = function(construct) { 22 | return detectDoubleFulfilledPromise(construct, console.trace) 23 | } 24 | 25 | createPromise(function(resolve, reject) { 26 | resolve(1) 27 | resolve(2) 28 | }).then(function(res) { 29 | console.log('got result:', res) 30 | }) -------------------------------------------------------------------------------- /timeout.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var timeoutPromise = function(timeout, construct) { 4 | return new Promise(function(resolve, reject) { 5 | construct(resolve, reject) 6 | 7 | setTimeout(function() { 8 | reject(new Error('timeout error')) 9 | }, timeout) 10 | }) 11 | } 12 | 13 | var createPromise = function(construct) { 14 | return timeoutPromise(1000, construct) 15 | } 16 | 17 | createPromise(function(resolve, reject) { 18 | // forget to fulfill 19 | }).then(function(res) { 20 | console.log('should never get result', res) 21 | }, function(err) { 22 | console.log('got timeout error:', err) 23 | }) -------------------------------------------------------------------------------- /uncaught.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var detectUncaughtPromise = function(promise, timeout, prevCaught) { 4 | var wrappedPromise = Object.create(promise) 5 | 6 | var chained = false 7 | var stack = new Error().stack 8 | 9 | wrappedPromise.then = function(onResolved, onRejected) { 10 | chained = true 11 | var nextCaught = onRejected ? true : false 12 | 13 | var newPromise = promise.then(onResolved, onRejected) 14 | return detectUncaughtPromise(newPromise, timeout, nextCaught) 15 | } 16 | 17 | wrappedPromise.catch = function(errHandler) { 18 | chained = true 19 | 20 | var newPromise = promise.catch(errHandler) 21 | return detectUncaughtPromise(newPromise, timeout, true) 22 | } 23 | 24 | setTimeout(function() { 25 | if(chained) return 26 | 27 | if(!prevCaught) { 28 | console.log('uncaught terminal promise detected.', 29 | 'last then() was on:', stack) 30 | } else { 31 | promise.catch(function(err) { 32 | console.log('exception occured inside error handler', 33 | 'of last promise chain:', err) 34 | }) 35 | } 36 | }, timeout) 37 | 38 | return wrappedPromise 39 | } 40 | 41 | var createPromise = function(construct) { 42 | var promise = new Promise(construct) 43 | return detectUncaughtPromise(promise, 1000) 44 | } 45 | 46 | createPromise(function(resolve, reject) { 47 | reject(1) 48 | }).then(function(res) { 49 | console.log('should never get result') 50 | }) 51 | 52 | createPromise(function(resolve, reject) { 53 | reject(1) 54 | }).catch(function(err) { 55 | console.log('trying to recover from error', err) 56 | throw new Error('error inside error handler') 57 | console.log('should never managed to recover fully') 58 | }) --------------------------------------------------------------------------------