JavaScript Async Contexts
Chengzhong Wu (Alibaba) 2020-06
Problems
🤯
Incomplete Error Stacks 🤒
TypeError: Failed to fetch
at rejectPromise
window.onload = e => {
fetch("https://no-exist.com").then(res => {
// doing something...
});
};
Error thrown in host provided async operations, like fetch API and net/http modules in Node.js, didn’t reflect the real cause location.
Where did we come to here? 🤔
export async function handler(ctx, next) {
const span = Tracer.startSpan();
// First query runs in the synchronous context of the request.
await dbQuery({ criteria: 'item > 10' });
// What about subsequent async operations?
await dbQuery({ criteria: 'item < 10' });
span.finish();
}
async function dbQuery(query) {
// How do we determine which request context we are in?
const span = Tracer.startSpan();
await db.query(query);
span.finish();
}
It can be very helpful to have ThreadLocal/AsyncLocal like features in JavaScript.
Leaking tasks 😵
test(() => {
// How to early fail the test with this leaking task?
setTimeout(() => {
throw new Error('foobar');
}, 1000);
});
// ✓ passed
test(async () => {
// How to early fail the test with this leaking task?
/** await */ asyncOperation();
});
// ✓ passed
- Early errors help debugging and testing strange behaviors and make applications easy to observe.
- Many JavaScript computing platforms also would like to know if all async tasks were resolved in an environment like Realms.
What are we solving?
Motivation
Ergonomically track async tasks in JavaScript
What is “async context”s
document.getElementById('button').onclick = e => {
// (1)
fetch("https://example.com").then(res => {
// (2)
return processBody(res.body).then(data => {
// (5)
const dialog = html`<dialog>Here's some cool data: ${data}
<button>OK, cool</button></dialog>`;
dialog.show();
dialog.querySelector("button").onclick = () => {
// (6)
dialog.close();
};
});
});
};
function processBody(body) {
// (3)
return body.json().then(obj => {
// (4)
return obj.data;
});
}
What is “async context”s
A simple plotting of the example async operation flow.
What is “async context”s
“Resource” can be multiplexed to request-responses on multiple async contexts.
Why can’t this be left to libraries?
Why can’t this be left to host environments?
Goals
Why no “error handling”?
API Shapes
🚧 possible solution
No global accessing async local
class AsyncLocalStorage {
constructor();
enterWith(store: any);
exit();
getStore(): any;
}
Most exciting features built on top of async contexts tracking. “Global”s for async flows, like user interaction flow, http server request scope.
Use Case: timing
// tracker.js
const store = new AsyncLocalStorage();
export function start() {
// (a)
store.enterWith({ startTime: Date.now() });
}
export function end() {
// (b)
const dur = Date.now() - store.getStore().startTime;
console.log('onload duration:', dur);
store.exit()
}
import * as tracker from 'tracker.js'
window.onload = e => {
// (1)
tracker.start()
fetch("https://example.com").then(res => {
// (2)
return processBody(res.body).then(data => {
// (3)
const dialog = html`<dialog>Here's some cool data: ${data}
<button>OK, cool</button></dialog>`;
dialog.show();
tracker.end();
});
});
};
Simple re-entrancy implementation of calculating time consumed of a series of async operations without additional context.
API for Library Owners
Manual declaring an async resource which will trigger a series of async operations. Tracking outstanding third-party library with custom scheduling
class AsyncTask {
constructor(name);
get name;
runInAsyncScope(callback[, thisArg, ...args]);
}
Usage: multiplexing tasks
class DatabaseConnection {
constructor(port, host) {
// Initialize connection, possibly in root context.
this.socket = connect(port, host)
}
async query(search) {
const query = new Query(search)
const result = await this.socket.send(query)
// This context is triggered by `DatabaseConnection`
// which is not linked to initiator of `DatabaseConnection.query`.
return query.runInAsyncScope(() => {
// Promise linked to the initiator of `DatabaseConnection.query`.
// Promise -> Query(AsyncTask) -> `DatabaseConnection.query`
return Promise.resolve(result)
})
}
}
class Query extends AsyncTask {
constructor(search) {
// scheduled async task
super('database-query')
this.search = search
}
}
Hooks!
class AsyncHook {
constructor(hookSpec);
enable();
disable();
}
interface HookSpec {
scheduledAsyncTask(task, triggerTask);
beforeAsyncTaskExecute(task);
afterAsyncTaskExecute(task);
}
Tracking async tasks scheduling & executions
Usage: tasks tracking
const als = new AsyncLocalStorage();
const backlog = [];
const hook = new AsyncHook({
scheduledAsyncTask (task) {
const test = als.getStore();
if (test == null) {
return;
}
backlog.push(new WeakRef(task));
}
});
hook.enable();
als.enterWith(name);
try {
await callback();
} finally {
hook.disable();
als.exit();
}
assert(
backlog.filter(ref => ref.deref() != null).length === 0,
`'${name}' ended with dangling async tasks.`
);
Assert on no outstanding tasks out lives on end of the run
Next Steps
Stage 1: explore more possibilities of API shapes to provide explicit async flow management