Skip to content

Commit 18992d8

Browse files
committed
Add --r2r publish mode with unified stamp caching
Introduce a --r2r switch on the default run command that publishes with PublishAot=false and PublishReadyToRun=true for apps needing dynamic .NET features. Track publish mode (aot|r2r) in the shared stamp file and invalidate cached output when the requested mode does not match.
1 parent e6eb380 commit 18992d8

8 files changed

Lines changed: 156 additions & 13 deletions

File tree

readme.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,23 @@ dnx go app.cs -- arg1 arg2
3131
dnx go app.cs /p:MyProp=true -- arg1 arg2
3232
```
3333

34-
The default mode publishes the app and then runs the resulting executable,
34+
The default mode publishes the app with native AOT and then runs the resulting executable,
3535
with smart up-to-date checks of every C# file used to build the app (including
3636
`#include` and `#ref` directives, transitively).
3737

38+
Use `--r2r` when your app needs more dynamic .NET features (reflection, dynamic loading, etc.)
39+
that native AOT does not support, while still keeping most publish optimizations:
40+
41+
```console
42+
dnx go app.cs --r2r
43+
44+
# Pass arguments to your app
45+
dnx go app.cs --r2r -- arg1 arg2
46+
```
47+
48+
This publishes with `/p:PublishAot=false` and `/p:PublishReadyToRun=true`.
49+
An equivalent `--aot` switch is not needed since native AOT is the default for file-based apps.
50+
3851
A dev mode is also available for faster iteration, which skips the publish step
3952
and runs the app directly from the build output without the optimizations
4053
applied by dotnet to published executables (i.e. AOT, RID-specific optimizations):

src/Tests/GoArgsTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,20 @@ public void Split_with_leading_separator_leaves_dotnet_args_empty()
3939
Assert.Empty(dotnet);
4040
Assert.Equal(["arg1"], app);
4141
}
42+
43+
[Fact]
44+
public void ApplyPublishMode_adds_r2r_properties_when_enabled()
45+
{
46+
var args = GoArgs.ApplyPublishMode(["-c", "Release"], readyToRun: true);
47+
48+
Assert.Equal(["/p:PublishAot=false", "/p:PublishReadyToRun=true", "-c", "Release"], args);
49+
}
50+
51+
[Fact]
52+
public void ApplyPublishMode_leaves_args_unchanged_when_disabled()
53+
{
54+
var args = GoArgs.ApplyPublishMode(["-c", "Release"], readyToRun: false);
55+
56+
Assert.Equal(["-c", "Release"], args);
57+
}
4258
}

src/Tests/GoBuildCacheTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ public void IsUpToDate_works_with_bin_artifact()
5656
Assert.True(BuildManager.IsUpToDate(state, bin));
5757
}
5858

59+
[Fact]
60+
public void IsUpToDate_returns_false_when_mode_mismatches()
61+
{
62+
var dir = CreateTempDir();
63+
var input = WriteFile(dir, "input.cs", "input");
64+
var app = WriteFile(dir, "app.exe", "app");
65+
File.SetLastWriteTimeUtc(input, DateTime.UtcNow.AddMinutes(-5));
66+
File.SetLastWriteTimeUtc(app, DateTime.UtcNow);
67+
68+
var state = new BuildState(app, null, [input], PublishMode.Aot);
69+
70+
Assert.False(BuildManager.IsUpToDate(state, app, PublishMode.R2r));
71+
}
72+
73+
[Fact]
74+
public void IsUpToDate_returns_true_when_mode_matches()
75+
{
76+
var dir = CreateTempDir();
77+
var input = WriteFile(dir, "input.cs", "input");
78+
var app = WriteFile(dir, "app.exe", "app");
79+
File.SetLastWriteTimeUtc(input, DateTime.UtcNow.AddMinutes(-5));
80+
File.SetLastWriteTimeUtc(app, DateTime.UtcNow);
81+
82+
var state = new BuildState(app, null, [input], PublishMode.R2r);
83+
84+
Assert.True(BuildManager.IsUpToDate(state, app, PublishMode.R2r));
85+
}
86+
5987
static string CreateTempDir()
6088
{
6189
var dir = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));

src/Tests/GoConfigReaderTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,48 @@ public void TryRead_returns_false_when_no_inputs()
130130
Assert.Null(state);
131131
}
132132

133+
[Fact]
134+
public void TryRead_defaults_mode_to_aot_when_missing()
135+
{
136+
var dir = CreateTempDir();
137+
var input = WriteFile(dir, "app.cs", "app");
138+
var stampPath = Path.Combine(dir, "app.stamp");
139+
140+
File.WriteAllText(stampPath, $"""
141+
input = {ToStampPath(input)}
142+
""");
143+
144+
var success = BuildState.TryRead(stampPath, out var state);
145+
146+
Assert.True(success);
147+
Assert.Equal(PublishMode.Aot, state!.Mode);
148+
}
149+
150+
[Fact]
151+
public void InitialContent_writes_mode_up_front()
152+
{
153+
Assert.Equal("mode=aot" + Environment.NewLine, BuildState.InitialContent(PublishMode.Aot));
154+
Assert.Equal("mode=r2r" + Environment.NewLine, BuildState.InitialContent(PublishMode.R2r));
155+
}
156+
157+
[Fact]
158+
public void TryRead_parses_mode_r2r()
159+
{
160+
var dir = CreateTempDir();
161+
var input = WriteFile(dir, "app.cs", "app");
162+
var stampPath = Path.Combine(dir, "app.stamp");
163+
164+
File.WriteAllText(stampPath, $"""
165+
mode = r2r
166+
input = {ToStampPath(input)}
167+
""");
168+
169+
var success = BuildState.TryRead(stampPath, out var state);
170+
171+
Assert.True(success);
172+
Assert.Equal(PublishMode.R2r, state!.Mode);
173+
}
174+
133175
static string CreateTempDir()
134176
{
135177
var dir = Path.Combine(Path.GetTempPath(), "go-tests-" + Guid.NewGuid().ToString("N"));

src/go/BuildManager.cs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22

33
namespace Devlooped;
44

5-
public record BuildState(string? App, string? Bin, IReadOnlyList<string> Inputs)
5+
public enum PublishMode
66
{
7+
Aot,
8+
R2r,
9+
}
10+
11+
public record BuildState(string? App, string? Bin, IReadOnlyList<string> Inputs, PublishMode Mode = PublishMode.Aot)
12+
{
13+
public static string InitialContent(PublishMode mode)
14+
=> $"mode={mode.ToString().ToLowerInvariant()}{Environment.NewLine}";
15+
716
public static bool TryRead(string path, [NotNullWhen(true)] out BuildState? state)
817
{
918
state = null;
@@ -13,6 +22,7 @@ public static bool TryRead(string path, [NotNullWhen(true)] out BuildState? stat
1322
string? app = null;
1423
string? bin = null;
1524
var inputs = new List<string>();
25+
var mode = PublishMode.Aot;
1626

1727
foreach (var line in File.ReadLines(path))
1828
{
@@ -33,20 +43,43 @@ public static bool TryRead(string path, [NotNullWhen(true)] out BuildState? stat
3343
bin = value;
3444
else if (key == "input")
3545
inputs.Add(value);
46+
else if (key == "mode" && TryParseMode(value, out var parsedMode))
47+
mode = parsedMode;
3648
}
3749

3850
if (inputs.Count == 0)
3951
return false;
4052

41-
state = new BuildState(app, bin, inputs);
53+
state = new BuildState(app, bin, inputs, mode);
4254
return true;
4355
}
56+
57+
static bool TryParseMode(string value, out PublishMode mode)
58+
{
59+
if (value.Equals("r2r", StringComparison.OrdinalIgnoreCase))
60+
{
61+
mode = PublishMode.R2r;
62+
return true;
63+
}
64+
65+
if (value.Equals("aot", StringComparison.OrdinalIgnoreCase))
66+
{
67+
mode = PublishMode.Aot;
68+
return true;
69+
}
70+
71+
mode = default;
72+
return false;
73+
}
4474
}
4575

4676
public static class BuildManager
4777
{
48-
public static bool IsUpToDate(BuildState state, string artifact)
78+
public static bool IsUpToDate(BuildState state, string artifact, PublishMode mode = PublishMode.Aot)
4979
{
80+
if (state.Mode != mode)
81+
return false;
82+
5083
if (string.IsNullOrWhiteSpace(artifact) || !File.Exists(artifact))
5184
return false;
5285

src/go/GoArgs.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ namespace Devlooped;
22

33
public static class GoArgs
44
{
5+
public static readonly string[] ReadyToRunPublishArgs = ["/p:PublishAot=false", "/p:PublishReadyToRun=true"];
6+
57
public static (string[] Dotnet, string[] App) Split(string[] extraArgs)
68
{
79
var separator = Array.IndexOf(extraArgs, "--");
@@ -13,4 +15,7 @@ [.. extraArgs.AsSpan(0, separator)],
1315
[.. extraArgs.AsSpan(separator + 1)]
1416
);
1517
}
18+
19+
public static string[] ApplyPublishMode(string[] dotnetArgs, bool readyToRun)
20+
=> readyToRun ? [.. ReadyToRunPublishArgs, .. dotnetArgs] : dotnetArgs;
1621
}

src/go/Program.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,23 @@
1111
/// Runs a file-based .NET app from a .cs entrypoint.
1212
/// </summary>
1313
/// <param name="input">Path to an existing .cs file.</param>
14+
/// <param name="r2r">Publish with ReadyToRun instead of native AOT; supports more dynamic .NET features while keeping most publish optimizations.</param>
1415
/// <param name="extraArgs">Arguments before '--' are passed to 'dotnet publish'; arguments after '--' are forwarded to the published app. Without '--', all extra arguments are forwarded to the published app.
1516
/// </param>
16-
static async Task<int> RunAsync([Argument] string input, [Argument] params string[] extraArgs)
17+
static async Task<int> RunAsync([Argument] string input, bool r2r = false, [Argument] params string[] extraArgs)
1718
{
18-
var context = Prepare(input, extraArgs);
19+
var context = Prepare(input, extraArgs, r2r);
1920
if (context is null)
2021
return 1;
2122

22-
var (dotnet, cs, stamp, targets, dotnetArgs, appArgs) = context.Value;
23+
var (dotnet, cs, stamp, targets, mode, dotnetArgs, appArgs) = context.Value;
2324

2425
if (BuildState.TryRead(stamp, out var state) &&
2526
state.App is not null &&
26-
BuildManager.IsUpToDate(state, state.App))
27+
BuildManager.IsUpToDate(state, state.App, mode))
2728
return await ProcessRunner.RunAsync(state.App, appArgs);
2829

29-
File.WriteAllText(stamp, string.Empty, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
30+
File.WriteAllText(stamp, BuildState.InitialContent(mode), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
3031

3132
var exit = await ProcessRunner.PublishAsync(dotnet, cs, stamp, targets, dotnetArgs);
3233
if (exit != 0)
@@ -53,7 +54,7 @@ static async Task<int> DevAsync([Argument] string input, [Argument] params strin
5354
if (context is null)
5455
return 1;
5556

56-
var (dotnet, cs, stamp, targets, dotnetArgs, appArgs) = context.Value;
57+
var (dotnet, cs, stamp, targets, _, dotnetArgs, appArgs) = context.Value;
5758

5859
if (BuildState.TryRead(stamp, out var state) &&
5960
state.Bin is not null &&
@@ -65,7 +66,7 @@ state.Bin is not null &&
6566
return await ProcessRunner.DotnetRunAsync(dotnet, cs, stamp, targets, dotnetArgs, appArgs);
6667
}
6768

68-
static (string Dotnet, string Cs, string Stamp, string Targets, string[] DotnetArgs, string[] AppArgs)? Prepare(string input, string[] extraArgs)
69+
static (string Dotnet, string Cs, string Stamp, string Targets, PublishMode Mode, string[] DotnetArgs, string[] AppArgs)? Prepare(string input, string[] extraArgs, bool readyToRun = false)
6970
{
7071
var dotnet = DotnetMuxer.Path?.FullName;
7172
if (dotnet is null)
@@ -82,8 +83,10 @@ state.Bin is not null &&
8283
}
8384

8485
var (dotnetArgs, appArgs) = GoArgs.Split(extraArgs);
86+
var mode = readyToRun ? PublishMode.R2r : PublishMode.Aot;
87+
dotnetArgs = GoArgs.ApplyPublishMode(dotnetArgs, readyToRun);
8588
var stamp = Path.ChangeExtension(cs, "stamp");
8689
var targets = Path.Combine(AppContext.BaseDirectory, "go.targets");
8790

88-
return (dotnet, cs, stamp, targets, dotnetArgs, appArgs);
91+
return (dotnet, cs, stamp, targets, mode, dotnetArgs, appArgs);
8992
}

src/go/help.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
```shell
2-
Usage: [command] [arguments...] [-h|--help] [--version]
2+
Usage: [command] [arguments...] [options...] [-h|--help] [--version]
33

44
Runs a file-based .NET app from a .cs entrypoint.
55

66
Arguments:
77
[0] <string> Path to an existing .cs file.
88
[1] <string[]> Arguments before '--' are passed to 'dotnet publish'; arguments after '--' are forwarded to the published app. Without '--', all extra arguments are forwarded to the published app.
99

10+
Options:
11+
--r2r Publish with ReadyToRun instead of native AOT; supports more dynamic .NET features while keeping most publish optimizations.
12+
1013
Commands:
1114
dev Runs a file-based .NET app from a .cs entrypoint using dotnet run for fast iteration.
1215
```

0 commit comments

Comments
 (0)