Skip to content

Commit ad02400

Browse files
committed
Crack the project when its MSBuild inputs change, and only once
The design time build cache key took its list of dependent files from MSBuildAllProjects. Since MSBuild 16.9 an import no longer adds itself to that property, so the list was the fsproj plus a handful of SDK targets and nothing else: editing a Directory.Build.props neither invalidated the cache nor re-cracked the project, because nothing knew the file existed. The convention imports are now asked for by name instead, and the packages props only when central package management is actually on, so watching it cannot re-crack for an edit that changes nothing. The keys themselves were built once per daemon lifetime. Which files an evaluation depends on is decided by the evaluation, so a key kept across cracks describes the project as it was, and a file added to the build was never noticed. They are now forgotten at the start of every crack, which still leaves them memoised for the length of one, where the same project can be visited more than once. A dev server calls watchChange on top of calling hotUpdate for every environment, so one touch of an fsproj arrived three times and cracked the project three times over, each one a full design time build. Deduplicating a change across environments already existed for source files; re-cracks now go through the same window, and watchChange defers to hotUpdate in dev, where it is the better of the two because it can reload the browser afterwards. Reading msbuild's stdout to the end before touching stderr can deadlock: a child that fills the other pipe blocks writing while this side blocks reading, and nothing between the daemon and the plugin times out. Both pipes are now drained together. A non-empty stderr also no longer fails the call, since msbuild and NuGet write warnings there on runs that succeed, and a real failure is reported on stdout with the exit code set, which is why the old message quoted stderr and named no reason at all.
1 parent 7bac35d commit ad02400

8 files changed

Lines changed: 281 additions & 25 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ The contract is hand-mirrored today and the mirroring is positional, which is wh
172172

173173
## 9. Cache invalidation questions
174174

175-
- The design-time build cache key (`Caching.fs`) hashes MSBuild inputs only. Adding/removing a `<Compile Include>` changes the fsproj hash so that is covered, but `Directory.Build.props` files outside `MSBuildAllProjects` (for instance ones pulled in via `Import` with a condition that is false at evaluation time) are not.
175+
- The design-time build cache key (`Caching.fs`) hashes MSBuild inputs only, and the list of inputs comes from `MSBuildAllProjects`. Since MSBuild 16.9 an import no longer adds itself to that property, so it now reports the fsproj and a handful of SDK targets and nothing else. `Directory.Build.props`, `Directory.Build.targets` and `Directory.Packages.props` are asked for by name (`DirectoryBuildPropsPath` and friends), which covers what people actually edit, but a file pulled in by an explicit `<Import>` is still invisible: changing it neither invalidates the cache nor re-cracks the project. Getting the real list means `dotnet msbuild -preprocess` or an equivalent, which is a much heavier query than the one property read the cache key does today.
176176
- `tryCompileProject` compiles with `NoCache = true` in `CliArgs` while the daemon maintains its own cache; confirm nothing in newer Fable.Compiler versions relies on that flag for correctness.
177177

178178
## 10. Rejected: writing the plugin in F# / Fable

packages/vite-plugin-fable/Fable.Daemon.Tests/DebugTests.fs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,120 @@ let ``changing a signature file reports a diagnostic on the implementation`` ()
130130
(daemon :> IDisposable).Dispose()
131131
}
132132

133+
/// MSBuild reports its own failures on stdout and leaves stderr empty, so the exit code is the
134+
/// only signal worth failing on, and a message built from stderr alone names no reason at all.
135+
[<Test>]
136+
let ``a failing dotnet msbuild call reports what MSBuild said`` () =
137+
task {
138+
let fsproj =
139+
FileInfo (Path.CombineNormalize (__SOURCE_DIRECTORY__, "../../../sample-project/App.fsproj"))
140+
141+
let run =
142+
MSBuild.dotnet_msbuild NullLogger.Instance fsproj "-t:NoSuchTarget"
143+
|> Async.StartAsTask
144+
145+
// Also that it comes back at all: draining one pipe to the end before touching the other
146+
// can deadlock, and nothing on the way back to the plugin times out.
147+
let! finished = Task.WhenAny (run :> Task, Task.Delay (TimeSpan.FromMinutes 2.))
148+
Assert.That (finished, Is.SameAs (run :> Task), "dotnet msbuild never came back")
149+
150+
let error = Assert.Throws<AggregateException>(fun () -> run.Wait ())
151+
152+
Assert.That (
153+
error.InnerException.Message,
154+
Does.Contain "MSB4057",
155+
"the failure did not say what MSBuild complained about"
156+
)
157+
}
158+
159+
/// Since MSBuild 16.9 an import no longer adds itself to `MSBuildAllProjects`, so asking for that
160+
/// property alone reports the fsproj and a handful of SDK targets and misses the file people
161+
/// actually edit. A `Directory.Build.props` that is not a dependent file is a design time build
162+
/// cache that survives an edit to it, and a file the plugin never watches.
163+
[<Test>]
164+
let ``the project's Directory.Build.props is reported as a file to watch`` () =
165+
task {
166+
let config = sampleApp
167+
let projectDir = FileInfo(config.Project).Directory.FullName
168+
Directory.SetCurrentDirectory projectDir
169+
170+
let directoryBuildProps =
171+
Path.CombineNormalize (projectDir, "Directory.Build.props")
172+
173+
Assert.That (
174+
File.Exists directoryBuildProps,
175+
Is.True,
176+
"the sample project no longer has a Directory.Build.props, so this test proves nothing"
177+
)
178+
179+
let struct (serverStream, clientStream) = FullDuplexStream.CreatePair ()
180+
181+
let daemon =
182+
new Program.FableServer (serverStream, serverStream, NullLogger.Instance)
183+
184+
let client = new JsonRpc (clientStream, clientStream)
185+
client.StartListening ()
186+
187+
try
188+
let! response = daemon.ProjectChanged config
189+
190+
match response with
191+
| ProjectChangedResult.Error error -> Assert.Fail $"expected the project to crack, got {error}"
192+
| ProjectChangedResult.Success (_, _, dependentFiles) ->
193+
194+
Assert.That (
195+
dependentFiles |> Array.map Path.GetFullPath,
196+
Does.Contain directoryBuildProps,
197+
$"""Directory.Build.props was not reported: %s{String.concat ", " dependentFiles}"""
198+
)
199+
finally
200+
client.Dispose ()
201+
(daemon :> IDisposable).Dispose()
202+
}
203+
204+
/// The resolver forgets its cache keys at the start of every crack, so the next one asks MSBuild
205+
/// again which files its evaluation depends on. The risk in forgetting them is that nothing fills
206+
/// them back in: `MSBuildProjectFiles` would then answer with an empty list, the plugin would watch
207+
/// no MSBuild inputs at all, and editing the fsproj would stop re-cracking the project.
208+
[<Test>]
209+
let ``cracking twice still reports the MSBuild files to watch`` () =
210+
task {
211+
let config = sampleApp
212+
Directory.SetCurrentDirectory (FileInfo(config.Project).DirectoryName)
213+
214+
let struct (serverStream, clientStream) = FullDuplexStream.CreatePair ()
215+
216+
let daemon =
217+
new Program.FableServer (serverStream, serverStream, NullLogger.Instance)
218+
219+
let client = new JsonRpc (clientStream, clientStream)
220+
client.StartListening ()
221+
222+
let dependentFiles (response : ProjectChangedResult) =
223+
match response with
224+
| ProjectChangedResult.Success (_, _, dependentFiles) -> dependentFiles
225+
| ProjectChangedResult.Error error -> failwith $"expected the project to crack, got {error}"
226+
227+
try
228+
let! first = daemon.ProjectChanged config
229+
let! second = daemon.ProjectChanged config
230+
231+
for response in [ first ; second ] do
232+
let files = dependentFiles response
233+
234+
Assert.That (files, Is.Not.Empty, "no MSBuild files were reported to watch")
235+
236+
Assert.That (
237+
files
238+
|> Array.exists (fun f -> f.EndsWith ("App.fsproj", StringComparison.Ordinal)),
239+
Is.True,
240+
$"""the project file itself was not reported: %s{String.concat ", " files}"""
241+
)
242+
finally
243+
client.Dispose ()
244+
(daemon :> IDisposable).Dispose()
245+
}
246+
133247
/// The daemon keeps Fable's `File` values between compiles so an unchanged file is not read and
134248
/// hashed again, and forgets the ones the plugin reports as changed. A change it failed to forget
135249
/// would be compiled from what the file used to say, and nothing else would notice: no error, just

packages/vite-plugin-fable/Fable.Daemon/Caching.fs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,13 @@ let private decodeCacheKey (options : CrackerOptions) (fsproj : FileInfo) (json
218218
let getProperty (name : string) =
219219
properties.GetProperty(name).GetString()
220220

221+
/// MSBuild answers with an empty string for a property it has no value for, so this is only
222+
/// about a property that was never asked for.
223+
let tryGetProperty (name : string) : string =
224+
match properties.TryGetProperty name with
225+
| true, value -> value.GetString () |> Option.ofObj |> Option.defaultValue ""
226+
| false, _ -> ""
227+
221228
let paths =
222229
(getProperty "MSBuildAllProjects").Split(';', StringSplitOptions.RemoveEmptyEntries)
223230
|> Array.choose (fun path ->
@@ -247,8 +254,41 @@ let private decodeCacheKey (options : CrackerOptions) (fsproj : FileInfo) (json
247254
let cacheFile =
248255
FileInfo (Path.Combine (intermediateOutputPath, $"{fsproj.Name}%s{DesignTimeBuildExtension}"))
249256

257+
/// The files MSBuild imports by convention rather than by an `<Import>` in the project.
258+
///
259+
/// They have to be asked for by name: since MSBuild 16.9 imports no longer add themselves
260+
/// to `MSBuildAllProjects`, so a `Directory.Build.props` does not appear there at all.
261+
/// Without these, editing one changed nothing the cache key knew about, the design time
262+
/// build was reused, and the plugin was never told to watch the file either.
263+
let conventionImports =
264+
[
265+
yield "DirectoryBuildPropsPath"
266+
yield "DirectoryBuildTargetsPath"
267+
// The SDK resolves this whether or not central package management is on. When it is
268+
// off the file is found and ignored, and watching it would re-crack the project for
269+
// an edit that cannot change anything.
270+
if
271+
String.Equals (
272+
tryGetProperty "ManagePackageVersionsCentrally",
273+
"true",
274+
StringComparison.OrdinalIgnoreCase
275+
)
276+
then
277+
yield "DirectoryPackagesPropsPath"
278+
]
279+
|> List.choose (fun property ->
280+
let path = tryGetProperty property
281+
282+
if String.IsNullOrWhiteSpace path then
283+
None
284+
else
285+
286+
let fi = FileInfo path
287+
if fi.Exists then Some fi else None
288+
)
289+
250290
let dependentFiles =
251-
[ yield fsproj ; yield! paths ; yield! nugetGProps ]
291+
[ yield fsproj ; yield! paths ; yield! conventionImports ; yield! nugetGProps ]
252292
|> List.distinctBy (fun fi -> fi.FullName)
253293

254294
Ok
@@ -285,7 +325,7 @@ let mkProjectCacheKey
285325
MSBuild.dotnet_msbuild
286326
logger
287327
fsproj
288-
$"/p:Configuration=%s{options.Configuration} --getProperty:MSBuildAllProjects --getProperty:IntermediateOutputPath --getProperty:MSBuildProjectExtensionsPath"
328+
$"/p:Configuration=%s{options.Configuration} --getProperty:MSBuildAllProjects --getProperty:IntermediateOutputPath --getProperty:MSBuildProjectExtensionsPath --getProperty:DirectoryBuildPropsPath --getProperty:DirectoryBuildTargetsPath --getProperty:DirectoryPackagesPropsPath --getProperty:ManagePackageVersionsCentrally"
289329

290330
return decodeCacheKey options fsproj json
291331
}

packages/vite-plugin-fable/Fable.Daemon/MSBuild.fs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,49 @@ let dotnet_msbuild (logger : ILogger) (fsproj : FileInfo) (args : string) : Asyn
2121

2222
use ps = new Process ()
2323
ps.StartInfo <- psi
24-
ps.Start () |> ignore
25-
let output = ps.StandardOutput.ReadToEnd ()
26-
let error = ps.StandardError.ReadToEnd ()
24+
25+
if not (ps.Start ()) then
26+
failwith $"Could not start `dotnet msbuild` for %s{fsproj.FullName}"
27+
28+
// Both pipes have to be drained at the same time. Reading one to the end first blocks here
29+
// until the child closes it, and a child that fills the other pipe's buffer meanwhile
30+
// blocks writing to it: neither side moves again. Nothing times out on the way back, so
31+
// the plugin would wait on a design time build that never finishes.
32+
let readOutput = ps.StandardOutput.ReadToEndAsync ()
33+
let readError = ps.StandardError.ReadToEndAsync ()
34+
let! output = readOutput
35+
let! error = readError
2736
do! ps.WaitForExitAsync ()
2837

29-
if ps.ExitCode <> 0 || not (String.IsNullOrWhiteSpace error) then
30-
logger.LogCritical ("dotnet msbuild \"{fsproj}\" {args}\n did has {error}", fsproj.FullName, args, error)
31-
failwithf $"In %s{pwd}:\ndotnet msbuild \"%s{fsproj.FullName}\" %s{args} failed with\n%s{error}"
38+
// The exit code decides, and only the exit code. MSBuild and NuGet write warnings to
39+
// stderr on runs that succeed, and failing on those reported a warning as a project that
40+
// could not be cracked.
41+
if ps.ExitCode <> 0 then
42+
// MSBuild reports its own failures on stdout (`error MSB4057: ...`) and typically
43+
// leaves stderr empty, so a message built from stderr alone names no reason at all.
44+
let detail =
45+
[ output ; error ]
46+
|> List.filter (String.IsNullOrWhiteSpace >> not)
47+
|> String.concat "\n"
48+
49+
logger.LogCritical (
50+
"dotnet msbuild \"{fsproj}\" {args} exited with {exitCode}:\n{detail}",
51+
fsproj.FullName,
52+
args,
53+
ps.ExitCode,
54+
detail
55+
)
56+
57+
failwithf
58+
$"In %s{pwd}:\ndotnet msbuild \"%s{fsproj.FullName}\" %s{args} failed with exit code %i{ps.ExitCode}\n%s{detail}"
59+
60+
if not (String.IsNullOrWhiteSpace error) then
61+
logger.LogWarning (
62+
"dotnet msbuild \"{fsproj}\" {args} wrote to stderr:\n{error}",
63+
fsproj.FullName,
64+
args,
65+
error
66+
)
3267

3368
return output.Trim ()
3469
}

packages/vite-plugin-fable/Fable.Daemon/Program.fs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ let tryTypeCheckProject
153153

154154
cliArgs, CrackerOptions (cliArgs, true)
155155

156+
// Which files the MSBuild evaluation depends on is decided by the evaluation, so the
157+
// keys from the previous crack describe the project as it was. Adding a
158+
// `Directory.Build.props` or an `<Import>` changes that list, and reusing the old key
159+
// would compare the new project against the old one's inputs and find them unchanged.
160+
model.Resolver.ForgetCacheKeys ()
161+
156162
let crackerResponse = getFullProjectOpts model.Resolver crackerOptions
157163

158164
logger.LogDebug ("CrackerResponse: {crackerResponse}", crackerResponse)

packages/vite-plugin-fable/Fable.Daemon/ProjectCracking.fs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ type CachedMSBuildCrackerResolver(logger : ILogger) =
3333
| None -> ()
3434
| Some cacheKey -> Caching.writeFableModulesFromCache cacheKey fableModuleFiles
3535

36+
/// Drop the remembered cache keys, so the next crack asks MSBuild again which files its
37+
/// evaluation depends on.
38+
///
39+
/// A key is worth remembering for the length of one crack, where the same project can be
40+
/// visited more than once, but not beyond it: `MSBuildAllProjects` is only re-read when a key
41+
/// is built, so a key kept across cracks describes the project as it was. Add a
42+
/// `Directory.Build.props` or an `<Import>` and the stale key compares the new project against
43+
/// the old one's inputs, finds nothing changed, and reuses a design time build that predates
44+
/// the file. The new file is also never reported to the plugin, so nothing watches it.
45+
member x.ForgetCacheKeys () : unit = cached.Clear ()
46+
3647
/// Get project files to watch inside the plugin
3748
/// These are the fsproj and potential MSBuild import files
3849
member x.MSBuildProjectFiles (fsproj : FullPath) : FileInfo list =

packages/vite-plugin-fable/src/index.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,16 @@ export function createFablePlugin(
375375
}
376376

377377
/**
378-
* The compile for one file change, shared by every environment Vite reports it to.
378+
* The work for one file change, shared by every environment Vite reports it to.
379379
*
380380
* `handleHMRUpdate` takes a single timestamp per change (`server/hmr.ts:472`) and then hands it
381381
* to `hotUpdate` once per environment (`:667-676`). Those calls arrive one after another, not
382382
* together, so the coalescing window has already flushed by the time the second one lands: a dev
383383
* server with the default `client` and `ssr` environments compiled every edit twice. Keying on
384384
* the timestamp Vite already computed makes one filesystem change mean one compile, while each
385-
* environment still resolves the resulting files against its own module graph.
385+
* environment still resolves the resulting files against its own module graph. The same
386+
* applies to a re-crack: an MSBuild input reported to two environments cracked the project
387+
* twice.
386388
*
387389
* A map rather than a single entry, because fan-outs overlap. The watcher calls
388390
* `onFileChange(file).catch(...)` without awaiting it (`server/index.ts:960-962`), so saving two
@@ -391,22 +393,27 @@ export function createFablePlugin(
391393
* environment only asks after the previous one's `hotUpdate` returned, which is already after
392394
* the compile that one awaited.
393395
*/
394-
const sharedSourceChanges: Map<string, Promise<BatchResult>> = new Map();
396+
const sharedChanges: Map<string, Promise<BatchResult>> = new Map();
395397

396398
/** Room for far more overlap than one fan-out can produce; this is a window, not a cache. */
397-
const SHARED_SOURCE_CHANGE_LIMIT: number = 32;
398-
399-
function queueSourceChangeOnce(file: string, timestamp: number): Promise<BatchResult> {
399+
const SHARED_CHANGE_LIMIT: number = 32;
400+
401+
/** Queues the work for a change once, however many environments Vite reports it to. */
402+
function queueOnce(
403+
file: string,
404+
timestamp: number,
405+
queue: (file: string) => Promise<BatchResult>,
406+
): Promise<BatchResult> {
400407
const key = `${file}\u0000${timestamp}`;
401-
const pending: Promise<BatchResult> | undefined = sharedSourceChanges.get(key);
408+
const pending: Promise<BatchResult> | undefined = sharedChanges.get(key);
402409
if (pending) return pending;
403-
const result: Promise<BatchResult> = queueSourceChange(file);
404-
sharedSourceChanges.set(key, result);
410+
const result: Promise<BatchResult> = queue(file);
411+
sharedChanges.set(key, result);
405412
// One insert can only put it one over, and a map iterates in insertion order, so this drops
406413
// the oldest change still on record.
407-
if (sharedSourceChanges.size > SHARED_SOURCE_CHANGE_LIMIT) {
408-
for (const oldest of sharedSourceChanges.keys()) {
409-
sharedSourceChanges.delete(oldest);
414+
if (sharedChanges.size > SHARED_CHANGE_LIMIT) {
415+
for (const oldest of sharedChanges.keys()) {
416+
sharedChanges.delete(oldest);
410417
break;
411418
}
412419
}
@@ -663,8 +670,13 @@ export function createFablePlugin(
663670
};
664671
},
665672
},
666-
// `hotUpdate` covers dev; this is what reaches the plugin under `vite build --watch`.
673+
// What reaches the plugin under `vite build --watch`. A dev server calls this as well
674+
// (`server/index.ts:912`, once for the client environment per `pluginContainer.ts:1252-1254`)
675+
// on top of calling `hotUpdate` for every environment, and each call used to crack the project
676+
// again: one touch of an fsproj meant three full re-cracks. `hotUpdate` is the better hook of
677+
// the two in dev, because it can reload the browser afterwards, so this defers to it there.
667678
watchChange: async function (id: string): Promise<void> {
679+
if (!state.isBuild) return;
668680
if (state.sourceFiles.size !== 0 && state.dependentFiles.has(normalizePath(id))) {
669681
await queueProjectChange(normalizePath(id));
670682
}
@@ -685,7 +697,7 @@ export function createFablePlugin(
685697
// "the whole project was rebuilt".
686698
if (state.dependentFiles.has(normalized)) {
687699
logDebug("hotUpdate", `project file ${short(normalized)} changed`);
688-
await queueProjectChange(normalized);
700+
await queueOnce(normalized, timestamp, queueProjectChange);
689701
environment.hot.send({ type: "full-reload" });
690702
return [];
691703
}
@@ -697,15 +709,15 @@ export function createFablePlugin(
697709
if (type !== "update") {
698710
if (!sourceFile && !fsharpFileRegex.test(normalized)) return;
699711
logDebug("hotUpdate", `${short(normalized)} was ${type}d, re-cracking`);
700-
await queueProjectChange(normalized);
712+
await queueOnce(normalized, timestamp, queueProjectChange);
701713
environment.hot.send({ type: "full-reload" });
702714
return [];
703715
}
704716

705717
if (!sourceFile) return;
706718

707719
logDebug("hotUpdate", `enter for ${short(sourceFile)}`);
708-
const result: BatchResult = await queueSourceChangeOnce(sourceFile, timestamp);
720+
const result: BatchResult = await queueOnce(sourceFile, timestamp, queueSourceChange);
709721
logDebug("hotUpdate", `leave for ${short(sourceFile)}`);
710722

711723
const errorDiagnostic: Diagnostic | undefined = result.diagnostics.find(

0 commit comments

Comments
 (0)