Skip to content

Cache assemblies and wasm using content hashes #18859

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

Merged
merged 42 commits into from
Feb 17, 2020

Conversation

SteveSandersonMS
Copy link
Member

@SteveSandersonMS SteveSandersonMS commented Feb 6, 2020

This is a prerequisite for the PWA work, but also benefits people who aren't building a PWA, since it means the client no longer has to make If-Modified-Since requests for all the assemblies and the wasm file. It also means that badly-configured servers (not returning etags) won't result in bad perf.

It's an unusually complicated change because:

  • I had to reorganize a lot of the logic for loading assemblies to factor parts out of MonoPlatform.ts
  • I had to add some extra logic for switching between "streaming" and "arraybuffer" wasm compilation modes, since we can no longer inherit that from emscripten (reasons in comments)
  • Some reordering was needed in the MSBuild targets, because we now need to collect info about the wasm files earlier so we can include their content hashes in blazor.boot.json
  • The caching logic has to be consumable in two different modes:
    • Evaluating the whole network response and validating the hash before completing the promise (for loading .NET assemblies/pdbs)
    • Returning the network response before it's fully loaded, and then validating the hash and caching as a separate background task (for loading wasm binaries, because streaming compilation only makes sense if you start it immediately and not after waiting for the response data to fully arrive)

Some not-strictly-required improvements I also made along the way:

  • Changed GenerateBlazorBootJson so it uses S.T.J rather than DataContractJsonSerializer
  • Fixed what seems to be a bug in the MSBuild targets that was double-writing pdb files to the output, and was unnecessary logic

The build will fail because I haven't yet updated the .js binaries and I think some tests will need to be updated too.

As for testing this in general, I plan to write CTI tests. I considered trying to write E2E tests but:

  • It would involve getting Selenium to report on the network traffic so we could observe whether things were retrieved from cache, which is not something we do elsewhere. It may be achievable, but:
  • Concurrent test execution would break things, because the cache is shared across all browser instances and tabs. To deal with this, we'd have to launch the browser on separate user profile directories for each test, but:
    • That's whole new infrastructure I don't want to build
    • Even if we did create that, it's not likely to be portable across browsers, e.g., when these tests run against actual devices. And it would make for slow tests, creating new user profiles for each.

However I think it will be possible to write pretty comprehensive CTI tests as the tester will be able to check the browser cache contents using dev tools, and verify things look right as far as console log output etc.

Simulated stats

According the browser dev tools, here's the effect on the time taken to fetch all the resources needed to load the page. This is based on a warm HTTP cache (so static resources such as .dlls are returning 304s either way):

Scenario Without this change With this change
Slow 3G 13.5s 4.1s
Fast 3G 5.1s 2.4s
Localhost (artificial scenario) 0.6s 0.1s

The difference is simply due to eliminating 30+ HTTP requests that all would have returned 304s.

Real-world stats

I've been trying to come up with an argument that we won't actually need to do this at all, and could rely on doing it all in a service worker instead. Unfortunately the data isn't agreeing with me. I deployed to GitHub pages and accessed it over a reasonable quality 4G connection. After ~20 trials both with and without this feature, measured by "finish" time displayed in browser dev tools:

Without this change With this change
25th centile 1.57s 0.48s
Median 1.68s 0.71s
75th centile 2.02s 0.91s

@ghost ghost added the area-blazor Includes: Blazor, Razor Components label Feb 6, 2020
<_BlazorBootResource Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
<_BlazorBootResource BootResourceType="assembly" Condition="'%(Extension)' == '.dll'" />
<_BlazorBootResource BootResourceType="pdb" Condition="'%(Extension)' == '.pdb'" />
<_BlazorBootResource BootResourceType="wasm" Condition="'%(Extension)' == '.wasm'" />
Copy link
Member

Choose a reason for hiding this comment

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

Should we be concerned about files that don't match one of these extension types? Are the non-matching files all of the other static assets that don't belong in boot.json?

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 ones without the BlazorRuntimeFile are the other static assets that don’t belong in the boot json.

Additionally we’re excluding other extensions from boot json by virtue of not assigning any BootResourceType. This eliminates the AssemblyName.xml files that would otherwise sometimes appear.

Copy link
Member

Choose a reason for hiding this comment

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

Why do we think it is important to differentiate by dll, pdbs and wasm? I think having the Condition piece should be good enough.

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 logic about how to distinguish the file types has to exist somewhere. I agree it could be done on the .NET side too but that seems neither better nor worse. If you can anticipate some problem with doing it on the MSBuild side I'm fine with changing it, but otherwise I suspect it's a neutral choice and we're just as well leaving it as-is. It's an internal implementation detail we can change later if we want.

Copy link
Contributor

@pranavkm pranavkm left a comment

Choose a reason for hiding this comment

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

Looks pretty neat. The JSON thing as Ryan pointed out needs to be fixed. Is there also a way to add some tests for the loader and it's caching?

@SteveSandersonMS SteveSandersonMS force-pushed the stevesa/content-hash-caching branch from 2ef6130 to c60982f Compare February 11, 2020 09:26
@SteveSandersonMS
Copy link
Member Author

Is there also a way to add some tests for the loader and it's caching?

Please see the very long PR description for details :)

Copy link
Member

@javiercn javiercn left a comment

Choose a reason for hiding this comment

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

Overall looks like a great set of changes!

High level feedback:

  • I have a bunch of comments about the specific caching mechanics.
    • It's not clear to me if we are taking a cache first approach or doing something different, but I can't discuss it on a PR effectively.
  • I propose we change the hashing format with the idea of simplifying things on the JS side.

// Bypass the entire cache flow, including checking hashes, etc.
// This gives developers an easy opt-out if they don't like anything about the default cache mechanism
const response = fetch(url, networkFetchOptions);
const data = response.then(r => r.clone().arrayBuffer());
Copy link
Member

Choose a reason for hiding this comment

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

Why do we clone the response data here?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added comment:

      // Cloning represents the response as two separate streams so we can process it twice independently
      // (once for hash checking, once for streaming compilation). Without this, there would be a read error.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, sorry. I should have been clearer. We are returning the response here, so we shouldn't need to clone it, as we are not going to cache it. Am I missing something obvious here?

Copy link
Member Author

Choose a reason for hiding this comment

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

We're returning the response so it can be read externally, and a promise that resolves with its data. These two separate actions can't be done on a single response stream, hence the need to clone it.

However when I go back to this and make use of fetch(..., { integrity: ... }), it might be that quite a bit of this complexity goes away. There might be no need to be reading the response data separately. I'll find out when I try it :)

Copy link
Member Author

Choose a reason for hiding this comment

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

It has got much simpler now I've changed to use integrity.

There is still one place where the response has to be cloned: before we put it into the cache. This is to be expected since we need to read the body (for caching) separately from returning it upstream. The "return from cache" code path, however, no longer has to clone.

<_BlazorPackageContentOutput Include="@(BlazorPackageContentFile)" Condition="%(SourcePackage) != ''">
<TargetOutputPath>$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
</_BlazorPackageContentOutput>
<BlazorOutputWithTargetPath Include="@(_BlazorPackageContentOutput)" />
</ItemGroup>
Copy link
Member

Choose a reason for hiding this comment

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

How do we debug Release builds? Is that a thing we want to support? I can debug regular .net apps in Release configuration.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure how the question relates to this PR. Is it a general question about whether pdb files should be emitted and loaded in release builds? If so, a totally reasonable question but let's find a separate channel for discussing it, as this PR doesn't change anything about that.

Copy link
Member

Choose a reason for hiding this comment

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

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 don't really follow the link you've posted, since the code on those lines doesn't seem to say anything about whether pdbs are exposed.

However there was an issue with controlling whether PDBs are loaded: #18655. I've now amended Blazor.MonoRuntime.targets to fix this. Specifically, I changed how we filter out PDBs from the output so that it's controlled by the BlazorEnableDebugging in the same way whether linking is enabled or disabled.

Copy link
Contributor

@pranavkm pranavkm left a comment

Choose a reason for hiding this comment

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

Once @javiercn is happy. Re-iterating my previous question - is there a way to add tests for the resource loader?

Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
-->
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" Condition="'%(Extension)' == '.dll'" />
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" />

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 condition is needed as a workaround for #18951. We might find a better solution later, but that's separate from this PR.

@pranavkm pranavkm added this to the blazor-wasm-3.2-preview2 milestone Feb 11, 2020
@SteveSandersonMS
Copy link
Member Author

I just realised the manual hash checking is completely unnecessary. We can pass { integrity: '<hash>' } as an option to fetch and it's all done for us. This will simplify several aspects of the flow :)

@javiercn
Copy link
Member

I just realised the manual hash checking is completely unnecessary. We can pass { integrity: '<hash>' } as an option to fetch and it's all done for us. This will simplify several aspects of the flow :)

I was super excited about this, but I'm sad to be the bearer of bad news :(.

It is not supported in Safari looks like. See here

@SteveSandersonMS
Copy link
Member Author

It is not supported in Safari looks like.

Good news :) It totally does work, at least in the use case we have here. I verified it on Safari iOS both in the success and failure cases for integrity checking. Caniuse has outdated info for this.

From Safari's release notes, it looks like this has been supported since at least release 65, which was ages ago.

@SteveSandersonMS SteveSandersonMS force-pushed the stevesa/content-hash-caching branch from d664f6e to 62fd199 Compare February 17, 2020 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants