Page MenuHomePhabricator

Consider removing new-line insertion from JavaScriptMinifier
Closed, ResolvedPublic

Description

Background

All JavaScript files in our codebase are delived to the browser by ResourceLoader. In production, these are passed through JavaScriptMinifier, which parses every token in every file, to determine whether it can be compressed (by removing whitespace). It does this at fast and with additional caching on top. Periodically (every 1000 characters or so) we try to break up the line.

When a JavaScript file (in a gadget, or extension) contains unknown or invalid syntax, we accept that with such invalid input, the minifier may produce invalid output (GIGO principle). After all, we don't need to support ES 16.0 code in our minifier, when our browsers require ES 7.0 code, because we wouldn't be writing ES 16.0 code in the first place, as we couldn't ship it anyway. We'll support it when we need it. So far so good.

As the JavaScript specification is under continuous development with new specifications each year, it is inevitable that some unknown syntax will be attempted to be used in a gadget or extension. Equally inevitably, is that developers may occasionally forget to run a linter, or syntax checker, or misconfigure the linter in one of their source repositories. Or in the case of gadgets, we currently still have a (temporary) option that allows a gadget to skip this syntax invalidation step (T362855).

  1. The most likely and least problematic failure scenario is under-compression: Since the unknown syntax isn't supported yet, we could compress better and remove more spaces/comments in the future for greater effiency. This is harmless.
  1. The least likely but unavoidable failure scenario is over-compression: If the future/unknown syntax involved a token that is similar to an existing syntax where a space is optional, but it requires the space, there's a small chance we may be compressing it too much. This is unavoidable, but very uncommon. The good thing is that this will be deterministic and immediately cause a syntax error or other behaviour change that should be easy to spot and reliably introduce. In any event, remember that we're talking about cases where a mistake has already happened: we have skipped/misconfigured a linter and unsupported code has made it into production, and now the minifier is running over it.
  1. There is a third scenario: This is fairly common, and I believe entirely avoidable. To break up the payload we try to spread it over multiple lines roughly every 1000 characters. Today, we do this by, after 1000 characters (or shortly before it) insert a new line if the syntax indicates that it is safe to do so. In practice this happens after every 990-1050 characters or so. The problem with this is that, for syntax we don't know that is masqerading as something we do know, our information about what is "safe" is just as likely to be right as it is to be wrong. This means we may be production invalid output for something that was actually valid on input (but unknown/newer than we support).

In these cases, it's "fine" for us to fail. We can't guruantee perfectly compressing something that we don't recognise or support yet. That's the interface contract. The question is, how do we fail?

Problem

Today, we fail in quite a subtle way. The inserted line break will be dynamically inserted at run-time in the middle of a compressed JavaScript blob, a blob that is automatically generated and then transmitted between server and browser. It is not for humans to review. It will be syntactically different or syntactically invalid in some subtle way that you're not likely to notice.

Unit tests? 100% code coverage is infeasible and expensive. If you did cover the affected code with QUnit and run it via MediaWiki/Quibble in WMF CI, you may potentially catch this. But this is unlikely. While issue 1 and 2 above are deterministic, issue 3 (which this task is about) is not a deterministic. It only affects 1 in every 1000 characters. Depending on what else the payload contains, it may only happen sometimes, and vary on the page, or what you have in cache etc. Unless the syntax and the offset line up exactly, you won't be able to reproduce it!

Solution

Today we remove all unneeded white space and line breaks, and every 1000th character or so we also try to insert a line break of our own. (If we can't, we'll do it at the next safe oppertunity). Given that a line in the source code can contain many different tokens (e.g. "var foo = fn(a + b);" is 10 tokens), our inserted line breaks are usually at positions where the original code did not have any, and the ones that were there, we remove for optimal compression. Again, this is fine when you have a correctly configured linter, and is fine when the input contains only supported syntax.

I suggest we instead, when we would like to insert a line break, to preserve the next line break that was already in the input, never create our own.

Imagine a compression break preference of 20 characters (production: 1000).

input.js
// My documentation comment
var foo = x(a + b) / y();
foo++;

// Start
var bar = y(
  z(a),
  z(b),
  z(c),
  z(d),
  z(e)
);
output.js Status Quo
var foo=x(a+b)/y();
foo++;var bar=y(z(a)
,z(b),z(c),z(d),z(e));
output.js Proposed
var foo=x(a+b)/y();foo++;
var bar=y(z(a),z(b),z(c),
z(d),z(e));

Outcome

  1. A compressor should never output invalid code when the input was valid. This gets us closer to that ideal. If you're using invalid syntax, you should find out from you linter (locally during dev, or in CI before merge) or from a runtime error from the browser informing you about it. You don't want the minifier subtly breaking or changing behavior of the code in a non-deterministic way.
  2. Compressed payloads will internally build slightly longer lines than before. Previously we inserted a line break every 990-1010 chars (either before or after the end of the "next" token when we exceed the threshold, given token lengths of ~10 chars). This will now be every 1000-1080 chars (at the end of an original line, given 50-80 char lines). This has no impact on performance or debuggability.
  3. Input to ResourceLoader that is already compressed will now have the same line length as the original, instead of gaining additional line breaks. This has no impact on performance or debuggability. This applies to gadgets or core/extension modules that embed an upstream JavaScript dependency, and copy an already-minified version of that dependency.

Details

Event Timeline

Restricted Application added a subscriber: Aklapper. · View Herald Transcript
Krinkle triaged this task as Medium priority.Jun 22 2024, 8:08 PM
Krinkle added a subscriber: Catrope.

I'm proposing this as a maintenance task next quarter to take on between Hannah and Derick (writing and review, respectively).

Change #1084719 had a related patch set uploaded (by Hokwelum; author: Hokwelum):

[mediawiki/libs/Minify@master] remove new line insertion

https://gerrit.wikimedia.org/r/1084719

Change #1084719 merged by jenkins-bot:

[mediawiki/libs/Minify@master] JavaScriptMinifier: Remove new line insertion

https://gerrit.wikimedia.org/r/1084719

FTR: I came across another instance where the bug can result in misbehaving code: from this test patch:

const testTemplate = function ( x ) {
	return `<td class="ext-campaignevents-eventdetails-user-row-checkbox">
			<div class="oo-ui-layout oo-ui-fieldLayout oo-ui-fieldLayout-align-left">
				<div class="oo-ui-fieldLayout-body">
						<label title="${ x }"`;
};

The minifier will insert a line break after return, causing the function to return undefined. It took me a while to debug this because MW and the browser kept trying REALLY HARD to show me the pretty-printed code instead of the minified version, but I was eventually able to confirm the issue, and also confirm that it gets fixed by r1084719.

Which leads me to the next question: why has core not been updated to a version of JavaScriptMinifier with the fix above? This task has been closed as resolved 3 months ago, but no new version of Minify has been tagged since then, core is still buggy, and I just spent more than 1 hour overall, trying to understand why my JavaScript code wasn't doing what it's supposed to do. Is this blocked on something else?

Hello @Daimona! Sorry you had to go through all that. Hopefully, the fix should be included in the next MediaWiki deployment train, so core will be updated soon.

Let us know if you run into any issues after the rollout!

@Hokwelum AIUI the problem is that no new version of Minify has been released and included in mediawiki/vendor and then core itself, so just waiting for the next train won't fix this issue. It'll be necessary to tag a new Minify release, update its copy in vendor, and then update the package version in core.

Core currently still pulls in wikimedia/minify 2.8.0, which is missing the fix.

Yes, @mszabo! We'll be doing all that! That's the only way the fix would be in the next mw train!

Hello @Daimona! Sorry you had to go through all that. Hopefully, the fix should be included in the next MediaWiki deployment train, so core will be updated soon.

Let us know if you run into any issues after the rollout!

Yes, @mszabo! We'll be doing all that! That's the only way the fix would be in the next mw train!

Thank you!

For posterity: fixed in minify 2.8.1, rolled out in T387507. Thank you!