Skip to content

Clarify that the scheduling state is supposed to be per event loop #114

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

Open
sefeng211 opened this issue Feb 18, 2025 · 9 comments · May be fixed by #115
Open

Clarify that the scheduling state is supposed to be per event loop #114

sefeng211 opened this issue Feb 18, 2025 · 9 comments · May be fixed by #115

Comments

@sefeng211
Copy link

Currently the scheduling state is set on the event loop, which means mulitple globals (multiple iframes) that share the same event loop can potentially interfere with each other.

It feels unusual to have this behaviour, or at least it'd be safer to make it per global.

@shaseley Scott, do you mind confirm that the intention was to make it per event loop?

@smaug----
Copy link

The specific worry I had when reviewing the implementation is that if there are same site (not same origin) iframes, they share the same event loop because with .domain one can make those same origin.

https://html.spec.whatwg.org/#event-loops

@shaseley
Copy link
Collaborator

I think of the scheduling state as a property of the currently running task or microtask (accessed through the event loop), which should be null at the start of each event loop task (cleared after postTask callback and microtasks). A task can call into other windows/contexts, but the per-task state remains consistent (i.e. priority has not changed), which I think is desirable in cases where frames (intentionally/are mutually expecting to) script each other.

For most scenarios I was thinking about, scripting is intentional and requires A to call B for B to learn about A, e.g.:

// Frame B (child)
window.doSomethingInOtherFrame = async() => {
  let taskFirst = false;
  await Promise.race([scheduler.postTask(() => { taskFirst = true; }), scheduler.yield()]);
  if (taskFirst) {
    console.log('The yield had lower priority');
  }
}

// Frame A (parent)
scheduler.postTask(async () => {
  // Call into frame B, otherwise B's code doesn't run in this task.
  await child.contentWindow.doSomethingInOtherFrame();
}, {priority: 'background'});

But I guess frame A could unintentionally run frame B code via events, e.g. if frame B calls A's window.addEventListener, and A dispatches an event during a postTask() callback or descendant microtask (assuming B knows such an event). Maybe there are other ways? But then frame B could just call frame A's window.scheduler.yield(), so I don't think scoping the state to the window/scheduler would help.

Are there other scenarios I'm missing, or does that still seem concerning?

@smaug----
Copy link

smaug---- commented Feb 21, 2025

I'm worried about the cases when something happens synchronously in a cross origin iframe when parent does something for example in a promise callback.
https://mozilla.pettay.fi/focus.html
Didn't yet test whether Chrome leaks scheduling information from the parent to the iframe in this kind of case. Note, there is no synchronous access between Window globals in this case, pages are same site, not same origin.

@sefeng211
Copy link
Author

@shaseley Here the updated testcase that Olli and I came up https://mozilla.seanfeng.dev/files/outer2.html.

It sets the Origin-Agent-Cluster: ?0 on both documents to make sure they run in the same process (again, they are same-site-but-different-origin documents).

If you click the focus me input, here's the result in Chrome 134

Received message: got focus from https://public.seanfeng.dev
outer2.html:8 Outer page will call focus().
inner2.html:9 Inner page got blur.
outer2.html:10 Outer page did call focus().
inner2.html:14 inner task run (priority: user-visible)
outer2.html:14 outer task run (priority: user-visible)
inner2.html:19 inner yield run (priority: ????)
outer2.html:19 outer yield run (priority: background)

What looks suspicious is the inner yield run (priority: ????). If this yield has the same priority as the outer task, the yield should run first because it's a continuation. However this isn't the case, the yield runs after, which likely means it inherits the background priority from the outer document. So here we are leaking the priority to a cross origin document.

@smaug----
Copy link

smaug---- commented Feb 21, 2025

Might be worth to test also on Android without Origin-Agent-Cluster: ?0.
There browsers may not create processes for all the cross-origin or even cross-site iframes.

Anyhow, the cross-origin leak is rather serious and could easily break sites in very unexpected way.
(Though, even just changing scheduling in same origin iframes could break stuff unexpectedly)

@shaseley
Copy link
Collaborator

Thanks for the example, agreed this case is problematic. Making the state per-Scheduler/global might be fine -- I'll think this over and talk to a few folks, and update this soon.

@shaseley
Copy link
Collaborator

shaseley commented Mar 3, 2025

Update: I patched Blink to check "same origin-domain" where the scheduling state is used (as a temp fix), and I added a WPT test for the blur() case based on the example, along with some other cross-frame tests documenting the current behavior. web-platform-tests/wpt#51023.

As far as resolving this, I think there are several options:

  1. Add a same origin-domain check when using the scheduling state in scheduler.yield()
  2. Clear the scheduling state when executing script (excluding scheduler.postTask() and requestIdleCallback() callbacks), i.e. clear the state when dispatching events/callbacks synchronously during another task or microtask, for the duration of the script.
  3. Make propagation per-global
  4. (2) and (3)

I'm leaning towards (2) since asynchronous callbacks/events that occur within a task/microtask aren't clearly a continuation of that task, as the blur() example illustrates. Treating nested callbacks/events as new "task roots" with no scheduling state seems reasonable, and I think it would eliminate some unexpected behavior.

I'm not sure about (3) though. I think it could lead to surprising behavior if doing intentional cross-frame scripting, like frameA calls postTask(frameB_Task); or frameA_Task calls a frameB_Function, which would behave differently if it were in a library.

@smaug----
Copy link

"Nested" events are very much part of the ongoing task. That is the model microtasks have had for 13 years now. (microtask checkpoint is at the end of outermost script execution or innermost task).

I think (3) would make most sense. That way caller from page A can't affect (and thus break) scheduling of something in page B so easily.

@shaseley
Copy link
Collaborator

shaseley commented Mar 4, 2025

"Nested" events are very much part of the ongoing task.

I just meant that code observing behavior (event listener) might be conceptually a different "task" / "thread of execution" (or whatever, these are all overloaded) from the developer's perspective than the code causing behavior -- even though they are literally part of the same task:

addEventListener("done", async () => {
  // Get out of the way.
  await scheduler.yield();
  doDependentOperation1();
});

addEventListener("done", async () => {
  // Get out of the way.
  await scheduler.yield();
  doDependentOperation2();
});

function task() {
  // ...

  // Inform observers:
  dispatchEvent(new Event("done"));
}

But it's easy enough to schedule things as separate tasks rather than continuations if that's indeed what's intended.

@shaseley shaseley linked a pull request Mar 5, 2025 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants