Promises created from async functions don't resolve when document is detached. 
    Categories
(Core :: DOM: Core & HTML, defect, P3)
Tracking
()
People
(Reporter: john.david.dalton, Unassigned)
References
(Blocks 1 open bug)
Details
(Keywords: parity-chrome)
Attachments
(1 file)
| 392 bytes,
          text/html         | Details | 
User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Steps to reproduce:
(async () => {
var i = document.createElement('iframe');
document.body.appendChild(i);
var a = i.contentWindow.eval('(async () => await 1)');
i.remove(); // someone removes the iframe
const v = await a();
console.log(v);
})();
Actual results:
Our use case has code that works when wired up and then someone removes the iframe and await no longer resolves.
Expected results:
The snippet works in Chrome.
| Comment 1•5 years ago
           | ||
Bugbug thinks this bug should belong to this component, but please revert this change in case of error.
| Comment 2•5 years ago
           | ||
Thank you for reporting.
The issue is the following:
- await 1in the iframe's async function enqueues a promise reaction job to the job queue
- in the next microtask checkpoint, the job is performed [1],
 but given that the global for the iframe is dying because it's detached,
 the callback isn't called [2]
- await 1waits forever, given the promise isn't resolved
- the promise for the iframe's async function is left pending
- await a()waits forever, given the promise isn't resolved
I'll check how the spec defines the steps for this case.
bool CycleCollectedJSContext::PerformMicroTaskCheckPoint(bool aForce) {
...
  for (;;) {
    RefPtr<MicroTaskRunnable> runnable;
...
      runnable = std::move(mPendingMicroTaskRunnables.front());
      mPendingMicroTaskRunnables.pop();
...
    if (runnable->Suppressed()) {
...
    } else {
      if (mPendingMicroTaskRunnables.empty() &&
          mDebuggerMicroTaskQueue.empty() && suppressed.empty()) {
        JS::JobQueueIsEmpty(Context());
      }
      didProcess = true;
      LogMicroTaskRunnable::Run log(runnable.get());
      runnable->Run(aso);
      runnable = nullptr;
    }
  }
class PromiseJobRunnable final : public MicroTaskRunnable {
...
  virtual void Run(AutoSlowOperation& aAso) override {
    JSObject* callback = mCallback->CallbackPreserveColor();
    nsIGlobalObject* global = callback ? xpc::NativeGlobal(callback) : nullptr;
    if (global && !global->IsDying()) {
      // Propagate the user input event handling bit if needed.
      nsCOMPtr<nsPIDOMWindowInner> win = do_QueryInterface(global);
      RefPtr<Document> doc;
      if (win) {
        doc = win->GetExtantDoc();
      }
      AutoHandlingUserInputStatePusher userInpStatePusher(
          mPropagateUserInputEventHandling);
      mCallback->Call("promise callback");
      aAso.CheckForInterrupt();
    }
| Comment 3•5 years ago
           | ||
First, when an iframe gets removed, its document's browsing context is set to null.
https://html.spec.whatwg.org/#the-iframe-element
When an iframe element is removed from a document, the user agent must discard
the element's nested browsing context, if it is not null, and then set the
element's nested browsing context to null.
https://html.spec.whatwg.org/#a-browsing-context-is-discarded
To discard a browsing context browsingContext, run these steps:
  1. Discard all Document objects for all the entries in browsingContext's session history.
...
https://html.spec.whatwg.org/#discard-a-document
To discard a Document document:
...
  7. Set document's browsing context to null.
Then, HostEnqueuePromiseJob says it doesn't run the job if the document's "browsing context" is null.
https://html.spec.whatwg.org/#hostenqueuepromisejob
...
  6. Queue a microtask on the surrounding agent's event loop to perform the
     following steps:
    1. If job settings is not null, then check if we can run script with job
       settings. If this returns "do not run" then return.
...
https://html.spec.whatwg.org/#check-if-we-can-run-script
The steps to check if we can run script with an environment settings object
settings are as follows. They return either "run" or "do not run".
  1. If the global object specified by settings is a Window object whose
     Document object is not fully active, then return "do not run".
...
https://html.spec.whatwg.org/#fully-active
A Document d is said to be fully active when d's browsing context is non-null,
d's browsing context's active document is d, and either d's browsing context is
a top-level browsing context, or d's container document is fully active.
This means a promise reaction is ignored if the global is dying,
unless there's something else that cleans up pending promises when the global dies.
I'll look into the spec some more.
| Updated•5 years ago
           | 
| Comment 4•5 years ago
           | ||
| Comment 5•5 years ago
           | ||
Safari Technology Preview also stops at await.
| Comment 6•5 years ago
           | ||
fwiw, chrome behaves differently between local file and remote file.
with remote file (attachment), it always prints 1.
with local file (downloaded attachment), it prints 1 if DevTools is closed on load, and it throws the following error if DevTools is opened on load:
TypeError: Promise resolve or reject function is not callable
    at Promise.then (<anonymous>)
| Updated•5 years ago
           | 
| Updated•5 years ago
           | 
| Updated•5 years ago
           | 
Our clients ran into this issue when using our components on Salesforce, who in turn recommend transpiling async / await away for FF https://developer.salesforce.com/docs/platform/lwc/guide/security-lwsec-async.html?_ga=2.249124807.1359724086.1730723767-755359336.1698399851. Would be great if that was not needed 😅
| Comment 8•11 months ago
           | ||
But is it a bug? My read of comment 3 is that this is per spec, and WebKit behaves the same... So in any case this seems like a bug in Chromium?
Tooru, do you know if there's a crbug for this? Should we file it?
| Comment 9•11 months ago
           | ||
(In reply to Emilio Cobos Álvarez (:emilio) from comment #8)
But is it a bug? My read of comment 3 is that this is per spec, and WebKit behaves the same... So in any case this seems like a bug in Chromium?
Yes, this is per spec.
Tooru, do you know if there's a crbug for this? Should we file it?
I'm not aware of existing bug filed for chromium.
and yeah, it would be nice to file bugs on other engines, referring the whatwg issue
| Updated•11 months ago
           | 
| Comment 10•11 months ago
           | ||
Then let's suggest a spec change.
Infinite waits are just a bug.
| Comment 11•11 months ago
           | ||
Well, executing JS of detached / inactive documents is arguably also a bug? Specially since rejecting the promise involves JS execution.
| Comment 12•11 months ago
           | ||
Also interesting is what spec change... https://github.com/whatwg/html/issues/2621 has a lot of discussion, some of which I'm not an expert on :)
| Comment 13•5 months ago
           | ||
Seems like both Chrome and Safari log now 1 when running the testcase.
| Updated•5 months ago
           | 
| Comment 14•5 months ago
           | ||
Several navigation api wpt tests depend on this behavior, right or wrong.
| Comment 15•5 months ago
           | ||
Looks like there have been several refactoring in the HTML spec around the "fully active", "navigable", "active document", "container document", etc, and I haven't yet figured out whether they're normative change or not.
If there was any normative change that affects "can run script" condition, or the "dying" condition, that may be causing the compat issue.
I'll continue investigating, but if anyone else knows the details about the change, please ping me.
| Comment 16•5 months ago
           | ||
Here's the result of research.
In short, the spec seems to be saying the same thing as before, and detaching an iframe results in not executing any of promise reaction jobs there.
I still haven't gotten used to the new concept used in the spec and how widely the definition spreads around,
so I'm not sure if I'm interpreting the spec correctly.
Details:
(1) Can run script
Whether to perform the promise reaction job or not is controlled by the "check if we can run script" steps, which checks if the document is "fully active":
https://html.spec.whatwg.org/#hostenqueuepromisejob
HostEnqueuePromiseJob(_job_, _realm_)
  ...
  2. [Queue a microtask] to perform the following steps:
    1. If _job settings_ is not null, then
         [check if we can run script] with _job settings_.
         If this returns "do not run" then
           return.
https://html.spec.whatwg.org/#check-if-we-can-run-script
The steps to [check if we can run script] with
  an [environment settings object] _settings_
are as follows.
They return either "run" or "do not run".
  1. If the [global object] specified by _settings_ is a Window object whose
     Document object is not [fully active], then
       return "do not run".
  2. If [scripting is disabled] for _settings_, then
       return "do not run".
  3. Return "run".
(2) Fully active
"Fully active" condition depends on multiple things, but to my understanding the "active document" part is related.
https://html.spec.whatwg.org/#fully-active
A Document _d_ is said to be [fully active] when
  * _d_ is the [active document] of a [navigable] _navigable_, and
  * either
    * _navigable_ is a [top-level traversable] or
    * _navigable_'s [container document] is [fully active].
(3) Active document
"Active document" is determined by "active session history entry" of a navigable.
https://html.spec.whatwg.org/#nav-document
A [navigable]'s [active document] is its [active session history entry]'s
[document].
(4) Navigable and iframe
An iframe creates a corresponding navigable when it's connected to a document.
https://html.spec.whatwg.org/#the-iframe-element:create-a-new-child-navigable
The iframe [HTML element post-connection steps], given
  _insertedNode_,
are:
  1. [Create a new child navigable] for _insertedNode_.
  ...
https://html.spec.whatwg.org/#create-a-new-child-navigable
To [create a new child navigable], given
  an [element] _element_:
  1. Let _parentNavigable_ be _element's_ [node navigable].
  ...
  7. Let _navigable_ be a new [navigable].
  8. [Initialize the navigable] _navigable_ given
       _documentState_ and
       _parentNavigable_.
  9. Set _element_'s [content navigable] to _navigable_.
  ...
Here's the iframe's "content navigable" becomes the child navigable.
When the iframe is removed from a document, the child navigable is destroyed,
and the child navigable's "active session history entry"'s "document state"'s "document" is set to null.
https://html.spec.whatwg.org/#the-iframe-element
The iframe [HTML element removing steps], given
  _removedNode_,
are to [destroy a child navigable] given _removedNode_.
https://html.spec.whatwg.org/#destroy-a-child-navigable
To [destroy a child navigable] given a [navigable container] _container_:
  ...
  5. [Destroy a document and its descendants] given
       _navigable_'s [active document].
  ...
https://html.spec.whatwg.org/#destroy-a-document-and-its-descendants
To [destroy a document and its descendants] given
  a Document _document_ and
  an optional set of steps _afterAllDestruction_,
perform the following steps in parallel:
  ...
  6. [Queue a global task] on the [navigation and traversal task source] given
       _document_'s [relevant global object]
     to perform the following steps:
    1. [Destroy] _document_.
    ...
https://html.spec.whatwg.org/#destroy-a-document
To [destroy] a Document _document_:
  ...
  8. Set _document_'s [browsing context] to null.
  9. Set _document_'s [node navigable]'s [active session history entry]'s
     [document state]'s [document] to null.
If this "document" field is the same thing as what the "active document" is checking, this means the iframe's content document is no longer an active document once detached, and it means the document is no longer fully active, thus the script settings can no longer run script, which means the promise reaction jobs there won't be performed.
So, to my understanding, the expected behavior haven't changed since the comment #3.
I'll look into see if the global handling have been modified.
(If that happened, this might be seeing the top-level document instead of the iframe document somehow, and that may explain the behavior difference)
| Comment 17•5 months ago
           | ||
Here's the result of checking the assocaited global.
In short, I don't see any change there, and the iframe's global and the settings should be used for the reaction, thus it should be skipped.
await 1 - create promise
await 1 first creates a promise for 1.
It creates a Promise instance, and fulfills it with the primitive value 1.
The promise object doesn't have any reaction at this point, so nothing happens
regarding the incumbent settings.
https://tc39.es/ecma262/#await
Await ( value )
  ...
  2. Let promise be ? PromiseResolve(%Promise%, value).
https://tc39.es/ecma262/#sec-promise-resolve
PromiseResolve ( C, x )
  1. If IsPromise(x) is true, then
    ...
  2. Let promiseCapability be ? NewPromiseCapability(C).
  3. Perform ? Call(promiseCapability.[[Resolve]], undefined, « x »).
  4. Return promiseCapability.[[Promise]].
https://tc39.es/ecma262/#sec-promise-resolve-functions
Promise Resolve Functions
When a promise resolve function is called with argument resolution,
the following steps are taken:
  ...
  8. If resolution is not an Object, then
    a. Perform FulfillPromise(promise, resolution).
    b. Return undefined.
https://tc39.es/ecma262/#sec-fulfillpromise
FulfillPromise ( promise, value )
  2. Let reactions be promise.[[PromiseFulfillReactions]].
  3. Set promise.[[PromiseResult]] to value.
  ...
  6. Set promise.[[PromiseState]] to fulfilled.
  7. Perform TriggerPromiseReactions(reactions, value).
https://tc39.es/ecma262/#sec-triggerpromisereactions
TriggerPromiseReactions ( reactions, argument )
  1. For each element reaction of reactions, do
    ...
  2. Return unused.
await 1 - awaiting
await 1 then creates the onFulfilled function.
https://tc39.es/ecma262/#await
Await ( value )
  ...
  3. Let fulfilledClosure be a new Abstract Closure with parameters (v) that
     captures asyncContext and performs the following steps when called:
       ...
  4. Let onFulfilled be CreateBuiltinFunction(fulfilledClosure, 1, "", « »).
the realm parameter is omitted, thus it uses the current realm,
which is the async function's realm, which is the iframe's realm.
https://tc39.es/ecma262/#sec-createbuiltinfunction
CreateBuiltinFunction ( behaviour, length, name, additionalInternalSlotsList
                        [ , realm [ , prototype [ , prefix ] ] ] )
  1. If realm is not present, set realm to the current Realm Record.
  ...
  5. Let func be a new built-in function object that, when called,
       performs the action described by behaviour using the provided arguments
       as the values of the corresponding parameters specified by behaviour.
     The new function object has internal slots whose names are the elements
     of internalSlotsList, and an [[InitialName]] internal slot.
  ...
  8. Set func.[[Realm]] to realm.
  ...
  13. Return func.
https://tc39.es/ecma262/#sec-createbuiltinfunction
await 1 - enqueue job
Finally, await 1 performs the "then" operation.
https://tc39.es/ecma262/#await
Await ( value )
  ...
  7. Perform PerformPromiseThen(promise, onFulfilled, onRejected).
https://tc39.es/ecma262/#sec-createbuiltinfunction
https://tc39.es/ecma262/#sec-performpromisethen
PerformPromiseThen ( promise, onFulfilled, onRejected [ , resultCapability ] )
  3. If IsCallable(onFulfilled) is false, then
    ...
  4. Else,
    a. Let onFulfilledJobCallback be HostMakeJobCallback(onFulfilled).
Here, HostMakeJobCallback is called for the onFulfilled.
It retrieves the incumbent settings object and stores into the job callback
record, but later it turns out not being used until the job is called.
https://html.spec.whatwg.org/#hostmakejobcallback
HostMakeJobCallback(callable)
  1. Let incumbent settings be the incumbent settings object.
  2. Let active script be the active script.
  3. Let script execution context be null.
  4. If active script is not null,
     set script execution context to a new JavaScript execution context,
     with its Function field set to null,
          its Realm field set to active script's settings object's realm, and
          its ScriptOrModule set to active script's record.
  5. Return the JobCallback Record {
       [[Callback]]: callable,
       [[HostDefined]]: {
         [[IncumbentSettings]]: incumbent settings,
         [[ActiveScriptContext]]: script execution context
       }
     }.
After that, fulfillReaction is created with:
- [[Capability]]being- undefined,
- [[Handler]]being the job callback created above, with the incumbent settings
and then a reaction job is created with:
- [[Job]]being a job that performs- HostCallJobCallback
- [[Realm]]being- onFulfilled's realm
and the job is enqueued.
https://tc39.es/ecma262/#sec-performpromisethen
PerformPromiseThen ( promise, onFulfilled, onRejected [ , resultCapability ] )
  2. If resultCapability is not present, then
    a. Set resultCapability to undefined.
  ...
  7. Let fulfillReaction be the PromiseReaction Record {
       [[Capability]]: resultCapability,
       [[Type]]: fulfill,
       [[Handler]]: onFulfilledJobCallback
     }.
  ...
  9. If promise.[[PromiseState]] is pending, then
    ...
  10. Else if promise.[[PromiseState]] is fulfilled, then
    a. Let value be promise.[[PromiseResult]].
    b. Let fulfillJob be NewPromiseReactionJob(fulfillReaction, value).
    c. Perform HostEnqueuePromiseJob(
         fulfillJob.[[Job]], fulfillJob.[[Realm]]).
  11. Else,
    ...
  12. Set promise.[[PromiseIsHandled]] to true.
https://tc39.es/ecma262/#sec-newpromisereactionjob
NewPromiseReactionJob ( reaction, argument )
  1. Let job be a new Job Abstract Closure with no parameters that
     captures reaction and argument and performs the following steps when
     called:
    a. Let promiseCapability be reaction.[[Capability]].
    b. Let type be reaction.[[Type]].
    c. Let handler be reaction.[[Handler]].
    d. If handler is empty, then
      ...
    e. Else,
      i. Let handlerResult be Completion(HostCallJobCallback(
           handler, undefined, « argument »)).
    f. If promiseCapability is undefined, then
      i. Assert: handlerResult is not an abrupt completion.
      ii. Return empty.
    ...
  2. Let handlerRealm be null.
  3. If reaction.[[Handler]] is not empty, then
    a. Let getHandlerRealmResult be
       Completion(GetFunctionRealm(reaction.[[Handler]].[[Callback]])).
    b. If getHandlerRealmResult is a normal completion,
         set handlerRealm to getHandlerRealmResult.[[Value]].
    c. Else,
         set handlerRealm to the current Realm Record.
    ...
  4. Return the Record { [[Job]]: job, [[Realm]]: handlerRealm }.
HostEnqueuePromiseJob uses the passed realm, which is the iframe's realm.
The realm is used for retrieving the settings object, which is used inside the
microtask.
https://html.spec.whatwg.org/#hostenqueuepromisejob
HostEnqueuePromiseJob(job, realm)
  1. If realm is not null, then
       let job settings be the settings object for realm.
     Otherwise,
       let job settings be null.
  2. Queue a microtask to perform the following steps:
    1. If job settings is not null, then
         check if we can run script with job settings.
         If this returns "do not run" then
           return.
    ...
Reaction job
Finally, when the microtask is performed, the microtask first checks the
"can run script", using the "job settings" for the iframe's realm,
and then if it can run, calls the job.
Then job is the promise reaction job created below, and it
performs HostCallJobCallback.
https://html.spec.whatwg.org/#hostenqueuepromisejob
HostEnqueuePromiseJob(job, realm)
  1. If realm is not null, then
       let job settings be the settings object for realm.
     Otherwise,
       let job settings be null.
  2. Queue a microtask to perform the following steps:
    1. If job settings is not null, then
         check if we can run script with job settings.
         If this returns "do not run" then
           return.
    2. If job settings is not null, then
         prepare to run script with job settings.
    3. Let result be job().
    ...
https://tc39.es/ecma262/#sec-newpromisereactionjob
NewPromiseReactionJob ( reaction, argument )
  1. Let job be a new Job Abstract Closure with no parameters that
     captures reaction and argument and performs the following steps when
     called:
    a. Let promiseCapability be reaction.[[Capability]].
    b. Let type be reaction.[[Type]].
    c. Let handler be reaction.[[Handler]].
    d. If handler is empty, then
      ...
    e. Else,
      i. Let handlerResult be Completion(HostCallJobCallback(
           handler, undefined, « argument »)).
    f. If promiseCapability is undefined, then
      i. Assert: handlerResult is not an abrupt completion.
      ii. Return empty.
    ...
HostCallJobCallback extracts the incumbent settings and uses it.
So, the incumbent settings doesn't contribute to the "can run script" condition.
https://html.spec.whatwg.org/#hostcalljobcallback
HostCallJobCallback(callback, V, argumentsList)
  1. Let incumbent settings be callback.[[HostDefined]].[[IncumbentSettings]].
  2. Let script execution context be
     callback.[[HostDefined]].[[ActiveScriptContext]].
  3. Prepare to run a callback with incumbent settings.
     If script execution context is not null, then
       push script execution context onto the JavaScript execution context
       stack.
  4. Let result be Call(callback.[[Callback]], V, argumentsList).
  5. If script execution context is not null, then
       pop script execution context from the JavaScript execution context stack.
  6. Clean up after running a callback with incumbent settings.
  7. Return result.
And all the "can run script" condition is performed with the iframe's realm and
its "job settings".
Settings object and realms
https://html.spec.whatwg.org/#concept-realm-settings-object
There is always a 1-to-1-to-1 mapping between realms, global objects,
and environment settings objects:
  * A realm has a [[HostDefined]] field,
    which contains the realm's settings object.
https://html.spec.whatwg.org/#script-settings-for-window-objects
Script settings for Window objects
  1. Let realm be the value of execution context's Realm component.
  2. Let window be realm's global object.
  3. Let settings object be a new environment settings object whose algorithms are defined as follows:
     ...
  ...
  7. Set realm's [[HostDefined]] field to settings object.
The "can run script" uses the "the global object specified by settings", that should be the iframe's global.
https://html.spec.whatwg.org/#check-if-we-can-run-script
The steps to check if we can run script with
  an environment settings object settings
are as follows.
They return either "run" or "do not run".
  1. If the global object specified by settings is a Window object whose
     Document object is not fully active, then
       return "do not run".
  ...
https://html.spec.whatwg.org/#concept-settings-object-global
There is always a 1-to-1-to-1 mapping between realms, global objects, and environment settings objects:
  ...
  * An environment settings object's realm then has a [[GlobalObject]] field,
    which contains the environment settings object's global object.
I'll look into when and how the Chrome and Safari behavior had changed.
There may be some hint in the patch, commit message, or the bug comments.
| Comment 18•5 months ago
           | ||
I've done bisection for Chrome with https://www.chromium.org/developers/bisect-builds-py/ ,
and the range where the behavior changed is https://chromium.googlesource.com/chromium/src/+log/81e020db125608d76d479acaa41d56a24ff33407..195df62b68fa1798379a91f09ac0c695388c8e3a
Possibly from https://chromium.googlesource.com/chromium/src/+/a5a3f3a8599e454645674a6de1a54660c34f8faf
where the detached iframe can now access objects,
but apparently this is not something directly related to the condition for the detached iframe vs job queue.
For Safari/WebKit, I've tried using https://github.com/WebKit/WebKit/blob/main/Tools/Scripts/bisect-builds , but the https://archives.webkit.org/ server used by the script seems to be no longer functional, and I cannot do the bisection,
and also I cannot find a binary archive that contains builds from 1+ years (https://webkit.org/build-archives/ contains 3 months, and the oldest one shows the same behavior as the current one), and also building older revision from the source looks tricky, as it hits many errors on the latest env.
So I'm not able to find the change where the Safari's behavior had changed.
| Comment 19•5 months ago
           | ||
posted a comment to the WebKit bugzilla to see if anyone knows the details.
https://bugs.webkit.org/show_bug.cgi?id=216149
| Updated•4 months ago
           | 
| Updated•4 months ago
           | 
Description
•