Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ Similarly, implicit build files like `Directory.Build.props` or `Directory.Packa
> [!CAUTION]
> Multi-file support is postponed for .NET 11.
> In .NET 10, only the single file passed as the command-line argument to `dotnet run` is part of the compilation.
> Specifically, the virtual project has properties `EnableDefaultCompileItems=false` and `EnableDefaultEmbeddedResourceItems=false`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did we arrive at disabling this particular set of items? Do we have a reason for believing that if the directory contains some embedded resource, that we don't want to include it in the file-based app?

@jjonescz jjonescz Jul 2, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compile items are disabled since we are doing single file apps.

Embedded resources were discussed in the internal chat and I think the decision was to also exclude them, right, @baronfel @DamianEdwards? I'm not sure there is a strong reason for that. I think I would also prefer to include everything unless explicitly disabled (would make it easier to reason about this).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping embedded resources seems fine to me. @baronfel what was your reasoning for excluding them from single-file apps?

> (which can be customized via `#:property` directives), and a `Compile` item for the entry point file.
> During [conversion](#grow-up), any `Content`, `None`, `Compile`, and `EmbeddedResource` items that do not have metadata `ExcludeFromFileBasedAppConversion=true`
Comment thread
jjonescz marked this conversation as resolved.
> and that are files inside the entry point file's directory tree are copied to the converted directory.

### Nested files

Expand Down
64 changes: 64 additions & 0 deletions src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Microsoft.Build.Evaluation;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.TemplateEngine.Cli.Commands;
Expand Down Expand Up @@ -32,6 +33,9 @@ public override int Execute()
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, errors: null);

// Find other items to copy over, e.g., default Content items like JSON files in Web apps.
var includeItems = FindIncludedItems().ToList();

Directory.CreateDirectory(targetDirectory);

var targetFile = Path.Join(targetDirectory, Path.GetFileName(file));
Expand All @@ -47,11 +51,71 @@ public override int Execute()
File.Move(file, targetFile);
}

// Create project file.
string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
VirtualProjectBuildingCommand.WriteProjectFile(writer, directives, isVirtualProject: false);

// Copy over included items.
foreach (var item in includeItems)
{
string targetItemFullPath = Path.Combine(targetDirectory, item.RelativePath);

// Ignore already-copied files.
if (File.Exists(targetItemFullPath))
{
continue;
}

string targetItemDirectory = Path.GetDirectoryName(targetItemFullPath)!;
Directory.CreateDirectory(targetItemDirectory);
File.Copy(item.FullPath, targetItemFullPath);
}

return 0;

IEnumerable<(string FullPath, string RelativePath)> FindIncludedItems()
{
string entryPointFileDirectory = PathUtility.EnsureTrailingSlash(Path.GetDirectoryName(file)!);
var projectCollection = new ProjectCollection();
var command = new VirtualProjectBuildingCommand(
entryPointFileFullPath: file,
msbuildArgs: MSBuildArgs.FromOtherArgs([]))
{
Directives = directives,
};
var projectInstance = command.CreateProjectInstance(projectCollection);

// Include only items we know are files.
string[] itemTypes = ["Content", "None", "Compile", "EmbeddedResource"];
var items = itemTypes.SelectMany(t => projectInstance.GetItems(t));

foreach (var item in items)
{
// Escape hatch - exclude items that have metadata `ExcludeFromFileBasedAppConversion` set to `true`.
string include = item.GetMetadataValue("ExcludeFromFileBasedAppConversion");
Comment thread
jjonescz marked this conversation as resolved.
if (string.Equals(include, bool.TrueString, StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Exclude items that are not contained within the entry point file directory.
string itemFullPath = Path.GetFullPath(path: item.GetMetadataValue("FullPath"), basePath: entryPointFileDirectory);
if (!itemFullPath.StartsWith(entryPointFileDirectory, StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Exclude items that do not exist.
if (!File.Exists(itemFullPath))
{
continue;
}

string itemRelativePath = Path.GetRelativePath(relativeTo: entryPointFileDirectory, path: itemFullPath);
yield return (FullPath: itemFullPath, RelativePath: itemRelativePath);
}
}
}
}
2 changes: 0 additions & 2 deletions src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,6 @@ public override RunApiOutput Execute()
CustomArtifactsPath = ArtifactsPath,
};

buildCommand.PrepareProjectInstance();

var runCommand = new RunCommand(
noBuild: false,
projectFileFullPath: null,
Expand Down
7 changes: 5 additions & 2 deletions src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ public int Execute()
if (EntryPointFileFullPath is not null)
{
Debug.Assert(!ReadCodeFromStdin);
projectFactory = CreateVirtualCommand().PrepareProjectInstance().CreateProjectInstance;
projectFactory = CreateVirtualCommand().CreateProjectInstance;
}
}

Expand Down Expand Up @@ -587,7 +587,10 @@ public static RunCommand FromParseResult(ParseResult parseResult)
}

// If '-' is specified as the input file, read all text from stdin into a temporary file and use that as the entry point.
entryPointFilePath = Path.GetTempFileName();
// We create a new directory for each file so other files are not included in the compilation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI failed, revealing a bug here, so I'm fixing it in the last commit.

Previously, the implementation for running code from stdin put the code into a temp file but with this PR other files in the temp directory could be potentially included in the compilation. I've fixed that by putting the stdin code files into isolated temp directories instead. I've reused the same machinery we have for creating the artifacts folders. I think both these kinds of temp dirs (for artifacts and for stdin code) can reuse other features when implemented (periodical cleanup and customizing the temp path).

@RikkiGibson RikkiGibson Jul 3, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a risk that Path.GetRandomFileName() will collide with another one from a previous run? Are we able to proceed and do something reasonable in that case?

I am curious if we could just think of each user having their own "stdin app" and reusing the artifacts path for it across runs. However, I don't think it's urgent/necessary (or even necessarily correct?) to do so.

@jjonescz jjonescz Jul 4, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a risk that Path.GetRandomFileName() will collide with another one from a previous run? Are we able to proceed and do something reasonable in that case?

We should probably create the file only if it doesn't exist and fail if it does. Thanks.

If that happens often, we can improve on the random file name generation.

I am curious if we could just think of each user having their own "stdin app" and reusing the artifacts path for it across runs. However, I don't think it's urgent/necessary (or even necessarily correct?) to do so.

Hm, interesting, but that would disallow users running multiple stdin apps in parallel.

string directory = VirtualProjectBuildingCommand.GetTempSubdirectory(Path.GetRandomFileName());
VirtualProjectBuildingCommand.CreateTempSubdirectory(directory);
entryPointFilePath = Path.Join(directory, "app.cs");
using (var stdinStream = Console.OpenStandardInput())
using (var fileStream = File.OpenWrite(entryPointFilePath))
{
Expand Down
94 changes: 53 additions & 41 deletions src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ Override targets which don't work with project files that are not present on dis
</Target>
""";

private ImmutableArray<CSharpDirective> _directives;

public VirtualProjectBuildingCommand(
string entryPointFileFullPath,
MSBuildArgs msbuildArgs)
Expand All @@ -136,6 +134,23 @@ public VirtualProjectBuildingCommand(
/// </summary>
public bool NoBuildMarkers { get; init; }

public ImmutableArray<CSharpDirective> Directives
{
get
{
if (field.IsDefault)
Comment thread
jjonescz marked this conversation as resolved.
{
var sourceFile = LoadSourceFile(EntryPointFileFullPath);
field = FindDirectives(sourceFile, reportAllErrors: false, errors: null);
Debug.Assert(!field.IsDefault);
}

return field;
}

init;
}

public override int Execute()
{
Debug.Assert(!(NoRestore && NoBuild));
Expand All @@ -157,8 +172,6 @@ public override int Execute()
Reporter.Output.WriteLine(CliCommandStrings.NoBinaryLogBecauseUpToDate.Yellow());
}

PrepareProjectInstance();

return 0;
}

Expand Down Expand Up @@ -188,8 +201,6 @@ public override int Execute()
LogTaskInputs = binaryLoggers.Length != 0,
};

PrepareProjectInstance();

// Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`).
// See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838
// and https://github.com/dotnet/msbuild/issues/11519.
Expand Down Expand Up @@ -445,17 +456,7 @@ private void MarkBuildStart()

string directory = GetArtifactsPath();

if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(directory);
}
else
{
// Ensure only the current user has access to the directory to avoid leaking the program to other users.
// We don't mind that permissions might be different if the directory already exists,
// since it's under user's local directory and its path should be unique.
Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
CreateTempSubdirectory(directory);

File.WriteAllText(Path.Join(directory, BuildStartCacheFileName), EntryPointFileFullPath);
}
Expand All @@ -472,19 +473,6 @@ private void MarkBuildSuccess(RunFileBuildCacheEntry cacheEntry)
JsonSerializer.Serialize(stream, cacheEntry, RunFileJsonSerializerContext.Default.RunFileBuildCacheEntry);
}

/// <summary>
/// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
/// </summary>
public VirtualProjectBuildingCommand PrepareProjectInstance()
{
Debug.Assert(_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");

var sourceFile = LoadSourceFile(EntryPointFileFullPath);
_directives = FindDirectives(sourceFile, reportAllErrors: false, errors: null);

return this;
}

public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection)
{
return CreateProjectInstance(projectCollection, addGlobalProperties: null);
Expand All @@ -511,13 +499,11 @@ private ProjectInstance CreateProjectInstance(

ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
{
Debug.Assert(!_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should have been called first.");

var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
var projectFileWriter = new StringWriter();
WriteProjectFile(
projectFileWriter,
_directives,
Directives,
isVirtualProject: true,
targetFilePath: EntryPointFileFullPath,
artifactsPath: GetArtifactsPath(),
Expand All @@ -534,20 +520,46 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)

private string GetArtifactsPath() => CustomArtifactsPath ?? GetArtifactsPath(EntryPointFileFullPath);

// internal for testing
internal static string GetArtifactsPath(string entryPointFileFullPath)
public static string GetArtifactsPath(string entryPointFileFullPath)
{
// Include entry point file name so the directory name is not completely opaque.
string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath);
string directoryName = $"{fileName}-{hash}";

return GetTempSubdirectory(directoryName);
}

/// <summary>
/// Obtains a temporary subdirectory for file-based apps.
/// </summary>
public static string GetTempSubdirectory(string name)
{
// We want a location where permissions are expected to be restricted to the current user.
string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.GetTempPath()
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

// Include entry point file name so the directory name is not completely opaque.
string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath);
string directoryName = $"{fileName}-{hash}";
return Path.Join(directory, "dotnet", "runfile", name);
}

return Path.Join(directory, "dotnet", "runfile", directoryName);
/// <summary>
/// Creates a temporary subdirectory for file-based apps.
/// Use <see cref="GetTempSubdirectory"/> to obtain the path.
/// </summary>
public static void CreateTempSubdirectory(string path)
{
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
}
else
{
// Ensure only the current user has access to the directory to avoid leaking the program to other users.
// We don't mind that permissions might be different if the directory already exists,
// since it's under user's local directory and its path should be unique.
Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}

public static void WriteProjectFile(
Expand Down Expand Up @@ -648,7 +660,7 @@ public static void WriteProjectFile(
writer.WriteLine("""

<PropertyGroup>
<EnableDefaultItems>false</EnableDefaultItems>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
""");
}
Expand Down
Loading