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

Re-enable mutObs with better safeguards #403

Merged
merged 9 commits into from
Oct 31, 2023

Conversation

GioSensation
Copy link
Member

@GioSensation GioSensation commented Oct 25, 2023

Reviewer: @shakyShane
Asana: https://app.asana.com/0/0/1205688498594652/f

Description

Re-enables the mutation observer within the form, with some critical changes highlighted inline. There's a lot more info on what I tried out and what I discarded and why in the task.

Steps to test

Re-enabled the automated tests + followed the steps in the parent task.

Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
@GioSensation GioSensation self-assigned this Oct 25, 2023
Signed-off-by: Emanuele Feliziani <[email protected]>
@@ -41,7 +41,7 @@ export async function withEmailProtectionExtensionSignedInAs (page, username) {
* MAX_INPUTS_PER_PAGE: number,
* MAX_FORMS_PER_PAGE: number,
* MAX_INPUTS_PER_FORM: number,
* MAX_FORM_MUT_OBS_COUNT: number
* MAX_FORM_RESCANS: number
Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed to capture the fact that the limit is per scan, regardless of whether the mutation observer initiated the rescan. This was at the heart of the issue we were seeing, where the form could rescan the same forms over and over without running into a failsafe.

Signed-off-by: Emanuele Feliziani <[email protected]>
Comment on lines +86 to +90
// Ensure we destroy the form if it's removed from the DOM
if (!this.form.isConnected) {
this.destroy()
return
}
Copy link
Member Author

Choose a reason for hiding this comment

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

The mutation observer callback is mostly the same as it was. This little bit is new. Just ensures we're cleaning things up and don't run on disconnected forms. Extremely unlikely, but there you go.

// Must check for inputs because a parent may be removed and not show up in record.removedNodes
if ([...this.inputs.all].some(input => !input.isConnected)) {
// This is re-connected in recategorizeAllInputs, disconnecting here to avoid risk of re-work
this.mutObs.disconnect()
Copy link
Member Author

Choose a reason for hiding this comment

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

This is also new. Since the re-scans happen in an idleCallback, I make sure to disconnect the observer here to avoid any risk of the callback running multiple times in a row. Extreme edge case, but there you go 🙂.

Comment on lines +338 to +343
// If the form mutates too much, disconnect to avoid performance issues
if (this.rescanCount >= MAX_FORM_RESCANS) {
this.mutObs.disconnect()
return
}
this.rescanCount++
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the critical part of the fix. In the previous implementation, the counter was only updated within the observer callback, but the recategorizeAllInputs could also be called from addInput coming from the scanner. That was bypassing the failsafes and causing it to spin out of control in certain edge cases. Now the count is incremented right within the method so it's never missed 💪🤞.

this.removeAllDecorations()
this.removeTooltip()
this.forgetAllInputs()
this.matching.clear()
this.intObs = null
this.device.scanner.forms.delete(this.form)
Copy link
Member Author

Choose a reason for hiding this comment

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

This is also new. When destroying the form we also ensure not to keep track of it in the scanner ledger. I don't particularly like reaching out to the scanner, but it's effective.

Comment on lines +384 to +386
// This is rather extreme, but better safe than sorry
this.device.scanner.stopScanner('The form has too many inputs, bailing.')
return
Copy link
Member Author

Choose a reason for hiding this comment

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

This is another crucial part. Previously, the else block would not return nor stop the scanner, thus a lot of work would keep happening after hitting the failsafes. TBH, stopping the scanner completely is probably overkill, but we've been burned before so I'm happy to go nuclear here.

Comment on lines +392 to +394
if (this.form !== document.body) {
this.mutObs.observe(this.form, this.mutObsConfig)
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Another important part here. The problem happened specifically when autofill got tricked into thinking that the whole body was a form (for example, when there is an input field at the top of the page and a bunch of fields down below). This ensures that when that happens we don't append the observer at all. Below there are a couple other measures to ensure we decrease the chance of identifying the body as the form.

console.log('The form has too many inputs, destroying.')
}
this.destroy()
this.device.scanner.stopScanner('The form has too many inputs, destroying.')
Copy link
Member Author

Choose a reason for hiding this comment

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

Again, going nuclear. Instead of just destroying the form, we stop the scanner altogether.

}

// When new inputs are added after the initial scan, reanalyze the whole form
if (this.initialScanComplete && this.rescanCount < MAX_FORM_RESCANS) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Checking the counter here as well, to avoid running the FormAnalyzer again, which is very expensive when the form doesn't have good markup.

genericTextField,
'[autocomplete=username]',
'select'
]
Copy link
Member Author

Choose a reason for hiding this comment

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

I noticed that there was a lot of duplication and incomplete selectors between these two, so I've moved them closer, and derive one from the other to avoid duplication and one getting out of sync with the other. The meaningful change here is to add :not([placeholder^=search i]) which skips more search fields.

<div class="TopbarPageHeaderGlobalActions-settingsMenuAvatar Avatar AvatarPhoto AvatarPhoto--small AvatarPhoto--color6"
role="img"
style="background-image: url(&quot;https://s3.amazonaws.com/profile_photos/1144596632862977.1144596633302696.AZv0WLh0YnXpeia8yd53_60x60.png&quot;);"></div>
</a></div>
Copy link
Member Author

Choose a reason for hiding this comment

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

Just reformatted while I was using to debug.

let element = input
// traverse the DOM to search for related inputs
while (element.parentElement && element.parentElement !== document.documentElement) {
while (traversalLayerCount <= 5 && element.parentElement && element.parentElement !== document.documentElement) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the last important bit. We limit the upward traversal when searching for a form container. This decreases the chance of false-matching the body as a form container. For example, in Asana, with a specific sequence of clicks the algo could have matched the whole body, thus running extremely expensive checks on the whole DOM. With this change the algo stops at the rows of tasks, as expected. This ensures we hit the max form limit much quicker and have more tightly-scoped observers.

@GioSensation GioSensation changed the title [WIP] Re-enable mutObs with better safeguards Re-enable mutObs with better safeguards Oct 25, 2023
@GioSensation GioSensation marked this pull request as ready for review October 25, 2023 16:43
@shakyShane
Copy link
Collaborator

thanks for the deep-dive and the inline annotations @GioSensation 🙏🏻

@GioSensation GioSensation merged commit e9c8133 into main Oct 31, 2023
1 check passed
@GioSensation GioSensation deleted the ema/fix-algo-spiralling-out-of-control branch October 31, 2023 07:48
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 this pull request may close these issues.

2 participants