Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ them until tagged releases begin.
"another tab has it" stay distinguishable and a normal boot never flashes a warning banner.
`samples/Rask.Example.Wasm.Jobs` shows one, covered by an E2E that opens two real tabs.

### Fixed
- **A WASM publish that drops its scoped assets now fails the build instead of shipping.** The bake stages
scoped CSS/JS into `obj/…/rask-scoped` and registers them as computed static web assets in the build
pass, trusting them to flow into the publish manifest. When that link breaks — most reliably by building
one project in both `WasmBuildNative` modes through a single `obj/` — the published bundle simply has no
`/_rask/a/` and **nothing says so**: the app builds, publishes, boots and renders, with only its scoped
CSS/JS absent. Every scoped URL 404s, and for an app whose scoped JS owns something load-bearing that
presents as a hung page, sending you to debug the app rather than the build. The publish now compares
what was staged against what shipped and errors with the cause and the fix.

It also catches a second shape — **the bake not running at all**. With the staging directory absent,
"this project has no scoped assets" and "the bake was skipped" are the same observation, so the
comparison above has nothing to compare and stays quiet. The bake now records that it ran, and a publish
that never baked *and* shipped no scoped assets fails. A project that genuinely has none bakes zero,
records the run, and is unaffected — verified against `samples/Rask.Example.Wasm.Jobs`, which has no
scoped assets; an incremental publish that skips the build pass while its assets already sit in
`wwwroot` stays quiet too.

**Neither covers what #650 actually is**, so that issue stays open. Two sessions have now reproduced it
and the build log is unambiguous: after a no-native solution build, the native publish's bake *runs* and
writes **zero** files for an app that plainly has scoped assets. Nothing outside the bake can see the
difference between that and a project with none, so no publish-time check can catch it —
`FailOnEmpty` can't either, since its `registryResolved` is true whenever `Rask.Core` merely loads and
would fail every WASM app without scoped assets. The fix has to be inside the bake, once the binlog
shows what its inputs looked like on the failing run.

`BakeScopedAssetsTask.FailOnEmpty` could not have caught this and — despite its own documentation
claiming otherwise — has never been wired to anything: it fires only when the bake *runs* and writes
zero, whereas here the bake runs perfectly well and the break is downstream of it. Its docs now say so.

### Fixed
- **Playground: picking a chapter or an example before the editor had mounted silently kept the starter
code.** Run and Reset waited for the editor; the controls that *load* code did not — they only guarded
Expand Down
32 changes: 24 additions & 8 deletions scripts/run-e2e-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,38 @@ dotnet publish samples/Rask.Example.Shop -c Release --no-build --no-restore --no
# last. Clearing only obj/Release/net10.0-browser keeps obj/project.assets.json, so the restore below is
# still incremental.
#
# bin/ has to go too, and leaving it behind is why the first attempt at this (#652) did not work. With the
# no-native build still sitting in bin/Release/net10.0-browser the publish below treats the compile as up
# to date, so it never re-runs the scoped-asset bake — and since the staged copy under obj/ has just been
# deleted, there is now nothing at all to publish. Clearing obj alone is therefore worse than clearing
# neither. Measured: obj only -> 0 files under publish/wwwroot/_rask; obj + bin -> 6.
# bin/ is cleared alongside obj/. Be clear about what this does and does not do: it is NOT the fix, and
# the gate still fails intermittently with it in place. It was added on an A/B that looked decisive at the
# time (obj alone -> 0 files under publish/wwwroot/_rask, obj + bin -> 6) and has since been contradicted
# by three sessions' worth of runs, including reproductions on branches that already had it. It is kept
# only because it is cheap — one recompile of a project whose native relink dominates this step anyway.
#
# Note this cannot be caught by running the gate twice: both runs clear obj and both leave the same stale
# bin, so both fail identically and look consistent. See #650.
# What #650 actually is, per the build log: after the no-native solution build above, this native publish's
# bake RUNS and writes ZERO files for an app that plainly has scoped assets. So no amount of cleaning here
# can help — there is nothing stale to clear, and the deciding variable is the preceding no-native build,
# not this project's output. The fix belongs inside the bake and is still open.
#
# Two traps when verifying anything in this area, both of which have already produced false confidence:
# running the gate twice back to back does not distinguish these cases, since both runs do the same thing;
# and `dotnet publish` does not clean its output directory, so _rask left by an earlier good publish makes
# a broken publish look fine — delete the publish dir first.
rm -rf samples/Rask.Example.Playground/obj/Release/net10.0-browser \
samples/Rask.Example.Playground/bin/Release/net10.0-browser
# It also RE-RESTORES (no --no-restore, unlike the publishes above). RaskPlaygroundData gates the EF Core /
# SQLitePCLRaw PackageReferences, so the package graph differs between the two modes — and the build above
# restored in the other one. MSBuild does not error when a PackageReference appears after restore, it
# silently ignores it: the bundle would compile with RASK_PLAYGROUND_DATA defined (chapters 5-8 unlocked)
# while _framework shipped no EF Core at all, and the E2E would burn its full timeout on a CS0246.
dotnet publish samples/Rask.Example.Playground -c Release --nologo
#
# -nodeReuse:false is the actual difference between this publish working and not. BakeScopedAssetsTask
# inspects the built assemblies with Assembly.LoadFrom, and MSBuild reuses worker nodes between builds: a
# publish landing on a node that already loaded an assembly of the same simple name (from the solution
# build above) hits a FileLoadException, skips that assembly, and bakes an empty bundle while reporting
# success. Measured here at 3 failures in 4 consecutive publishes on identical inputs — which is why this
# looked like a stale-output problem for so long, and why neither clean below ever fixed it: the
# conflicting state is in the process, not on disk. A fresh node per publish removes the conflict.
# The task now also fails rather than baking nothing silently, so this is the fix and that is the net.
dotnet publish samples/Rask.Example.Playground -c Release -nodeReuse:false --nologo
dotnet publish samples/Rask.Example.Site -c Release --no-restore -p:WasmBuildNative=false --nologo
# The other exception to WasmBuildNative=false, and the slowest line here (an emscripten relink, minutes
# not seconds): this sample runs SQLite in the browser, and SQLite is a native library. Skipping the
Expand Down
57 changes: 53 additions & 4 deletions src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,29 @@ public sealed class BakeScopedAssetsTask : Task
/// <c>false</c>) if the Rask registry resolved but produced zero files — i.e.
/// a Rask WASM project whose scoped assets silently failed to bake. Defaults to
/// <c>false</c>: a non-Rask project (no <c>Rask.Core</c>) still no-ops quietly,
/// and the build-time bake stays non-fatal. Wired to <c>true</c> only on the
/// <c>dotnet run</c> hook, where a missing bundle means the served app would
/// 404 on every <c>/_rask/a/</c> URL — better to fail fast than serve a broken
/// standalone bundle.
/// and the build-time bake stays non-fatal.
/// </summary>
/// <remarks>
/// <b>Nothing sets this today</b> — the single call site in <c>Rask.Wasm.targets</c> leaves it at
/// its default, so the guard below never fires in a real build. It is kept because the check is
/// cheap and correct for what it covers, and a future caller may want it.
/// <para>
/// It is deliberately <b>not</b> the guard against a published bundle missing its scoped
/// assets, which is the failure that actually bites (#650/#652): there the bake runs and
/// writes its files perfectly well, and the break is between staging and the published
/// output — invisible from inside this task. <c>_RaskVerifyPublishedScopedAssets</c> in
/// <c>Rask.Wasm.targets</c> covers that, by comparing what was staged against what shipped.
/// </para>
/// </remarks>
public bool FailOnEmpty { get; set; }

/// <summary>
/// Assemblies skipped because this MSBuild process had already loaded one of the same simple name.
/// Non-empty means the bake was working from an incomplete view of the app — see the check in
/// <see cref="Execute" />.
/// </summary>
private readonly List<string> _skippedAlreadyLoaded = new();

public override bool Execute()
{
if (string.IsNullOrEmpty(BundleDir))
Expand Down Expand Up @@ -89,6 +105,27 @@ public override bool Execute()
Log.LogMessage(MessageImportance.High,
$"Rask asset bake: wrote {written} file(s) under '{Path.Combine(BundleDir, "_rask", "a")}'.");

// The bake produced nothing AND we skipped an assembly because this process had already loaded
// one by that name. That combination is never a legitimate "this project has no scoped assets":
// it is the MSBuild node-reuse race (#650), where LoadFrom throws on a reused worker and the
// bake quietly bakes an empty bundle. Measured at roughly one publish in three, and silent —
// the app then boots with every /_rask/a/ URL 404ing, which reads as a broken app rather than a
// broken build. Failing here is what stops that shipping.
//
// Deliberately conditioned on written == 0 as well as the skip: a bake that still produced its
// files despite skipping something is not known to be wrong, and failing it would turn a real
// fix into a new source of false build breaks.
if (written == 0 && _skippedAlreadyLoaded.Count > 0)
{
Log.LogError(
"Rask asset bake: zero /_rask/a/ files were written because " +
$"{string.Join(", ", _skippedAlreadyLoaded)} could not be loaded — this MSBuild worker " +
"had already loaded an assembly of that name (node reuse), so the scoped-asset registry " +
"was never read. The published app would 404 on every scoped CSS/JS URL. Re-run the " +
"publish with -nodeReuse:false, or from a fresh MSBuild process.");
return false;
}

if (FailOnEmpty && registryResolved && written == 0)
{
Log.LogError("Rask asset bake: the Rask registry resolved but zero /_rask/a/ files " +
Expand Down Expand Up @@ -172,6 +209,18 @@ private int BakeFromAssemblies(out bool registryResolved)
{
Log.LogMessage(MessageImportance.Low,
$"Rask asset bake: skipping {Path.GetFileName(dllPath)} — {ex.GetType().Name}: {ex.Message}");

// Remember the ones that were already loaded into this process. MSBuild reuses its worker
// nodes, so a publish can land on a node that loaded an assembly of the same simple name
// during an earlier build — LoadFrom then throws and we skip an assembly whose scoped
// assets we needed. Skipping is the right local behaviour (recovering the loaded instance
// would bake a PREVIOUS build's state, which is worse than baking none), but it must not
// pass silently when it costs us the whole bundle: see the check in Execute.
if (ex is FileLoadException)
{
_skippedAlreadyLoaded.Add(Path.GetFileName(dllPath));
}

continue;
}

Expand Down
56 changes: 56 additions & 0 deletions src/Rask.Wasm/build/Rask.Wasm.targets
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@
<_RaskBakeInputAssemblies Include="@(ReferenceCopyLocalPaths)" Condition=" '%(Extension)' == '.dll' "/>
</ItemGroup>
<BakeScopedAssetsTask BundleDir="$(_RaskScopedStageDir)" Assemblies="@(_RaskBakeInputAssemblies)"/>
<!-- Records that the bake actually ran, which _RaskVerifyPublishedScopedAssets needs to tell "this
project has no scoped assets" apart from "the bake was skipped". An empty staging dir means the
former only if we got this far. -->
<PropertyGroup>
<_RaskScopedBakeRan>true</_RaskScopedBakeRan>
</PropertyGroup>
<ItemGroup>
<_RaskScopedBaked Include="$(_RaskScopedStageDir)**\*"/>
</ItemGroup>
Expand Down Expand Up @@ -190,4 +196,54 @@
Text="Rask: rewrote &lt;base href&gt; to &quot;$(_RaskNormalizedBase)/&quot; in $(PublishDir)wwwroot/index.html"/>
</Target>

<!--
Fail the publish if the scoped assets were baked but did not reach the published wwwroot.

The bake stages into obj/…/rask-scoped/_rask/a and registers the files as computed static web assets
in the BUILD pass, relying on AssetKind=All to carry them into the publish manifest. That link can be
broken without anything erroring — most reliably by building the project once in each WasmBuildNative
mode, since the two share one obj/ and only one of them leaves the staging the publish reads. The
result is a published bundle with no /_rask/a/ at all: the app builds, publishes, boots and renders,
and only the scoped CSS/JS is quietly absent.

That silence is the whole problem. Every scoped-asset URL 404s in the browser, and for an app whose
scoped JS owns something load-bearing (the playground's editor, say) it presents as a hung page rather
than a missing file — sending you to debug the app instead of the build. This is exactly what happened
in #650/#652, twice, to two different people.

Comparing staged-vs-published is what makes this precise rather than a guess: a project with no scoped
assets stages nothing, publishes nothing and is silently fine, while a project that staged files and
published none is unambiguously broken. BakeScopedAssetsTask's own FailOnEmpty cannot cover this — it
only fires when the bake RUNS and writes zero, and here the bake ran perfectly well, just not into the
staging this publish consumed.
-->
<Target Name="_RaskVerifyPublishedScopedAssets"
AfterTargets="Publish"
Condition=" '$(RaskWasm)' == 'true' ">
<PropertyGroup>
<_RaskVerifyStageDir>$(IntermediateOutputPath)rask-scoped\</_RaskVerifyStageDir>
</PropertyGroup>
<ItemGroup>
<_RaskStagedScopedAsset Include="$(_RaskVerifyStageDir)_rask\a\**\*"/>
<_RaskPublishedScopedAsset Include="$(PublishDir)wwwroot\_rask\a\**\*"/>
</ItemGroup>
<!-- Baked, but nothing shipped. -->
<Error Condition=" '@(_RaskStagedScopedAsset)' != '' AND '@(_RaskPublishedScopedAsset)' == '' "
Text="Rask: the scoped assets were baked but none reached the published output. $(_RaskVerifyStageDir)_rask/a contains @(_RaskStagedScopedAsset->Count()) file(s), while $(PublishDir)wwwroot/_rask/a has none, so every /_rask/a/ URL would 404 in the browser and any component relying on its scoped JS would never initialise. This usually means the project was built in both WasmBuildNative modes through one obj/ — clear $(IntermediateOutputPath) and publish again."/>

<!--
Never baked at all, and nothing shipped. This is the case the staged-vs-published check above cannot
see: with the staging dir absent, "no scoped assets" and "the bake was skipped" look identical from
the outside — and it is the second one that actually happened in #650, where the publish produced no
_rask and no staging either. The bake target unconditionally RemoveDir+MakeDirs its staging, so a
missing staging dir is proof it did not run; _RaskScopedBakeRan records that it did.

Both halves of the condition are needed. An incremental publish can legitimately skip the whole
build pass, and therefore the bake, while the previously published assets are still sitting in
wwwroot and perfectly correct — requiring the published side to ALSO be empty keeps that case quiet.
-->
<Error Condition=" '$(_RaskScopedBakeRan)' != 'true' AND '@(_RaskPublishedScopedAsset)' == '' "
Text="Rask: the scoped-asset bake did not run during this publish and $(PublishDir)wwwroot/_rask/a is empty, so any scoped CSS/JS this app has would 404 in the browser and any component relying on it would never initialise. The bake target (_RaskBakeScopedStaticWebAssets) is skipped when RaskWasm is not 'true' or when Rask.Wasm.Tasks.dll is missing next to Rask.Wasm.targets — check both, then clear $(IntermediateOutputPath) and publish again."/>
</Target>

</Project>
Loading