Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New principle: A Promise represents completion or a value, not a callback (#342) #496

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
56 changes: 56 additions & 0 deletions index.bs
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,62 @@ If the bytes in the buffer have a natural intepretation
as one of the other TypedArray types, provide that instead.
For example, if the bytes represent Float32 values, use a {{Float32Array}}.

<h3 id="allow-microtask-switches">A Promise represents completion or a value, not a callback</h3>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this section belongs next to https://pr-preview.s3.amazonaws.com/jan-ivar/design-principles/pull/496.html#promises, not just at the end of the parent section.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't that renumber § 6.11 through § 6.14?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I'm double-checking with other TAG folks, but I think it's a very bad idea for anyone to refer to sections of a living document by number, and we shouldn't be constrained to preserve those numbers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about what this section actually means, maybe it's something like:

Suggested change
<h3 id="allow-microtask-switches">A Promise represents completion or a value, not a callback</h3>
<h3 id="promises-stay-fulfilled">Don't automatically undo the state represented by a Promise settling</h3>

That implies that setFocusBehavior() violates the principle, but we expect principles to sometimes be violated for good reasons. The text below gives some hints about when to expect violations of this principle, although maybe we can improve those hints...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a different principle. AFAIK it's not possible to unfulfill a promise, so I find this confusing. Also, if setFocusBehavior() violates it, it's not the same principle, since setFocusBehavior() is an example of the principle I had in mind applied correctly.


Avoid requiring that something needs to happen synchronously within
a promise resolution or rejection callback.
Comment on lines +1882 to +1883
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one can actually be "Never", because of the composition pitfall you're describing. Hm, except that you have "Never limit the viability..." down below, implying that you were trying for something more general here. How about:

Suggested change
Avoid requiring that something needs to happen synchronously within
a promise resolution or rejection callback.
When a Promise settles (is fulfilled or rejected),
that tells the developer that
certain facts have become true of the execution environment.
Developers tend to assume that these facts have "inertia":
that they will remain true until something acts on them.
When the platform automatically changes some of these facts,
that risks introducing bugs.
The platform must not change these facts between microtasks.
Always wait at least until a new task runs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You seem to have hit on something useful that is at the core of this:

  • The state of the execution environment, as presented to the developer, cannot change except when executing a new task.

It's OK for the information presented to be a view of the state of things that might subsequently (or even immediately) be invalidated. If the API relates to something that is acting outside of the sandbox, like a camera or another application, then there is no way to be certain that information presented about that thing is correct. The best you can do is send it messages and see what happens. But things that comprise the environment are there to serve the developer's needs.

The whole Promise resolution thing is secondary to all of that. Maybe there is a different framing we could use that highlights that.

Copy link
Contributor Author

@jan-ivar jan-ivar Oct 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review and text!

The state of the execution environment, as presented to the developer, cannot change except when executing a new task.

I'm not sure we can say the environment is immutable. E.g. JS can call track.stop() and synchronously observe track.readyState changed from "live" to "ended". Only in-parallel changes seem problematic.

Also, apart from the busy-wait timer (which is more like ~1 second and a mitigation really), I don't think it's accurate to describe an overly strict and synchronous API contract as an in-parallel change to the environment.

The platform already seems to contain examples of synchronous API contracts, like preventDefault() that don't seem to violate § 5.2. Preserve run-to-completion semantics.

I think the anti-pattern is applying such synchronous API contracts to a promise callback.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the environment can certainly change in response to the developer actively doing something. Thanks for pointing out "Preserve run-to-completion semantics". Maybe the bit about not changing things between microtasks should actually be part of that section. It could use some tweaking anyway because "while a JavaScript event loop is running" includes the time between tasks when we want the platform to change things.

But then what's left for this section to say? There is something interesting about the way that a Promise says "a new state has started", while a callback says "a state is present during this function invocation", but I'm not sure exactly how to express it. setFocusBehavior() is going to violate the rule regardless ... and maybe that's a sign that it should actually be a callback in the DisplayMediaStreamOptions, and not have special permission to run for a short time after the Promise resolves?

Copy link
Contributor Author

@jan-ivar jan-ivar Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A singular underlying principle is encouraging, suggesting we're on the right track, or at least being consistent.

But practically speaking, might it still deserve two guidelines if the patterns to avoid are distinct enough?

Anti-pattern 1:

  • rationale: app must do something right away or not at all; a microtask window seems shorter than a task
  • symptom: breaks the async-functions-are-wrappable invariant

Anti-pattern 2:

  • rationale: mitigate busy-waiting exploits; a 1 second timer deadline won't bother well-behaved apps
  • symptom: breaks malicious or already broken apps as a secondary action-at-a-distance symptom

Reasons to treat them separately:

  • the first one seems more serious than the second
  • the second one seems in the long-running scripts mitigation category; exempt from guidelines?
  • setFocusBehavior() went from violating only the first to violating only the second; an improvement?

I'm concerned these distinctions might be lost if we merge/generalize the language too much.


This breaks basic assumptions around asynchronous APIs being wrappable.
Authors tend to expect that any `async` function can be wrapped
in another that appends synchronous code at the end.
This might be to examine a resolved value or rejection using try/catch.
But using `await` or similar wrappers queue a microtask:
<div class="example">
```js
async function fooWrapper() {
const foo = await platform.foo();
console.log("success");
return foo;
}

(async () => {
const foo = await fooWrapper();
foo.bar(); // on the same task, but not the same microtask that logged "success"
})();
```
</div>

In general, the settled result from an asynchronous function
should be usable at any time.

However, if the result of your API is timing sensitive, you might need
to restrict when it can be acted on.

In the above example, perhaps the `platform.foo()` function
establishes state that has real-time dependencies such that calling
`bar()` is not viable at any future time.

Never limit the viability of acting on a settled result to a microtask,
as this prevents most forms of code composition.

If possible, set a short timeout interval. Failing that, viability can
be limited to the same task, but not a single microtask.

<div class="example">
One case where this came up was the [captureController.setFocusBehavior()](https://w3c.github.io/mediacapture-screen-share/#dom-capturecontroller-setfocusbehavior)
method which controls whether a window the user selects to screen-capture
should immediately be pushed to the front or not. It can be called as late
as right <em>after</em> the resolution of the `getDisplayMedia()` promise. This allows
applications to make a different decision based on the window the user chose.
But the application must act right away or this becomes surprising to the user.

The solution was to add a timeout on top of the requirement of it being
called on the same task that resolves the `getDisplayMedia()` promise.
</div>
Comment on lines +1905 to +1931
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given my suggestion above, some of this text becomes redundant:

Suggested change
In general, the settled result from an asynchronous function
should be usable at any time.
However, if the result of your API is timing sensitive, you might need
to restrict when it can be acted on.
In the above example, perhaps the `platform.foo()` function
establishes state that has real-time dependencies such that calling
`bar()` is not viable at any future time.
Never limit the viability of acting on a settled result to a microtask,
as this prevents most forms of code composition.
If possible, set a short timeout interval. Failing that, viability can
be limited to the same task, but not a single microtask.
<div class="example">
One case where this came up was the [captureController.setFocusBehavior()](https://w3c.github.io/mediacapture-screen-share/#dom-capturecontroller-setfocusbehavior)
method which controls whether a window the user selects to screen-capture
should immediately be pushed to the front or not. It can be called as late
as right <em>after</em> the resolution of the `getDisplayMedia()` promise. This allows
applications to make a different decision based on the window the user chose.
But the application must act right away or this becomes surprising to the user.
The solution was to add a timeout on top of the requirement of it being
called on the same task that resolves the `getDisplayMedia()` promise.
</div>
Occasionally, users need the state represented by a Promise to automatically move on.
<div class="example">
Developers need to be able to call
<code>captureController.{{CaptureController/setFocusBehavior()}}</code>
after the Promise returned by {{MediaDevices/getDisplayMedia()}}
so that they can use attributes of the window the user chose
when deciding how focus should change.
However, users will be surprised if
the focus change doesn't feel like an immediate effect from selecting a window,
so {{CaptureController/setFocusBehavior()}} needs to automatically stop working
shortly after that Promise fulfills.
</div>
A timeout can be a good trigger for these automatic state changes,
especially when they're happening to aid user perception.
If you need a faster automatic state change,
you can [=queue a task=] to make the change.
As mentioned above,
never change the state established by a Promise between microtasks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a faster automatic state change, you can [=queue a task=] to make the change.

Faster in the non-degenerate case. To clarify the example, setFocusBehavior() does both: It queues "a task to run the finalize focus decision algorithm" AND applies a deadline for that task.

IOW, the timer is only there to catch busy-waiting exploits in the callback.


If an API depends on setting up temporary conditions then invoking the caller,
that is a good reason to use a callback rather than a promise.
Comment on lines +1933 to +1934
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe:

Suggested change
If an API depends on setting up temporary conditions then invoking the caller,
that is a good reason to use a callback rather than a promise.
If a feature requires the developer to only rely on some state between precise points within a task,
that can be a good reason to use a callback rather than a promise.


<h2 id="event-design">Event Design</h2>

<h3 id="one-time-events">Use promises for one time events</h3>
Expand Down