Synchronous promise inspection

Finally came across a case in spec discussions where synchronous promise inspection would be extremely valuable: Watcher simplification · Issue #222 · tc39/proposal-signals · GitHub

It'd be extremely nice for promises to expose properties to do synchronous state inspection, and not just require deferral. Something like state: "pending" | "fulfilled" | "rejected" and value/reason getters. And by "extremely nice" I mean "I'd have little choice but to roll my own awkward promise subclass if you all don't provide this".

And that link isn't my only use case; far from it.

It's a very intentional part of the design to never allow synchronous interrogation of a Promise's state; doing so releases zalgo.

Just for the record, doing so does not release Zalgo. Full stop.

1 Like

To be specific, the only time Zalgo is released is when your callback (invoked through a promise or otherwise) may run synchronously or asynchronously and you would have no way of knowing which.

I do not see this proposal suggesting synchronous callback-calling, so I infer that it is Zalgo-safe.

1 Like

that’s not accurate; the phrase refers to when something can have both sync or async behavior. The only way to avoid it is to have something be either always sync, or always async, and never mix the two.

I thought you would say that, but funny enough it's the Zalgo article itself that argues against your position. You're saying that one should always use synthetic deferrals, and the article you're using to justify the position has a rather large bit of headline text in it that says, and I quote:

Avoid Synthetic Deferrals

I know what you’re thinking: “But you just told me to use nextTick!”

And yes, it’s true, you should use synthetic deferrals when the only alternative is releasing Zalgo. However, these synthetic deferrals should be treated as a code smell. They are a sign that your API might not be optimally designed.

He goes on to show "check synchronously to see if you need to wait asynchronously" as one of the preferred solutions to the problem, and he even demonstrates it with a code example to make sure that people understand what he is saying they should do.

1 Like

I read that as saying, it's ok if you conditionally decide to call an sync vs async API, but that API must always have the same behavior (sync vs async). In other words, it doesn't say that it's ok for the API to do that, it says that it's ok for YOU to do that.

This means that synchronous promise inspection could be used in a perfectly fine way! It also means that it could easily be used in a way that unleashes zalgo.

It's ok that we have different interpretations of the article, of course.

This means that synchronous promise inspection could be used in a perfectly fine way! It also means that it could easily be used in a way that unleashes zalgo.

This is true, but Isaac must have known as much when he wrote the article. He was advocating patterns that could be used to squeeze all the possible performance out of a system without releasing Zalgo.

1 Like

but that API must always have the same behavior (sync vs async)

Yes. I think that property is sufficiently well preserved though. It's a bit of a different situation with promises than you have in a purely callback-based APIs where there might not be any synchronous return value at all. Having the promise there as the synchronous return value fulfills the requirement that all sync calls have sync behavior -- that is they all always give you a chance to continue your own thread of execution (such as attaching then handlers to the promise) before the then callback is triggered.

The nasty part is that is that in the resulting code you have two scenarios as the caller:

 function transformValue (input = 2) {
  const promise = getValue(input);

  if (promise.state.status === "fulfilled") {
     const resultPromise = Promise.resolve(promise.state.value + 2);

     assert(resultPromise.state.status === "fulfilled");

     return resultPromise;
  } else {
     return promise.then(value => value + 2);
  }
}

In one scenario you know that anything bad that could possibly happen has already happened, and you're ready to proceed synchronously. This specifically means there is no need to interact with the async scheduler, which is a major perf win. In the other you have to wait to proceed, and you need the caller to know that they may need to handle deferred errors.

Clearly this code has ugly duplication in it, but it's both fast and correct. What's really wrong here is the expectation that people would go to that much work writing and reading the duplicated logic. If this pattern is going to be super common, this syntax is just too verbose. You would need it to be dirt simple, something like:

async function transformValue (input = 2) {
  return await? getValue(input) + 2;
}

The upshot is that transformValue would indicate a synchronous return when it could, that is: when it had never actually had to wait anywhere. The implementation would remain backwards compatible, as existing code would not be aware of the promise.state API.

And to be very extra clear: no then callback would ever by triggered synchronously!

If you had just return await getValue(input) + 2 there, what's the performance cost compared to your desired API? It seems like it'd just be one tick.

One tick. So mostly setting up the promise handlers (unwinding the call stack), and storing async stack traces (rebuilding the call stack).

That wouldn't be an outlandish cost, except that it's incurred on every single stack frame that control passes through. In such a system the incentive is to keep the call stack as short as possible since every level of calling indirection adds additional overhead.

The formula for whether this will be a problem for you is "average depth of call stack" times "average sparsity of async events in your data stream".

If you have a call stack 10 frames deep and a data source where 1/10 results require waiting then 1/100 times you destroy and rebuild the call stack will be because it was actually necessary. In other words, 99x the required overhead.

Personally, I don't see "easily misused" as holding much weight. Not given existing precedent.

  • Proxies are easily misused. You can break almost all user intuition with non-frozen proxies. Doesn't mean people do it.
  • Addition of new environment globals is frequently misuse, and that's one of the key things globalThis provides the ability to do in a cross-platform way.
  • Non-error exceptions are an extremely obvious example of an easily misused feature. There are legitimate use cases: a highly recursive algorithm could use it instead of a (potentially too-slow) generator to break early. But it's also a gigantic footgun (you have to filter every exception) and it's usually better to just use regular return values in most cases you'd throw a non-error.
  • Atomics' wait/waitAsync and notify are hard to get right in the first place. Ordinary web developers generally have to do a lot of reading just to not shoot themselves in the foot with what's essentially a minigun.
  • Atomics.waitAsync is actually direct precedent here. It returns an object with a sometimes-async value, and a boolean async to detect this.

And to be clear, I'm not interested in any changes to when anything is called - in fact, I'd oppose changing await, and I'm not convinced an await? conditional suspend is a good idea.

I just want the instance accessors as I can derive performance improvements (namely, avoid waiting for a second animation frame callback run) out of it in an algorithm that already necessarily plans around far worse Zalgo (that I can't even prevent) with reentrancy and state potentially moving out from under me.

I don't think it's about if something can be misused, it's about the balance.
Also because JS can't break the web, nothing is ever removed from the language. So the presence of an API doesn't necessarily mean it's still a good modal to follow. Perspectives change over time.

I have an existing ideas thread that attempts to introduce a new type of "mixed iterator", but I think this idea is brilliant because it can supersede mixed iterators: it solves the same problems without introducing any new primitive types!

On one hand is the risk of accidental misuse, which we have several avenues to address. On the other hand is safe, efficient processing of text streams, for which some approach like this is a necessity due to the unfavorable async-ness sparsity ratios when reading character data from block storage. If the hope is "a lack of language support will prevent people mixing sync and async" I think that ship has sailed. When your perf and abstraction concerns dictate a mixture, a mixture is what you will have.

Sure. I'm just motivated by performance for reasons similar to why Atomics.waitAsync returns an optionally-async value.

I'm motivated by near-term pragmatic needs, in code similar to this segment of code but in a code path it'd be called a lot more often in.

I think first I'll try making a library-based solution that uses a weak map to track the extra state about the promise objects.

The principal benefit is that if the features of the library ever become standard in the language the library can offer an easy upgrade path to the language-native implementation. For my existing code that lets me ship before I know what the standard implementation would be (or if it will ever come to exist).

It also gives everyone a chance to try a system which works like this and see how the particular trade-offs play out over time

Hate to break it to you, but your code will likely break with async/await, as there's many pitfalls. I've been around that block before, adapting a legacy framework feature (not my design choice) to work with async/await: Make `m.request` work with `async`/`await` correctly. by dead-claudia · Pull Request #2428 · MithrilJS/mithril.js · GitHub

I just read through that thread, but it sounds like you're describing trying to monkey-patch every promise in JS all at once. I'm just thinking of a library that helps you the places you use it and is otherwise invisible to the JS core. It is possible with the magic of weak maps.

// sync-promise-library.js
const { freeze } = Object;
const states = new WeakMap();

const settled = (promise, value, status) => {
    states.set(promise, freeze({ status, value }));
    return value;
}

export const resolveSync = (value) => {
    const promise = wrap(Promise.resolve(value));
    states.set(promise, freeze({ status: 'resolved', value }));
    return promise;
}

export const wrap = (promise) => {
    const promise_ = states.has(promise) ? promise : promise.then(
        (value) => settled(promise_, value, 'resolved'),
        (value) => settled(promise_, value, 'rejected'),
    );
    return promise_;
}

export const getState = (promise) => {
    return states.get(promise) || freeze({ status: 'pending', value: undefined });
}

(Edit: Sorry for all edits. I probably should have written this in a proper dev environment instead of the Discourse post editor...)