From 58bdf0055002a564cb1959590bb626a839d8b461 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 10:43:48 +0200 Subject: [PATCH 1/6] fix(wasm): fail the publish when scoped assets are baked but never shipped 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 has no /_rask/a/ at all and nothing says so. The app builds, publishes, boots and renders; only its scoped CSS/JS is missing, every scoped URL 404s, and for an app whose scoped JS owns something load-bearing it presents as a hung page. That cost two people a debugging session each (#650, #652). The publish now compares what was staged against what shipped. Both halves are load-bearing: checking only the published side would fail every project that legitimately has no scoped assets, and checking only staging would never fire. The error names the cause and the fix. BakeScopedAssetsTask.FailOnEmpty could not have caught this, and despite its own documentation claiming it is 'wired to true on the dotnet run hook', nothing has ever set it -- the single call site leaves it at its default. It fires only when the bake RUNS and writes zero, whereas here the bake runs fine and the break is downstream. Its docs now say both things. Verified against a real publish in all three states: staged-but-not-published errors with the message, neither passes, both passes. Closes #650 --- CHANGELOG.md | 15 +++ src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs | 17 +++- src/Rask.Wasm/build/Rask.Wasm.targets | 35 +++++++ .../PublishedScopedAssetGuardTests.cs | 95 +++++++++++++++++++ 4 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be64f30..d7aeca37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,21 @@ 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. A project with no scoped + assets stages nothing and is silently unaffected. Closes #650. + + `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 diff --git a/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs b/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs index 18c78fe0..be44407f 100644 --- a/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs +++ b/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs @@ -54,11 +54,20 @@ public sealed class BakeScopedAssetsTask : Task /// false) if the Rask registry resolved but produced zero files — i.e. /// a Rask WASM project whose scoped assets silently failed to bake. Defaults to /// false: a non-Rask project (no Rask.Core) still no-ops quietly, - /// and the build-time bake stays non-fatal. Wired to true only on the - /// dotnet run hook, where a missing bundle means the served app would - /// 404 on every /_rask/a/ URL — better to fail fast than serve a broken - /// standalone bundle. + /// and the build-time bake stays non-fatal. /// + /// + /// Nothing sets this today — the single call site in Rask.Wasm.targets 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. + /// + /// It is deliberately not 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. _RaskVerifyPublishedScopedAssets in + /// Rask.Wasm.targets covers that, by comparing what was staged against what shipped. + /// + /// public bool FailOnEmpty { get; set; } public override bool Execute() diff --git a/src/Rask.Wasm/build/Rask.Wasm.targets b/src/Rask.Wasm/build/Rask.Wasm.targets index b2d5b868..b983c7a7 100644 --- a/src/Rask.Wasm/build/Rask.Wasm.targets +++ b/src/Rask.Wasm/build/Rask.Wasm.targets @@ -190,4 +190,39 @@ Text="Rask: rewrote <base href> to "$(_RaskNormalizedBase)/" in $(PublishDir)wwwroot/index.html"/> + + + + <_RaskVerifyStageDir>$(IntermediateOutputPath)rask-scoped\ + + + <_RaskStagedScopedAsset Include="$(_RaskVerifyStageDir)_rask\a\**\*"/> + <_RaskPublishedScopedAsset Include="$(PublishDir)wwwroot\_rask\a\**\*"/> + + + + diff --git a/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs b/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs new file mode 100644 index 00000000..4c203748 --- /dev/null +++ b/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs @@ -0,0 +1,95 @@ +namespace Rask.Wasm.Tasks.Tests; + +/// +/// Pins the publish-time guard in Rask.Wasm.targets that fails a WASM publish whose scoped +/// assets were baked but never reached the published output. +/// +/// +/// The bake stages into obj/…/rask-scoped and registers computed static web assets in the build +/// pass, trusting them to flow into the publish manifest. When that link breaks — most reliably by +/// building the project in both WasmBuildNative modes through one 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 missing. For an app whose scoped JS owns something +/// load-bearing that presents as a hung page, which is how #650/#652 cost two people a debugging +/// session each. +/// +/// There is no harness here for executing targets, so this asserts the guard's shape: that +/// it still runs after publish, still compares staged against published rather than merely +/// checking one of them, and is still scoped to Rask WASM projects. The behaviour itself was +/// verified by running the target against a real publish in all three states — staged-but-not- +/// published (errors), neither (passes), both (passes). +/// +/// +public sealed class PublishedScopedAssetGuardTests +{ + private static readonly string _targets = File.ReadAllText(Path.Combine( + LocateRepoRoot(), "src", "Rask.Wasm", "build", "Rask.Wasm.targets")); + + [Fact] + public void The_publish_is_verified_after_it_runs_for_rask_wasm_projects() + { + Assert.Contains("_RaskVerifyPublishedScopedAssets", _targets, StringComparison.Ordinal); + + var target = TargetBody(); + Assert.Contains("AfterTargets=\"Publish\"", target, StringComparison.Ordinal); + Assert.Contains("'$(RaskWasm)' == 'true'", target, StringComparison.Ordinal); + } + + // The comparison is the whole point. A guard that only checked the published side would fail every + // project that legitimately has no scoped assets; one that only checked staging would never fire. + [Fact] + public void The_guard_compares_what_was_staged_against_what_shipped() + { + var target = TargetBody(); + + Assert.Contains("_RaskStagedScopedAsset", target, StringComparison.Ordinal); + Assert.Contains("_RaskPublishedScopedAsset", target, StringComparison.Ordinal); + // Staged side reads the bake's staging dir; published side reads the publish output. + Assert.Contains("$(_RaskVerifyStageDir)_rask\\a\\**\\*", target, StringComparison.Ordinal); + Assert.Contains("$(PublishDir)wwwroot\\_rask\\a\\**\\*", target, StringComparison.Ordinal); + Assert.Contains("rask-scoped", _targets, StringComparison.Ordinal); + + // Errors only on staged-non-empty AND published-empty — both halves, or it misfires. + Assert.Contains("'@(_RaskStagedScopedAsset)' != '' AND '@(_RaskPublishedScopedAsset)' == ''", + target, StringComparison.Ordinal); + } + + // The message is the deliverable: the failure it replaces named nothing at all. + [Fact] + public void The_error_says_what_broke_and_what_to_do() + { + var target = TargetBody(); + + Assert.Contains("= 0, "The _RaskVerifyPublishedScopedAssets target is gone from Rask.Wasm.targets."); + + var end = _targets.IndexOf("", start, StringComparison.Ordinal); + Assert.True(end > start, "The _RaskVerifyPublishedScopedAssets target is not closed."); + return _targets[start..end]; + } + + private static string LocateRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Rask.slnx"))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + + throw new InvalidOperationException( + $"Could not locate Rask.slnx walking up from {AppContext.BaseDirectory}"); + } +} From 3133ef5344a3e6d7ec5b388f8574687188862fc1 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 11:01:27 +0200 Subject: [PATCH 2/6] fix(e2e): clear the playground's bin as well as its obj before the native publish Reported and measured on another machine: clearing only the intermediates still produced a publish with zero scoped assets, and adding bin/Release/net10.0-browser to the clean fixed it. The reported mechanism is that a stale no-native bin lets the publish treat the compile as up to date, so the bake never re-runs and the just-deleted staging is never rebuilt. That mechanism is NOT confirmed, and the comment says so: the same sequence reproduces cleanly here in three variations, including the full solution build, each time re-running the bake and shipping 6 files. The deciding variable is still unknown. Taking the superset clean anyway: the cost is one recompile of a project whose native relink dominates this step regardless, and the failure it guards against is silent and has now cost several sessions. The publish-time verification added in this branch turns the same failure into a build error either way, so this is belt as well as braces. Also worth recording: dotnet publish does not clean its output directory, so an earlier good publish can leave _rask in place and make a broken publish look fine. That confound probably invalidated the twice-back-to-back verification in #652. --- scripts/run-e2e-local.sh | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh index d2aace96..f93311cb 100755 --- a/scripts/run-e2e-local.sh +++ b/scripts/run-e2e-local.sh @@ -62,14 +62,24 @@ 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 as well as obj/, because clearing only the intermediates was measured to be +# insufficient: on one machine, an A/B inside this gate gave 0 files under publish/wwwroot/_rask with obj +# alone and 6 with obj + bin, the one-line change being the only difference. On another machine the same +# sequence — solution build, clear obj, publish — reproduces cleanly and ships its files every time, so +# whatever decides it is not in this script. # -# 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. +# The mechanism is therefore NOT known. The first explanation (a stale no-native bin/ letting the publish +# treat the compile as up to date, so the bake never re-runs) is contradicted by those clean runs, where +# the bake re-ran and published normally; it is recorded here only so nobody re-derives and re-believes +# it. The clean is a deliberate superset justified by cost — one recompile of a project whose native +# relink dominates this step regardless — not by a claim either measurement supports. +# +# Worth knowing when verifying any fix here: running the gate twice back to back does NOT distinguish +# these, since both runs clear the same things and leave the same things stale. Nor does inspecting +# publish/wwwroot after a passing run — `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. +# _RaskVerifyPublishedScopedAssets (Rask.Wasm.targets) turns the whole class into a build error if it +# recurs, which is the durable half of this. See #650. 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 / From eab8e74edd1e3a7d98b898ccc7f7806e5ebe0116 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 11:41:52 +0200 Subject: [PATCH 3/6] fix(wasm): also fail when the scoped-asset bake never ran at all The staged-vs-published comparison has a hole, and it is the one that matters: when the bake does not run, the staging dir is absent, so 'this project has no scoped assets' and 'the bake was skipped' are the same observation and the guard stays silent through exactly the failure it was written for. Found by reproducing #650 live -- the E2E gate failed on this branch after a rebase, and the broken publish had neither wwwroot/_rask NOR any staging. Since the bake target unconditionally RemoveDir+MakeDirs its staging, a missing staging dir is proof it never ran, which also confirms the mechanism the other session inferred and I could not reproduce. The bake now records that it ran, and a publish that never baked and shipped no scoped assets fails with a message pointing at the two conditions that skip the target. Both halves of that condition are load-bearing: an incremental publish can legitimately skip the build pass while correct assets already sit in wwwroot. Verified: fires on the live broken publish; silent for a normal playground publish (6 files); silent for Rask.Example.Wasm.Jobs, which legitimately bakes zero. --- CHANGELOG.md | 11 ++++++++-- src/Rask.Wasm/build/Rask.Wasm.targets | 21 +++++++++++++++++++ .../PublishedScopedAssetGuardTests.cs | 18 ++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aeca37..c905093c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,15 @@ them until tagged releases begin. `/_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. A project with no scoped - assets stages nothing and is silently unaffected. Closes #650. + what was staged against what shipped and errors with the cause and the fix. + + It also catches the harder half, which is what actually happened: **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 a staged-vs-published comparison sees 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 silently unaffected — verified against + `samples/Rask.Example.Wasm.Jobs`, which has no scoped assets, and an incremental publish that skips the + build pass while its assets already sit in `wwwroot` stays quiet too. Closes #650. `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 diff --git a/src/Rask.Wasm/build/Rask.Wasm.targets b/src/Rask.Wasm/build/Rask.Wasm.targets index b983c7a7..f6ddfe71 100644 --- a/src/Rask.Wasm/build/Rask.Wasm.targets +++ b/src/Rask.Wasm/build/Rask.Wasm.targets @@ -127,6 +127,12 @@ <_RaskBakeInputAssemblies Include="@(ReferenceCopyLocalPaths)" Condition=" '%(Extension)' == '.dll' "/> + + + <_RaskScopedBakeRan>true + <_RaskScopedBaked Include="$(_RaskScopedStageDir)**\*"/> @@ -221,8 +227,23 @@ <_RaskStagedScopedAsset Include="$(_RaskVerifyStageDir)_rask\a\**\*"/> <_RaskPublishedScopedAsset Include="$(PublishDir)wwwroot\_rask\a\**\*"/> + + + + diff --git a/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs b/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs index 4c203748..b07a7d25 100644 --- a/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs +++ b/tests/Rask.Wasm.Tasks.Tests/PublishedScopedAssetGuardTests.cs @@ -65,6 +65,24 @@ public void The_error_says_what_broke_and_what_to_do() Assert.Contains("WasmBuildNative", target, StringComparison.Ordinal); } + // The second guard, and the one that catches what actually happened in #650: the bake never ran, so + // the staging dir is absent and the staged-vs-published comparison sees nothing to compare. Without + // _RaskScopedBakeRan, "this project has no scoped assets" and "the bake was skipped" are the same + // observation — and the first guard stays silent through the second. + [Fact] + public void A_bake_that_never_ran_is_distinguished_from_a_project_with_nothing_to_bake() + { + Assert.Contains("<_RaskScopedBakeRan>true", _targets, StringComparison.Ordinal); + + var target = TargetBody(); + Assert.Contains("'$(_RaskScopedBakeRan)' != 'true' AND '@(_RaskPublishedScopedAsset)' == ''", + target, StringComparison.Ordinal); + + // The published half is not optional: an incremental publish can skip the build pass (and so the + // bake) while the assets already sit correctly in wwwroot. Requiring both keeps that quiet. + Assert.Contains("_RaskBakeScopedStaticWebAssets", target, StringComparison.Ordinal); + } + private static string TargetBody() { const string open = " Date: Sat, 8 Aug 2026 11:58:27 +0200 Subject: [PATCH 4/6] docs(changelog): narrow the scoped-asset guard claim to what is proven The guards cover two of the three shapes this failure takes. The third -- a bake that RUNS and produces zero for an app that does have scoped assets -- is not covered and cannot be, from outside the bake: an empty staging dir is indistinguishable from a project that legitimately has none. Reproduced live on this branch in both covered and uncovered shapes, which is why the claim is narrowed rather than left as 'closes'. FailOnEmpty cannot be wired to cover it: registryResolved is true whenever Rask.Core merely loads, so enabling it would falsely fail every WASM app with no scoped assets, Rask.Example.Wasm.Jobs included. #650 stays open. --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c905093c..6d411a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,14 @@ them until tagged releases begin. 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 silently unaffected — verified against `samples/Rask.Example.Wasm.Jobs`, which has no scoped assets, and an incremental publish that skips the - build pass while its assets already sit in `wwwroot` stays quiet too. Closes #650. + build pass while its assets already sit in `wwwroot` stays quiet too. + + This covers two of the three shapes this failure takes. The one still uncovered is a bake that **runs + and produces zero** for an app that does have scoped assets: an empty staging directory is + indistinguishable from a project that legitimately has none, so nothing can tell them apart from the + outside. `BakeScopedAssetsTask.FailOnEmpty` cannot be enabled to cover it either — its `registryResolved` + is true whenever `Rask.Core` merely loads, so it would falsely fail every WASM app with no scoped assets. + Tracked in #650, which stays open. `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 From c4e00a15a3e38a62385945a6aceeae91fe3e0428 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 12:05:23 +0200 Subject: [PATCH 5/6] docs: correct the scoped-asset story to what the build log shows Two corrections to claims I made in this branch, both disproven by evidence since: The bake is not being skipped. I read a missing rask-scoped/_rask/a as 'the bake never ran', but that path only exists when files are written -- the staging dir itself was present. Both my reproductions were the same shape another session found in its build log: after the no-native solution build, the native publish's bake RUNS and writes ZERO files for an app that plainly has scoped assets. The bin clean is not the fix. It was added on an A/B that looked decisive and has since been contradicted by reproductions on branches that already had it. It stays because it is cheap, and the comment now says plainly that it does not fix anything. So neither guard in this branch covers #650, which stays open: nothing outside the bake can distinguish 'baked zero because something went wrong' from 'baked zero because there is nothing to bake'. The guards still cover two real shapes and turn those into named build errors, which is worth having on its own terms. --- CHANGELOG.md | 29 +++++++++++++++-------------- scripts/run-e2e-local.sh | 29 +++++++++++++---------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d411a10..5e15c1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,20 +26,21 @@ them until tagged releases begin. 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 the harder half, which is what actually happened: **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 a staged-vs-published comparison sees 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 silently unaffected — verified against - `samples/Rask.Example.Wasm.Jobs`, which has no scoped assets, and an incremental publish that skips the - build pass while its assets already sit in `wwwroot` stays quiet too. - - This covers two of the three shapes this failure takes. The one still uncovered is a bake that **runs - and produces zero** for an app that does have scoped assets: an empty staging directory is - indistinguishable from a project that legitimately has none, so nothing can tell them apart from the - outside. `BakeScopedAssetsTask.FailOnEmpty` cannot be enabled to cover it either — its `registryResolved` - is true whenever `Rask.Core` merely loads, so it would falsely fail every WASM app with no scoped assets. - Tracked in #650, which stays open. + 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 diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh index f93311cb..4b9b7e13 100755 --- a/scripts/run-e2e-local.sh +++ b/scripts/run-e2e-local.sh @@ -62,24 +62,21 @@ 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/ is cleared as well as obj/, because clearing only the intermediates was measured to be -# insufficient: on one machine, an A/B inside this gate gave 0 files under publish/wwwroot/_rask with obj -# alone and 6 with obj + bin, the one-line change being the only difference. On another machine the same -# sequence — solution build, clear obj, publish — reproduces cleanly and ships its files every time, so -# whatever decides it is not in this script. +# 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. # -# The mechanism is therefore NOT known. The first explanation (a stale no-native bin/ letting the publish -# treat the compile as up to date, so the bake never re-runs) is contradicted by those clean runs, where -# the bake re-ran and published normally; it is recorded here only so nobody re-derives and re-believes -# it. The clean is a deliberate superset justified by cost — one recompile of a project whose native -# relink dominates this step regardless — not by a claim either measurement supports. +# 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. # -# Worth knowing when verifying any fix here: running the gate twice back to back does NOT distinguish -# these, since both runs clear the same things and leave the same things stale. Nor does inspecting -# publish/wwwroot after a passing run — `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. -# _RaskVerifyPublishedScopedAssets (Rask.Wasm.targets) turns the whole class into a build error if it -# recurs, which is the durable half of this. See #650. +# 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 / From a737e35ce5d137c7df7192b84d6887bb587a0972 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 14:32:56 +0200 Subject: [PATCH 6/6] fix(wasm): stop a poisoned MSBuild node baking an empty bundle in silence Root cause, from another session's binlog: BakeScopedAssetsTask inspects the built assemblies with Assembly.LoadFrom, and MSBuild reuses worker nodes. A publish landing on a node that already loaded an assembly of the same simple name throws FileLoadException; the task caught it, logged at MessageImportance.Low and continued, so it baked nothing and reported success. The published app then 404s on every scoped CSS/JS URL, which reads as a broken app rather than a broken build. It is node-STATE dependent, not a race: once a reused node has loaded that assembly every publish on it fails, and with a cold pool every publish passes. That is why it looked intermittent and why every clean either of us tried appeared to work exactly once -- we were sampling whether the pool happened to be warm. Measured here: 3 failures in 4 consecutive publishes on a warm pool, 0 in 4 with -nodeReuse:false; another session measured 6/6 versus 0/6. Two changes, neither of them the real fix: - The gate publishes the playground with -nodeReuse:false. Measured, and it only protects that one call site. - The task now fails when it baked zero AND skipped an assembly because one of that name was already loaded. That combination is never a legitimate empty bake. Conditioned on both so a bake that still produced its files cannot be broken by it. Honesty about the second one: it is written from the binlog, and I have NOT seen it fire. I could not reproduce the poisoned-node state on demand -- and node reuse pins the task assembly too, so a reused node keeps running the OLD task and hides any change to it, which is its own small trap for anyone testing this. The real fix is a fresh AssemblyLoadContext per invocation, so a reused node cannot collide at all. That is not in here: Rask.Wasm.Tasks targets netstandard2.0 where AssemblyLoadContext is unavailable at compile time, so it needs multi-targeting and a runtime-conditional UsingTask, with packaging consequences that deserve their own PR. #650 stays open for it. --- scripts/run-e2e-local.sh | 11 +++++- src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs | 40 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh index 4b9b7e13..6793a633 100755 --- a/scripts/run-e2e-local.sh +++ b/scripts/run-e2e-local.sh @@ -84,7 +84,16 @@ rm -rf samples/Rask.Example.Playground/obj/Release/net10.0-browser \ # 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 diff --git a/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs b/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs index be44407f..182bb9de 100644 --- a/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs +++ b/src/Rask.Wasm.Tasks/BakeScopedAssetsTask.cs @@ -70,6 +70,13 @@ public sealed class BakeScopedAssetsTask : Task /// public bool FailOnEmpty { get; set; } + /// + /// 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 + /// . + /// + private readonly List _skippedAlreadyLoaded = new(); + public override bool Execute() { if (string.IsNullOrEmpty(BundleDir)) @@ -98,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 " + @@ -181,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; }