NCZOnline - The Official Web Site of Nicholas C. Zakas

Subscribe to NCZOnline - The Official Web Site of Nicholas C. Zakas feed
The Official Web Site of Nicholas C. Zakas
Updated: 2 min 59 sec ago

Two approaches to win an argument as a software engineer

Mon, 03/15/2021 - 20:00

If you’ve spent any time developing software professionally and then you are probably used to the spirited debates that take place between software engineers as well as between software engineers and management, design, and product. Software engineers are not known for being shy about their opinions on any particular subject, and especially when it comes to the company they work for or the software they work on. However, many software engineers are not good at convincing others of their position. The fundamental problem is in the approach.

When trying to convince someone that their position is correct, software engineers tend to default to a data transfer methodology: I have arrived at my position due to the state of data in my brain and so to convince you I will now attempt to transfer that state to your brain. The problem is, not everyone’s brain works the same way, so attempting to transfer that state is inefficient and can result in duplicating errors. Unfortunately, there are no checksums to fall back on.

Another approach software engineers try is an approach based on trust. If you trust me, then you should trust that what I say is true and should therefore agree with my recommendation. This approach is actually a power play where you believe that your clout is enough that people should have blind faith in you. This only works some of the time for some people and generally leaves everyone feeling resentful. You don’t want to steamroll your team members, you want collaboration.

So if data transfer and trust won’t work, what is left? The simple answer is to create a separate pool of data that exists outside of your brain. This “outside data” needs to be presented in a way that others can understand, so you are meeting together in the middle rather than trying to project your internal state to someone else.

That probably sounds very wordy, so let me break it down to two approaches: argue with data and argue with code.

Approach 1: Prove something is true with data

If you want to prove that something is true, which is to say that the thing you’re promoting is both factual and accurate, then the only way to win this argument is to gather data to illustrate the fact. For example, each of these statements may or may not be true on a project:

  1. Switching to another framework will improve performance
  2. Our algorithm is more error prone than others so we should change it
  3. We waste a lot of time on repetitive tasks

Statements like these tend to start out as opinion and often don’t go any further, which leads to arguments of opinion. Opinion arguments can’t be won because, by definition, an opinion cannot be wrong. The only way to resolve this type of dispute is with data.

First, get specific and define all of your terms. What is it you are actually claiming? Taking the first statement, what does it mean to “improve performance?” Are you talking about reducing page load time? Or maybe reducing CPU utilization on a virtual machine? Or maybe using less memory? Be as specific as possible about what you are proposing as a fact.

Second, ask yourself what kind of data would prove your point? Have other companies published data showing the performance improvement between the framework you’re using and the one you’re proposing? Again, are you looking for page load time, CPU utilization, memory utilization, or something else? What numbers would make your point?

Third, do you already have the data you need or do you need to collect it? Some companies collect a lot of data about their software and operations; some do not. To get the data you need, you may need to add some instrumentation, or dive into your analytics system, or find tech talks or research papers. You need to find a source of data that is separate from you to make your point.

Keep in mind that data can be quantitative (measured values) or qualitative (opinions). While quantitative data is always preferable, it’s not always possible. In those moments, qualitative data can still win the argument. If you’re trying to prove that the team is wasting a lot of time on repetitive tasks, your best bet may be to have everyone on the team fill out a survey asking questions about the tasks they’re doing and if they are frustrated by them.

Last, present the data in a format that anyone can understand. Oftentimes, putting together a simple slide deck is still the easiest way to convince others (especially in management) that something is true. Just make sure you give people the time and space to consume the data you’ve presented before pushing for a decision.

The most important thing about this approach is that you are gathering data that exists outside of your brain and can easily be shared with others.

Approach 2: Prove something is possible with code

If you want to prove that something is possible, then the only way to win this argument is with code. It always surprises me when I find two software engineers arguing with each other over whether something is possible when it might take an hour to write some code and settle the issue permanently. It doesn’t make sense to argue over something that can be proven, beyond all doubt, by writing code. Here are some example statements that can probably be proved with code:

  1. We can make the list scroll infinitely
  2. It’s possible to write a complex query with one request
  3. Switching to another framework will improve performance

Back when I was a young web developer, when people proposed an infinitely-scrolling list I thought they were crazy. It would never be smooth, it would never be fast enough, it would crash the browser due to memory limits. But then I saw the first demo of an infinitely-scrolling list that was smooth and didn’t crash the browser, and that was it. No more argument from my side. Just because I couldn’t figure out how to do it didn’t mean it wasn’t possible. If you’re in a position where you are arguing something is possible, go ahead and create it (or a reasonable prototype); if you are unwilling or unable to write the code, then it’s time to drop the argument.

One last point: that last statement looks familiar, doesn’t it? Yes, sometimes the same statement needs both data and code to win the argument. In this case, you might need to gather data to prove that performance is a problem and then also write some code to show that switching to the new framework will improve those numbers. You are actually trying to prove two things: 1) the performance is a problem and 2) switching to the new framework addresses the problem. The combination approach works exceedingly well in software engineering because so much of the work is about making changes and measuring the effect of those changes.

Conclusion

Arguing your point is an expected part of being a software engineer. It is fine to have opinions about everything, but if you are attempting to convince someone that your position is correct in order to make some change, then you owe it to them and to yourself to make sure you are correct. You shouldn’t expect people to blindly follow your opinions; you should expect to need to produce data, code, or some combination of the two to convince others. The willingness to do the research, data crunch, write code, or otherwise dig into the problem is what will ultimately convince others of your position.

No matter what kind of work-related argument you are trying to win, remember to argue with data and argue with code.

Categories: Tech-n-law-ogy

Introducing Env: a better way to read environment variables in JavaScript

Mon, 02/15/2021 - 19:00

If you write server-side JavaScript, chances are you’ve need to read information from environment variables. It’s considered a best practice to share sensitive information, such as access tokens, inside of environment variables to keep them secure. However, the way environment variables are read from JavaScript is error-prone in subtle ways that might take you hours to figure out. When an error occurs reading an environment variable, you want to know immediately, and you don’t want to interpret cryptic error messages. That’s where Env comes in.

Installing Env

Env1 is a zero-dependency utility designed to make reading environment variables safer and less error-prone. It does this by addressing the root causes of environment variable-related errors in server-side JavaScript. It works in both Node.js and Deno, and automatically reads environment variables from the correct location based on the runtime being used.

To use Env in Node.js, install it with npm:

$ npm install @humanwhocodes/env

And then import the Env constructor:

import { Env } from "@humanwhocodes/env"; // or const { Env } = require("@humanwhocodes/env");

To use Env in Deno, reference it from Skypack:

import { Env } from "https://cdn.skypack.dev/@humanwhocodes/env?dts";

Once you have the Env constructor, you can create a new instance like this:

const env = new Env();

And now you’re ready to read environment variables safely.

Problem #1: Missing environment variables

The first problem Env addresses is how to deal with missing environment variables. It’s quite common for environment variables to go missing either because they were accidentally not set up correctly or because they only exist on some containers and not all. In any case, you want to handle missing environment variables seamlessly. In Node.js, you might do something like this:

const USERNAME = process.env.USERNAME || "guest";

The intent here is to use the USERNAME environment variable if present, and if not, default to "guest". Env streamlines this to make setting defaults clear:

const USERNAME = env.get("USERNAME", "guest");

This code has the same effect but avoids any type coercion in the process. Of course, this assumes it’s okay for USERNAME to be missing. But what if you absolutely need an environment variable present in order for your application to work? For that, you might write some code like this:

const USERNAME = process.env.USERNAME; if (!USERNAME) { throw new Error("Environment variable USERNAME is missing."); }

That’s a lot of code for some simple validation, and if you have several required environment variables, you’ll end up repeating this pattern for each one. With Env, you can use the require() method:

const USERNAME = env.require("USERNAME");

If the environment variable USERNAME is missing in this example, then an error is thrown telling you so. You can also use the required property in a similar way:

const USERNAME = env.required.USERNAME;

This syntax allows you to avoid typing a string but will still throw an error if USERNAME is not present.

Problem #2: Typos

Another type of error that is common with environment variables are typos. Typos can be hard to spot when you are typing the same thing multiple times. For example, you might type something like this:

const USERNAME = process.env.USERRNAME;

Personally, I’ve spent hours tracking down bugs related to my incorrectly typing the name of the environment variable in my code. For whatever reason, I type the name of the variable correctly but not the environment variable name. If you want your JavaScript variables to have the same name as some required environment variables, you can use destructuring of the required property to only type the name once:

const { PORT, HOST } = env.required;

Here, two local variables, PORT and HOST, are created from the environment variables of the same name. If either environment variable is missing, an error is thrown.

Problem #3: Type mismatches

Another subtle type of error with environment variables are type mismatches. For instance, consider the following Node.js code:

const PORT = process.env.PORT || 8080;

This line, or something similar, appears in a lot of Node.js applications. Most of the time it doesn’t cause an issue…but it could. Can you spot the problem?

All environment variables are strings, so the JavaScript variable PORT is a string when the environment variable is present and a number if not. Using similar code in Deno threw an error2 that took me a while to figure out. It turned out that the Deno HTTP server required the port to be a number, so it worked fine locally but when I deployed it to Cloud Run, I received an error.

To solve this problem, Env converts all default values into strings automatically:

const PORT = env.get("PORT", 8080); console.log(typeof PORT === "string"); // always true

Even if you pass in a non-string value as the default, Env will convert it to a string to ensure that you only ever receive a string value when reading environment variables.

Problem #4: Fallback variables

Sometimes you might want to check several environment variables and only use a default if none of the environment variables are present. So you might have code that looks like this:

const PORT = process.env.PORT || process.env.HTTP_PORT || 8080;

You can make that a bit clearer using Env:

const PORT = env.first(["PORT", "HTTP_PORT"], 8080);

Using this code, Env returns a value from the first environment variable it finds. Similar to get(), first() allows you to pass in a default value to use if none of the environment variables are found, and that default value is automatically converted to a string. As an added error check, if the first argument isn’t an array or is an array with only one item, then an error is thrown.

Conclusion

Env is one of those utilities that has been so valuable to me that I sometimes forget to mention it. I’ve been using it in a number of personal projects for the past two years and it’s saved me a lot of time. Debugging errors related to environment variables isn’t anyone’s idea of fun, and I can’t count the times where I’ve been saved by an Env error. I hope you find it helpful, as well.

  1. Env 

  2. serve() error: “Uncaught InvalidData” 

Categories: Tech-n-law-ogy

Creating a JavaScript promise from scratch, Part 7: Unhandled rejection tracking

Mon, 01/18/2021 - 19:00

When promises were introduced in ECMAScript 2015, they had an interesting flaw: if a promise didn’t have a rejection handler and was later rejected, you would have no idea. The rejection silently occurred behind the scenes and, therefore, could easily be missed. The best practice of always attaching rejection handlers to promises emerged due to this limitation. Eventually, a way to detect unhandled promise rejections was added to ECMA-262 and both Node.js and web browsers implemented console warnings when an unhandled rejection occurred. In this post, I’ll walk through how unhandled rejection tracking works and how to implement it in JavaScript.

This is the seventh and final post in my series about creating JavaScript promises from scratch. If you haven’t already read the previous posts, I’d suggest you do before continuing on:

As a reminder, this series is based on my promise library, Pledge. You can view and download all of the source code from GitHub.

Unhandled rejection tracking in browsers

While both Node.js and web browsers have ways of dealing with unhandled rejections, I’m going to focus on the web browser implementation because it is defined in the HTML specification1. Having a specification to work from makes it easier to understand what’s going on as opposed to the Node.js implementation which is custom (though still similar to web browsers). To start, suppose you have a promise defined like this:

const promise = new Promise((resolve, reject) => { reject(43); });

This promise doesn’t have a rejection handler defined and so when it’s rejected it ends up being tracked by the browser. Periodically, the browser checks its list of unhandled rejections and fires a unhandledrejection event on globalThis. The event handler receives an event object with a promise property containing the rejected promise and a reason property containing the rejection reason (43 in the case of this example). For example:

// called when an unhandled rejection occurs globalThis.onunhandledrejection = event => { console.log(event.promise); // get the promise console.log(event.reason); // get the rejection reason };

In addition to triggering the unhandledrejection event, the browser will output a warning to the console indicating that an unhandled rejection occurred. You can therefore choose to track unhandled rejections programmatically or keep your console open to see them as you’re developing.

Late-handled promise rejection

You may be wondering, what happens if a rejection handler is added at some later point in time? After all, you can add a rejection handler anytime between creation of the promise and the time when the promise is destroyed through garbage collection. You can, for instance, do this:

const promise = new Promise((resolve, reject) => { reject(43); }); setTimeout(() => { promise.catch(reason => { console.error(reason); }); }, 1000));

Here, a promise is created without a rejection handler initially and then adds one later. What happens in this case depends largely on the amount of time that has passed:

  • If the rejection handler is added before the browser decides to trigger unhandledrejection, then the event will not be triggered.
  • If the rejection handler is added after the browser has triggered unhandledrejection, then a rejectionhandled event is triggered to let you know that the rejection is no longer unhandled.

It’s a little bit confusing, but basically, any promise that triggers an unhandledrejection event could potentially trigger a rejectionhandled event later. Therefore, you really need to listen for both events and track which promises remain, like this:

const rejections = new Map(); // called when an unhandled rejection occurs globalThis.onunhandledrejection = ({ promise, reason }) => { rejections.set(promise, reason); }; // called when an unhandled rejection occurs globalThis.onrejectionhandled = ({ promise }) => { rejections.delete(promise); };

This code tracks unhandled rejections using a map. When an unhandledrejection event occurs, the promise and rejection reason are saved to the map; when a rejectionhandled event occurs, the promise is deleted from the map. By periodically checking the contents of rejections, you can then track which rejections occurred without handlers.

Another quirk in the relationship between the unhandledrejection and rejectionhandled events is that you can prevent the rejectionhandled event from firing by adding a rejection handler inside of the onunhandledrejection event handler, like this:

// called when an unhandled rejection occurs globalThis.onunhandledrejection = ({ promise, reason }) => { promise.catch(() => {}); // make the rejection handled }; // this will never be called globalThis.onrejectionhandled = ({ promise }) => { console.log(promise); };

In this case, the rejectionhandled event isn’t triggered because a rejection handler is added before it’s time for that event. The browser assumes that you know the promise is now handled and so there is no reason to trigger the rejectionhandled event.

Eliminating the console warning

As mentioned previously, the browser will output a warning to the console whenever an unhandled promise rejection occurs. This console warning occurs after the unhandledrejection event is fired, which gives you the opportunity to prevent the warning altogether. You can cancel the console warning by calling the preventDefault() method on the event object, like this:

globalThis.onunhandledrejection = event => { event.preventDefault(); };

This event handler ensures that the console warning for the unhandled rejection will not happen. Suppressing the console warning is helpful in production where you don’t want to litter the console with additional information once you already know a promise was missing a rejection handler.

With that overview out of the way, it’s now time to discuss how to implement the same browser unhandled rejection tracking from scratch.

Implementing unhandled rejection tracking

The design for rejection tracking in the Pledge library closely follows the web browser approach. Because I didn’t want to mess with the globalThis object, I decided to add two static methods to the Pledge class to act as event handlers:

class Pledge { // other methods omitted for space static onUnhandledRejection(event) { // noop } static onRejectionHandled(event) { // noop } // other methods omitted for space }

The event object is an instance of PledgeRejectionEvent, which is defined like this:

class PledgeRejectionEvent { constructor(pledge, reason) { this.pledge = pledge; this.reason = reason; this.returnValue = true; } preventDefault() { this.returnValue = false; } }

I’ve included the preventDefault() method as well as the returnValue legacy property so either way of canceling the event will work.

Last, I created a RejectionTracker class to encapsulate most of the functionality. While this class isn’t described in any specification, I found it easier to wrap all of the functionality in this class. I then attached an instance of RejectionTracker to Pledge via a symbol property:

Pledge[PledgeSymbol.rejectionTracker] = new RejectionTracker();

In this way, I can always reach the rejection tracker from any instance of Pledge through this.constructor[PledgeSymbol.rejectionTracker]. It will become more apparent why this is important later in this post.

What does it mean for a promise to be handled?

ECMA-262 considers a promise to be handled if the promise’s then() method has been called (which includes catch() and finally(), both of which call then() behind the scenes). It actually doesn’t matter if you’ve attached a fulfillment handler, a rejection handler, or neither, so long as then() was called. Each call to then() creates a new promise which then becomes responsible for dealing with any fulfillment or rejection. Consider this example:

const promise1 = new Promise((resolve, reject) => { reject(43); }); const promise2 = promise1.then(value => { console.log(value); });

Here, promise1 is considered handled because then() is called and a fulfillment handler is attached. When promise1 is rejected, that rejection is passed on to promise2, which is not handled. A browser would report the unhandled rejection from promise2 and disregard promise1. So, the browser isn’t really tracking all unhandled rejections, but rather, it’s tracking whether the last promise in a chain has any handlers attached.

How do you know if a promise is handled?

ECMA-262 describes two key features that enable rejection tracking:

  1. The [[PromiseIsHandled]] internal property2 of every promise. This is a Boolean value indicating if the promise is handled. It starts out as false and is changed to true after then() is called.
  2. The HostPromiseRejectionTracker() operation3 is an abstract representation of a promise rejection tracker. ECMA-262 itself does not specify an algorithm for this operation; instead, it defers that to host environments to decide (host environments meaning browsers, Node.js, Deno, etc.).

The majority of the functionality related to these two features is contained the PerformPromiseThen() operation4 (discussed in part 3), which I’ve implemented as performPledgeThen():

function performPledgeThen(pledge, onFulfilled, onRejected, resultCapability) { assertIsPledge(pledge); if (!isCallable(onFulfilled)) { onFulfilled = undefined; } if (!isCallable(onRejected)) { onRejected = undefined; } const fulfillReaction = new PledgeReaction(resultCapability, "fulfill", onFulfilled); const rejectReaction = new PledgeReaction(resultCapability, "reject", onRejected); switch (pledge[PledgeSymbol.state]) { case "pending": pledge[PledgeSymbol.fulfillReactions].push(fulfillReaction); pledge[PledgeSymbol.rejectReactions].push(rejectReaction); break; case "fulfilled": { const value = pledge[PledgeSymbol.result]; const fulfillJob = new PledgeReactionJob(fulfillReaction, value); hostEnqueuePledgeJob(fulfillJob); } break; case "rejected": { const reason = pledge[PledgeSymbol.result]; // if the pledge isn't handled, track it with the tracker if (pledge[PledgeSymbol.isHandled] === false) { hostPledgeRejectionTracker(pledge, "handle"); } const rejectJob = new PledgeReactionJob(rejectReaction, reason); hostEnqueuePledgeJob(rejectJob); } break; default: throw new TypeError(`Invalid pledge state: ${pledge[PledgeSymbol.state]}.`); } // mark the pledge as handled pledge[PledgeSymbol.isHandled] = true; return resultCapability ? resultCapability.pledge : undefined; }

Regardless of what happens during the course of called performPledgeThen(), the pledge is always marked as handled before the end of the function. If the pledge is rejected, then hostPledgeRejectionTracker() is called with the pledge and a second argument of "handle". That second argument indicates that the rejection was handled and shouldn’t be tracked as an unhandled rejection.

The HostPromiseRejectionTracker() is also called by the RejectPromise() operation5 (also discussed in part 3), which I’ve implemented as rejectPledge():

export function rejectPledge(pledge, reason) { if (pledge[PledgeSymbol.state] !== "pending") { throw new Error("Pledge is already settled."); } const reactions = pledge[PledgeSymbol.rejectReactions]; pledge[PledgeSymbol.result] = reason; pledge[PledgeSymbol.fulfillReactions] = undefined; pledge[PledgeSymbol.rejectReactions] = undefined; pledge[PledgeSymbol.state] = "rejected"; // global rejection tracking if (pledge[PledgeSymbol.isHandled] === false) { hostPledgeRejectionTracker(pledge, "reject"); } return triggerPledgeReactions(reactions, reason); }

Here, the rejectPledge() function called hostPledgeRejectionTracker() with a second argument of "reject", indicating that the pledge was rejected and not handled. Remember, rejectPledge() is the function that is called by the reject argument that is passed in to executor function when creating a new promise, so at that point in time, the promise hasn’t had any handlers assigned. So, rejectPledge() is marking the pledge as unhandled, and if then() is later called to assign a handler, then it will bemarked as handled.

I’ve implemented hostPledgeRejectionTracker() as follows:

export function hostPledgeRejectionTracker(pledge, operation) { const rejectionTracker = pledge.constructor[PledgeSymbol.rejectionTracker]; rejectionTracker.track(pledge, operation); }

This is where attaching the rejection handler to the Pledge constructor is helpful. I’m able to get to the RejectionTracker instance and call the track() method to keep this function simple.

The RejectionTracker class

The RejectionTracker class is designed to encapsulate all of the rejection tracking functionality described in the HTML specification:

An environment settings object also has an outstanding rejected promises weak set and an about-to-be-notified rejected promises list, used to track unhandled promise rejections. The outstanding rejected promises weak set must not create strong references to any of its members, and implementations are free to limit its size, e.g. by removing old entries from it when new ones are added.

This description is a little bit confusing, so let me explain it. There are two different collections used to track rejections:

  • The “about-to-be-notified” rejected promises list is a list of promises that have been rejected and will trigger the unhandledrejection event.
  • The outstanding rejected promises weak set is a collection of promises that had unhandled rejections and triggered the unhandledrejection event. These promises are tracked just in case they have a rejection handler added later, in which case the rejectionhandled event is triggered.

So these are the two collections the RejectionTracker needs to manage. Additionally, it manages a logger (typically console but can be overwritten for testing) and a timeout ID (which I’ll explain later in this post). Here’s what the class and constructor look like:

export class RejectionTracker { constructor(logger = console) { this.aboutToBeNotified = new Set(); this.outstandingRejections = new WeakSet(); this.logger = logger; this.timeoutId = 0; } track(pledge, operation) { // TODO } }

I chose to use a set for the “about-to-be-notified” promises list because it will prevent duplicates while allowing me to iterate through all of the promises contained within it. The outstanding rejections collection is implemented as a weak set, per the specification, which means there’s no way to iterate over the contents. That’s not a problem for how this collection is used in algorithm, however.

Implementing HostPromiseRejectionTracker()

The primary method is track(), and that implements the functionality described in the HTML specification for HostPromiseRejectionTracker()6, which is as follows:

  1. Let script be the running script.
  2. If script’s muted errors is true, terminate these steps.
  3. Let settings object be script’s settings object.
  4. If operation is "reject",
    1. Add promise to settings object’s about-to-be-notified rejected promises list.
  5. If operation is "handle",
    1. If settings object’s about-to-be-notified rejected promises list contains promise, then remove promise from that list and return.
    2. If settings object’s outstanding rejected promises weak set does not contain promise, then return.
    3. Remove promise from settings object’s outstanding rejected promises weak set.
    4. Let global be settings object’s global object.
    5. Queue a global task on the DOM manipulation task source given global to fire an event named rejectionhandled at global, using PromiseRejectionEvent, with the promise attribute initialized to promise, and the reason attribute initialized to the value of promise’s [[PromiseResult]] internal slot.

The first three steps can be ignored for our purposes because they are just setting up variables. The fourth steps occurs when operation is "reject", at which point the promise that was rejected is added to the about-to-be-notified rejected promises list. That’s all that needs to happen at this point because a recurring check will later read that list to determine if any events need to be fired. The more interesting part is what happens when operation is "handle", meaning that a previously rejected promise now has a rejection handler added. Here are the steps using clearer language:

  1. If promise is in the about-to-be-notified rejected promises list, that means the promise was rejected without a rejection handler but the unhandledrejection event has not yet been fired for that promise. Because of that, you can just remove promise from the list to ensure the event is never fired, and therefore, you’ll never need to fire a rejectionhandled event. Your work here is done.
  2. If the outstanding rejected promises weak set doesn’t contain promise, then there’s also nothing else to do here. The unhandledrejection event was never fired for promise so the rejectionhandled event should also never fire. There’s no more tracking necessary.
  3. If promise is in the outstanding rejected promises weak set, that means it has previously triggered the unhandledrejection event and you are now being notified that it is handled. That means you need to trigger the rejectionhandled event. For simplicity, you can read “queue a global task” as “run this code with setTimeout().”

After all of that explanation, here’s what it looks like in code:

export class RejectionTracker { constructor(logger = console) { this.aboutToBeNotified = new Set(); this.outstandingRejections = new WeakSet(); this.logger = logger; this.timeoutId = 0; } track(pledge, operation) { if (operation === "reject") { this.aboutToBeNotified.add(pledge); } if (operation === "handle") { if (this.aboutToBeNotified.has(pledge)) { this.aboutToBeNotified.delete(pledge); return; } if (!this.outstandingRejections.has(pledge)) { return; } this.outstandingRejections.delete(pledge); setTimeout(() => { const event = new PledgeRejectionEvent(pledge, pledge[PledgeSymbol.result]); pledge.constructor.onRejectionHandled(event); }, 0); } // not part of spec, need to toggle monitoring if (this.aboutToBeNotified.size > 0) { this.startMonitor(); } else { this.stopMonitor(); } } // other methods omitted for space }

The code closely mirrors the specification algorithm, ultimately resulting in the onRejectionHandled method being called on the Pledge constructor with an instance of PledgeReactionEvent. This event can’t be cancelled, so there’s no reason to check the returnValue property.

I did need to add a little bit of extra code at the end to toggle the monitoring of rejected promises. You only need to monitor the about-to-be-notified rejected promises list to know when to trigger the unhandledrejection event. (The outstanding promise rejections weak set doesn’t need to be monitored.) To account for that, and to save resources, I turn on the monitor when there is at least one item in the about-to-be-notified rejected promises list and turn it off otherwise.

The actual monitoring process is described in the HTML specification, as well, and is implemented as the startMonitor() method.

Monitoring for promise rejections

The HTML specification1 says that the following steps should be taken to notify users of unhandled promise rejections:

  1. Let list be a copy of settings object’s about-to-be-notified rejected promises list.
  2. If list is empty, return.
  3. Clear settings object’s about-to-be-notified rejected promises list.
  4. Let global be settings object’s global object.
  5. Queue a global task on the DOM manipulation task source given global to run the following substep:
    1. For each promise p in list:
      1. If p’s [[PromiseIsHandled]] internal slot is true, continue to the next iteration of the loop.
      2. Let notHandled be the result of firing an event named unhandledrejection at global, using PromiseRejectionEvent, with the cancelable attribute initialized to true, the promise attribute initialized to p, and the reason attribute initialized to the value of p’s [[PromiseResult]] internal slot.
      3. If notHandled is false, then the promise rejection is handled. Otherwise, the promise rejection is not handled.
      4. If p’s [[PromiseIsHandled]] internal slot is false, add p to settings object’s outstanding rejected promises weak set.

The specification further says:

This algorithm results in promise rejections being marked as handled or not handled. These concepts parallel handled and not handled script errors. If a rejection is still not handled after this, then the rejection may be reported to a developer console.

So this part of the specification describes exactly how to determine when an unhandledrejection event should be fired and what effect, if any, it has on a warning being output to the console. However, the specification doesn’t say when this should take place, so browsers are free to implement it in the way they want. For the purposes of this post, I decided to use setInterval() to periodically check the about-to-be-notified rejected promises list. This code is encapsulated in the startMonitor() method, which you can see here:

export class RejectionTracker { // other methods omitted for space startMonitor() { // only start monitor once if (this.timeoutId > 0) { return; } this.timeoutId = setInterval(() => { const list = this.aboutToBeNotified; this.aboutToBeNotified = new Set(); if (list.size === 0) { this.stopMonitor(); return; } for (const p of list) { if (p[PledgeSymbol.isHandled]) { continue; } const event = new PledgeRejectionEvent(p, p[PledgeSymbol.result]); p.constructor.onUnhandledRejection(event); const notHandled = event.returnValue; if (p[PledgeSymbol.isHandled] === false) { this.outstandingRejections.add(p); } if (notHandled) { this.logger.error(`Pledge rejection was not caught: ${ p[PledgeSymbol.result] }`); } } }, 100); } stopMonitor() { clearInterval(this.timeoutId); this.timeoutId = 0; } }

The first step in stopMonitor() is to ensure that only one timer is ever used, so I check to make sure that timeoutId is 0 before proceeding. Next, list stores a reference to the current about-to-be-notified rejected promises list and then the property is overwritten with a new instance of Set to ensure that the same promises aren’t processed by this check more than once. If there are no promises to process then the monitor is stopped and the function exits (this is not a part of the specification).

Next, each pledge in list is evaluated. Remember that the PledgeSymbol.isHandled property indicates if there’s a rejection handler attached to the pledge, so if that is true, then you can safely skip processing that pledge. Otherwise, the Pledge.onUnhandledRejection() method is called with an event object. Unlike with Pledge.onRejectionHandled(), in this case you care about whether or not the event was cancelled, so notHandled is set to the event’s return value.

After that, the function checks PledgeSymbol.isHandled again because it’s possible that the code inside of Pledge.onUnhandledRejection() might have added a rejection handler. If this property is still false, then the pledge is added to the outstanding rejections weak set to track for any future rejection handler additions.

To finish up the algorithm, if notHandled is true, that’s when an error is output to the console. Keep in mind that the notHandled variable is the sole determinant of whether or not a console error is output; the PledgeSymbol.isHandled property is a completely separate value that only indicates if a rejection handler is present.

The stopMonitor() method simply cancels the timer and resets the timeoutId to 0.

With that, the RejectionTracker class is complete and all of the unhandled rejection tracking from browser implementations are now part of the Pledge library.

Wrapping Up

This post covered how browsers track unhandled promise rejections, which is a bit different than how Node.js tracks them. The browser triggers an unhandledrejection event when a rejected promise is missing a rejection handler as well as outputting a message to the console. If the promise later has a rejection handler assigned, then a rejectionhandled event is triggered.

The description of how this functionality works is spread across both the ECMA-262 and HTML specifications, with the former defining only a small, abstract API while the latter provides explicit instructions to browsers on how to track unhandled rejections.

All of the code from this series is available in the Pledge on GitHub. I hope you’ll download it and try it out to get a better understanding of promises.

And thank you to my sponsors, whose donations supported parts 5 through 7 of this series. If you enjoyed this series and would like to see more in-depth blog posts, please consider sponsoring me. Your support allows independent software developers like me to continue our work.

References
  1. Unhandled promise rejections  ↩2

  2. Properties of Promise Instances 

  3. HostPromiseRejectionTracker ( promise, operation ) 

  4. PerformPromiseThen ( promise, onFulfilled, onRejected [ , resultCapability ] ) 

  5. RejectPromise ( promise, reason ) 

  6. HostPromiseRejectionTracker(promise, operation) 

Categories: Tech-n-law-ogy