Skip to content

Commit 3e8c5c4

Browse files
authored
Add support for views + SingleFileExe (#24925)
* Add support for views + SingleFileExe
2 parents 0b1042c + 6a9241b commit 3e8c5c4

File tree

8 files changed

+180
-39
lines changed

8 files changed

+180
-39
lines changed

src/Mvc/Mvc.Core/src/ApplicationParts/RelatedAssemblyAttribute.cs

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
55
using System.Collections.Generic;
66
using System.IO;
77
using System.Linq;
88
using System.Reflection;
9+
using System.Runtime.Loader;
910
using Microsoft.AspNetCore.Mvc.Core;
1011

1112
namespace Microsoft.AspNetCore.Mvc.ApplicationParts
@@ -16,8 +17,6 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationParts
1617
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
1718
public sealed class RelatedAssemblyAttribute : Attribute
1819
{
19-
private static readonly Func<string, Assembly> AssemblyLoadFileDelegate = Assembly.LoadFile;
20-
2120
/// <summary>
2221
/// Initializes a new instance of <see cref="RelatedAssemblyAttribute"/>.
2322
/// </summary>
@@ -50,14 +49,15 @@ public static IReadOnlyList<Assembly> GetRelatedAssemblies(Assembly assembly, bo
5049
throw new ArgumentNullException(nameof(assembly));
5150
}
5251

53-
return GetRelatedAssemblies(assembly, throwOnError, File.Exists, AssemblyLoadFileDelegate);
52+
var loadContext = AssemblyLoadContext.GetLoadContext(assembly) ?? AssemblyLoadContext.Default;
53+
return GetRelatedAssemblies(assembly, throwOnError, File.Exists, new AssemblyLoadContextWrapper(loadContext));
5454
}
5555

5656
internal static IReadOnlyList<Assembly> GetRelatedAssemblies(
5757
Assembly assembly,
5858
bool throwOnError,
5959
Func<string, bool> fileExists,
60-
Func<string, Assembly> loadFile)
60+
AssemblyLoadContextWrapper assemblyLoadContext)
6161
{
6262
if (assembly == null)
6363
{
@@ -66,7 +66,7 @@ internal static IReadOnlyList<Assembly> GetRelatedAssemblies(
6666

6767
// MVC will specifically look for related parts in the same physical directory as the assembly.
6868
// No-op if the assembly does not have a location.
69-
if (assembly.IsDynamic || string.IsNullOrEmpty(assembly.Location))
69+
if (assembly.IsDynamic)
7070
{
7171
return Array.Empty<Assembly>();
7272
}
@@ -78,8 +78,10 @@ internal static IReadOnlyList<Assembly> GetRelatedAssemblies(
7878
}
7979

8080
var assemblyName = assembly.GetName().Name;
81-
var assemblyLocation = assembly.Location;
82-
var assemblyDirectory = Path.GetDirectoryName(assemblyLocation);
81+
// Assembly.Location may be null for a single-file exe. In this case, attempt to look for related parts in the app's base directory
82+
var assemblyDirectory = string.IsNullOrEmpty(assembly.Location) ?
83+
AppContext.BaseDirectory :
84+
Path.GetDirectoryName(assembly.Location);
8385

8486
var relatedAssemblies = new List<Assembly>();
8587
for (var i = 0; i < attributes.Length; i++)
@@ -91,26 +93,46 @@ internal static IReadOnlyList<Assembly> GetRelatedAssemblies(
9193
Resources.FormatRelatedAssemblyAttribute_AssemblyCannotReferenceSelf(nameof(RelatedAssemblyAttribute), assemblyName));
9294
}
9395

96+
Assembly relatedAssembly;
9497
var relatedAssemblyLocation = Path.Combine(assemblyDirectory, attribute.AssemblyFileName + ".dll");
95-
if (!fileExists(relatedAssemblyLocation))
98+
if (fileExists(relatedAssemblyLocation))
99+
{
100+
relatedAssembly = assemblyLoadContext.LoadFromAssemblyPath(relatedAssemblyLocation);
101+
}
102+
else
96103
{
97-
if (throwOnError)
104+
try
98105
{
99-
throw new FileNotFoundException(
100-
Resources.FormatRelatedAssemblyAttribute_CouldNotBeFound(attribute.AssemblyFileName, assemblyName, assemblyDirectory),
101-
relatedAssemblyLocation);
106+
var relatedAssemblyName = new AssemblyName(attribute.AssemblyFileName);
107+
relatedAssembly = assemblyLoadContext.LoadFromAssemblyName(relatedAssemblyName);
102108
}
103-
else
109+
catch when (!throwOnError)
104110
{
111+
// Ignore assembly load failures when throwOnError = false.
105112
continue;
106113
}
107114
}
108115

109-
var relatedAssembly = loadFile(relatedAssemblyLocation);
110116
relatedAssemblies.Add(relatedAssembly);
111117
}
112118

113119
return relatedAssemblies;
114120
}
121+
122+
internal class AssemblyLoadContextWrapper
123+
{
124+
private readonly AssemblyLoadContext _loadContext;
125+
126+
public AssemblyLoadContextWrapper(AssemblyLoadContext loadContext)
127+
{
128+
_loadContext = loadContext;
129+
}
130+
131+
public virtual Assembly LoadFromAssemblyName(AssemblyName assemblyName)
132+
=> _loadContext.LoadFromAssemblyName(assemblyName);
133+
134+
public virtual Assembly LoadFromAssemblyPath(string assemblyPath)
135+
=> _loadContext.LoadFromAssemblyPath(assemblyPath);
136+
}
115137
}
116138
}

src/Mvc/Mvc.Core/test/ApplicationParts/RelatedAssemblyPartTest.cs

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.IO;
67
using System.Reflection;
78
using System.Reflection.Emit;
9+
using System.Runtime.Loader;
810
using Xunit;
911

1012
namespace Microsoft.AspNetCore.Mvc.ApplicationParts
@@ -43,35 +45,39 @@ public void GetRelatedAssemblies_ThrowsIfRelatedAttributeReferencesSelf()
4345
public void GetRelatedAssemblies_ThrowsIfAssemblyCannotBeFound()
4446
{
4547
// Arrange
46-
var expected = $"Related assembly 'DoesNotExist' specified by assembly 'MyAssembly' could not be found in the directory {AssemblyDirectory}. Related assemblies must be co-located with the specifying assemblies.";
4748
var assemblyPath = Path.Combine(AssemblyDirectory, "MyAssembly.dll");
4849
var assembly = new TestAssembly
4950
{
5051
AttributeAssembly = "DoesNotExist"
5152
};
5253

5354
// Act & Assert
54-
var ex = Assert.Throws<FileNotFoundException>(() => RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, throwOnError: true));
55-
Assert.Equal(expected, ex.Message);
56-
Assert.Equal(Path.Combine(AssemblyDirectory, "DoesNotExist.dll"), ex.FileName);
55+
Assert.Throws<FileNotFoundException>(() => RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, throwOnError: true));
5756
}
5857

5958
[Fact]
60-
public void GetRelatedAssemblies_LoadsRelatedAssembly()
59+
public void GetRelatedAssemblies_ReadsAssemblyFromLoadContext_IfItAlreadyExists()
6160
{
6261
// Arrange
63-
var destination = Path.Combine(AssemblyDirectory, "RelatedAssembly.dll");
62+
var expected = $"Related assembly 'DoesNotExist' specified by assembly 'MyAssembly' could not be found in the directory {AssemblyDirectory}. Related assemblies must be co-located with the specifying assemblies.";
63+
var assemblyPath = Path.Combine(AssemblyDirectory, "MyAssembly.dll");
64+
var relatedAssembly = typeof(RelatedAssemblyPartTest).Assembly;
6465
var assembly = new TestAssembly
6566
{
66-
AttributeAssembly = "RelatedAssembly",
67+
AttributeAssembly = "RelatedAssembly"
6768
};
68-
var relatedAssembly = typeof(RelatedAssemblyPartTest).Assembly;
69-
70-
var result = RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, throwOnError: true, file => true, file =>
69+
var loadContext = new TestableAssemblyLoadContextWrapper
7170
{
72-
Assert.Equal(file, destination);
73-
return relatedAssembly;
74-
});
71+
Assemblies =
72+
{
73+
["RelatedAssembly"] = relatedAssembly,
74+
}
75+
};
76+
77+
// Act
78+
var result = RelatedAssemblyAttribute.GetRelatedAssemblies(assembly, throwOnError: true, file => false, loadContext);
79+
80+
// Assert
7581
Assert.Equal(new[] { relatedAssembly }, result);
7682
}
7783

@@ -94,5 +100,21 @@ public override object[] GetCustomAttributes(Type attributeType, bool inherit)
94100
return new[] { attribute };
95101
}
96102
}
103+
104+
private class TestableAssemblyLoadContextWrapper : RelatedAssemblyAttribute.AssemblyLoadContextWrapper
105+
{
106+
public TestableAssemblyLoadContextWrapper() : base(AssemblyLoadContext.Default)
107+
{
108+
}
109+
110+
public Dictionary<string, Assembly> Assemblies { get; } = new Dictionary<string, Assembly>();
111+
112+
public override Assembly LoadFromAssemblyPath(string assemblyPath) => throw new NotSupportedException();
113+
114+
public override Assembly LoadFromAssemblyName(AssemblyName assemblyName)
115+
{
116+
return Assemblies[assemblyName.Name];
117+
}
118+
}
97119
}
98120
}

src/ProjectTemplates/BlazorTemplates.Tests/Infrastructure/GenerateTestProps.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
MicrosoftNETSdkRazorPackageVersion=$(MicrosoftNETSdkRazorPackageVersion);
3636
MicrosoftAspNetCoreAppRefPackageVersion=$(MicrosoftAspNetCoreAppRefPackageVersion);
3737
MicrosoftAspNetCoreAppRuntimePackageVersion=@(_RuntimePackageVersionInfo->'%(PackageVersion)');
38-
SupportedRuntimeIdentifiers=$(SupportedRuntimeIdentifiers);
38+
SupportedRuntimeIdentifiers=$(SupportedRuntimeIdentifiers.Trim());
3939
DefaultNetCoreTargetFramework=$(DefaultNetCoreTargetFramework);
4040
RepoRoot=$(RepoRoot);
4141
Configuration=$(Configuration);

src/ProjectTemplates/Shared/AspNetProcess.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Diagnostics;
7+
using System.IO;
78
using System.Linq;
89
using System.Net;
910
using System.Net.Http;
@@ -41,6 +42,7 @@ public AspNetProcess(
4142
IDictionary<string, string> environmentVariables,
4243
bool published,
4344
bool hasListeningUri = true,
45+
bool usePublishedAppHost = false,
4446
ILogger logger = null)
4547
{
4648
_developmentCertificate = DevelopmentCertificate.Create(workingDirectory);
@@ -59,17 +61,37 @@ public AspNetProcess(
5961

6062
output.WriteLine("Running ASP.NET Core application...");
6163

62-
var arguments = published ? $"exec {dllPath}" : "run --no-build";
64+
string process;
65+
string arguments;
66+
if (published)
67+
{
68+
if (usePublishedAppHost)
69+
{
70+
// When publishingu used the app host to run the app. This makes it easy to consistently run for regular and single-file publish
71+
process = Path.ChangeExtension(dllPath, OperatingSystem.IsWindows() ? ".exe" : null);
72+
arguments = null;
73+
}
74+
else
75+
{
76+
process = DotNetMuxer.MuxerPathOrDefault();
77+
arguments = $"exec {dllPath}";
78+
}
79+
}
80+
else
81+
{
82+
process = DotNetMuxer.MuxerPathOrDefault();
83+
arguments = "run --no-build";
84+
}
6385

64-
logger?.LogInformation($"AspNetProcess - process: {DotNetMuxer.MuxerPathOrDefault()} arguments: {arguments}");
86+
logger?.LogInformation($"AspNetProcess - process: {process} arguments: {arguments}");
6587

6688
var finalEnvironmentVariables = new Dictionary<string, string>(environmentVariables)
6789
{
6890
["ASPNETCORE_Kestrel__Certificates__Default__Path"] = _developmentCertificate.CertificatePath,
6991
["ASPNETCORE_Kestrel__Certificates__Default__Password"] = _developmentCertificate.CertificatePassword,
7092
};
7193

72-
Process = ProcessEx.Run(output, workingDirectory, DotNetMuxer.MuxerPathOrDefault(), arguments, envVars: finalEnvironmentVariables);
94+
Process = ProcessEx.Run(output, workingDirectory, process, arguments, envVars: finalEnvironmentVariables);
7395

7496
logger?.LogInformation("AspNetProcess - process started");
7597

src/ProjectTemplates/Shared/Project.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,16 @@ internal async Task<ProcessResult> RunDotNetNewAsync(
110110
}
111111
}
112112

113-
internal async Task<ProcessResult> RunDotNetPublishAsync(IDictionary<string, string> packageOptions = null, string additionalArgs = null)
113+
internal async Task<ProcessResult> RunDotNetPublishAsync(IDictionary<string, string> packageOptions = null, string additionalArgs = null, bool noRestore = true)
114114
{
115115
Output.WriteLine("Publishing ASP.NET Core application...");
116116

117117
// Avoid restoring as part of build or publish. These projects should have already restored as part of running dotnet new. Explicitly disabling restore
118118
// should avoid any global contention and we can execute a build or publish in a lock-free way
119119

120-
using var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish --no-restore -c Release /bl {additionalArgs}", packageOptions);
120+
var restoreArgs = noRestore ? "--no-restore" : null;
121+
122+
using var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish {restoreArgs} -c Release /bl {additionalArgs}", packageOptions);
121123
await result.Exited;
122124
CaptureBinLogOnFailure(result);
123125
return new ProcessResult(result);
@@ -177,7 +179,7 @@ internal AspNetProcess StartBuiltProjectAsync(bool hasListeningUri = true, ILogg
177179
return new AspNetProcess(Output, TemplateOutputDir, projectDll, environment, published: false, hasListeningUri: hasListeningUri, logger: logger);
178180
}
179181

180-
internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true)
182+
internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true, bool usePublishedAppHost = false)
181183
{
182184
var environment = new Dictionary<string, string>
183185
{
@@ -188,8 +190,8 @@ internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true)
188190
["ASPNETCORE_Logging__Console__FormatterOptions__IncludeScopes"] = "true",
189191
};
190192

191-
var projectDll = $"{ProjectName}.dll";
192-
return new AspNetProcess(Output, TemplatePublishDir, projectDll, environment, published: true, hasListeningUri: hasListeningUri);
193+
var projectDll = Path.Combine(TemplatePublishDir, $"{ProjectName}.dll");
194+
return new AspNetProcess(Output, TemplatePublishDir, projectDll, environment, published: true, hasListeningUri: hasListeningUri, usePublishedAppHost: usePublishedAppHost);
193195
}
194196

195197
internal async Task<ProcessResult> RunDotNetEfCreateMigrationAsync(string migrationName)

src/ProjectTemplates/test/Infrastructure/GenerateTestProps.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
MicrosoftNETSdkRazorPackageVersion=$(MicrosoftNETSdkRazorPackageVersion);
3636
MicrosoftAspNetCoreAppRefPackageVersion=$(MicrosoftAspNetCoreAppRefPackageVersion);
3737
MicrosoftAspNetCoreAppRuntimePackageVersion=@(_RuntimePackageVersionInfo->'%(PackageVersion)');
38-
SupportedRuntimeIdentifiers=$(SupportedRuntimeIdentifiers);
38+
SupportedRuntimeIdentifiers=$(SupportedRuntimeIdentifiers.Trim());
3939
DefaultNetCoreTargetFramework=$(DefaultNetCoreTargetFramework);
4040
RepoRoot=$(RepoRoot);
4141
Configuration=$(Configuration);

src/ProjectTemplates/test/MvcTemplateTest.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,77 @@ public async Task MvcTemplate_IndividualAuth(bool useLocalDB)
223223
}
224224
}
225225

226+
[ConditionalFact(Skip = "https://github.com/dotnet/aspnetcore/issues/25103")]
227+
[SkipOnHelix("cert failure", Queues = "OSX.1014.Amd64;OSX.1014.Amd64.Open")]
228+
public async Task MvcTemplate_SingleFileExe()
229+
{
230+
// This test verifies publishing an MVC app as a single file exe works. We'll limit testing
231+
// this to a few operating systems to make our lives easier.
232+
string runtimeIdentifer;
233+
if (OperatingSystem.IsWindows())
234+
{
235+
runtimeIdentifer = "win-x64";
236+
}
237+
else if (OperatingSystem.IsLinux())
238+
{
239+
runtimeIdentifer = "linux-x64";
240+
}
241+
else
242+
{
243+
return;
244+
}
245+
246+
Project = await ProjectFactory.GetOrCreateProject("mvcsinglefileexe", Output);
247+
Project.RuntimeIdentifier = runtimeIdentifer;
248+
249+
var createResult = await Project.RunDotNetNewAsync("mvc", auth: "Individual", useLocalDB: true);
250+
Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
251+
252+
var publishResult = await Project.RunDotNetPublishAsync(additionalArgs: $"/p:PublishSingleFile=true -r {runtimeIdentifer}", noRestore: false);
253+
Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
254+
255+
var pages = new[]
256+
{
257+
new Page
258+
{
259+
// Verify a view from the app works
260+
Url = PageUrls.HomeUrl,
261+
Links = new []
262+
{
263+
PageUrls.HomeUrl,
264+
PageUrls.RegisterUrl,
265+
PageUrls.LoginUrl,
266+
PageUrls.HomeUrl,
267+
PageUrls.PrivacyUrl,
268+
PageUrls.DocsUrl,
269+
PageUrls.PrivacyUrl
270+
}
271+
},
272+
new Page
273+
{
274+
// Verify a view from a RCL (in this case IdentityUI) works
275+
Url = PageUrls.RegisterUrl,
276+
Links = new []
277+
{
278+
PageUrls.HomeUrl,
279+
PageUrls.RegisterUrl,
280+
PageUrls.LoginUrl,
281+
PageUrls.HomeUrl,
282+
PageUrls.PrivacyUrl,
283+
PageUrls.ExternalArticle,
284+
PageUrls.PrivacyUrl
285+
}
286+
},
287+
};
288+
289+
using var aspNetProcess = Project.StartPublishedProjectAsync(usePublishedAppHost: true);
290+
Assert.False(
291+
aspNetProcess.Process.HasExited,
292+
ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process));
293+
294+
await aspNetProcess.AssertPagesOk(pages);
295+
}
296+
226297
[Fact]
227298
[QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/23993")]
228299
public async Task MvcTemplate_RazorRuntimeCompilation_BuildsAndPublishes()

0 commit comments

Comments
 (0)