Skip to content

Make dotnet run pass its arguments to the app, as it does without the package - #724

Merged
Nikola Metulev (nmetulev) merged 14 commits into
mainfrom
azchohfi-fix-dotnet-run-arguments
Aug 13, 2026
Merged

Nikola Metulev (nmetulev) merged 14 commits into
mainfrom
azchohfi-fix-dotnet-run-arguments

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #731 — merge that first. This PR makes MSBuild properties the only way to configure winapp through dotnet run, and #731 is what supplies Detach, UnregisterOnExit, Clean, Symbols, Executable, and RunArgs.

Problem

For a project that references this package, both of these fail:

> dotnet run --devtools
❌ Unrecognized argument: '--devtools'. To pass arguments to the app, use --

> dotnet run -- --devtools
❌ Unrecognized argument: '--devtools'. To pass arguments to the app, use --

The second one is the trap: the error tells you to use --, and you did. Both commands work on any project that doesn't reference the package, so adding it silently changed what dotnet run means.

Why both spellings fail identically

The .NET SDK appends your application arguments to $(RunArguments) verbatim, and System.CommandLine consumes any standalone -- while parsing dotnet run itself and never re-emits it (RunProperties.WithApplicationArguments → CommonRunHelpers.CombineRunArguments).

Verified with a probe app that echoes its argv — dotnet run --devtools and dotnet run -- --devtools produce byte-identical results. The separator is unrecoverable, so the targets file cannot tell the two apart.

Because the package overrides RunCommand to an intermediary launcher that has its own options, those tokens land in winapp's option namespace instead of your app's.

Fix

One line. RunArguments now ends with a separator:

<RunArguments>$(_WinAppRunArgs) --</RunArguments>

Everything the SDK appends lands in winapp's passthrough region and reaches your app unchanged.

No CLI change is needed — winapp run keeps rejecting unknown options, so a typo like winapp run . --debug-outpt still fails loudly in a hand-typed invocation.

Command Before After
dotnet run --devtools ❌ error app gets --devtools ✅
dotnet run -- --devtools ❌ error app gets --devtools ✅
dotnet run --detach winapp detaches app gets --detach ⚠️
dotnet run -p:WinAppRunDetach=true winapp detaches unchanged ✅
winapp run . --detach (direct CLI) winapp detaches unchanged ✅
winapp run . --debug-outpt (direct CLI) ❌ typo caught ❌ typo caught ✅

⚠️ Breaking change

Options written after dotnet run used to configure the launcher. dotnet run --detach now passes --detach to your app instead. Use the properties: -p:WinAppRunDetach=true.

MSBuild can't warn about this, because it never sees those tokens — so winapp does. When invoked as the NuGet caller and a forwarded argument matches one of its own options:

ℹ '--detach' was passed to your application, not to winapp.
  To configure winapp, use -p:WinAppRunDetach=true instead.

Deliberately narrow: unknown arguments stay silent (they never had a winapp meaning, so a notice would be noise on every run), a direct winapp run . -- --detach stays silent (an explicit request to forward), and attached-value spellings such as --executable=foo.exe are matched on the option name, since those configured winapp before this change too.

Note that a forwarded --json or --quiet does produce the notice: with the separator in place those tokens go to your app rather than putting winapp into JSON/quiet mode, so the notice is accurate. The suppression guard still applies when winapp itself is in that mode (winapp run --json, or -p:WinAppRunArgs="--json").

Validation

  • 132/132 RunCommandTests pass, including 4 new tests covering each notice case.
  • 27/29 NuGet tests pass; the 2 failures are the pre-existing package-layout tests needing a built .nupkg.
  • New test asserts the shared _WinAppRunArgs stays separator-free, so RunPackagedApp is provably unaffected.
  • Verified on the built binary: notice fires for a known option, stays silent for --devtools and for direct CLI use, and covers attached-value forms like --executable=foo.exe.

Docs updated across docs/dotnet-run-support.md, docs/usage.md, docs/guides/dotnet.md, the package README, and the frameworks skill.

Copilot AI balanced review requested due to automatic review settings August 10, 2026 21:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes #723 by preserving application arguments passed through dotnet run in the NuGet integration.

Changes:

  • Adds the required -- forwarding separator to generated WinApp CLI arguments.
  • Adds regression coverage for separator placement and WinAppLaunchArgs ordering.
  • Documents argument forwarding across NuGet and .NET guidance.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/winapp-NuGet/tests/NuGet.Tests.ps1 Tests computed run-argument ordering.
src/winapp-NuGet/README.md Documents NuGet argument forwarding.
src/winapp-NuGet/build/Microsoft.Windows.SDK.BuildTools.WinApp.targets Restores the application-argument separator.
plugins/winapp/skills/winapp-frameworks/SKILL.md Updates shipped framework guidance.
docs/usage.md Adds forwarding usage example.
docs/guides/dotnet.md Updates the .NET workflow guide.
docs/dotnet-run-support.md Documents transient and persistent arguments.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

⏳ Build in progress — metrics below are from a previous commit and will update when the current build finishes.

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 38.62 MB 38.62 MB 📈 +2.5 KB (+0.01%)
CLI (x64) 38.73 MB 38.74 MB 📈 +2.5 KB (+0.01%)
MSIX (ARM64) 16.02 MB N/A N/A
MSIX (x64) 17.01 MB N/A N/A
NPM Package 33.42 MB N/A N/A
NuGet Package 33.46 MB N/A N/A

Test Results

❌ 4571 passed, 1 failed, 5 skipped out of 4577 tests in 558.9s (+10 tests, -79.8s vs. baseline)

Test Coverage

✅ 89.1% line coverage, 82.4% branch coverage · ✅ no change vs. baseline

CLI Startup Time

48ms median (x64, winapp --version) · ✅ no change vs. baseline


Updated 2026-08-13 18:15:31 UTC · commit 83cb58c · workflow run

@azchohfi Alexandre Zollinger Chohfi (azchohfi) changed the title Fix NuGet dotnet run argument forwarding Expand NuGet dotnet run option support Aug 10, 2026
@azchohfi Alexandre Zollinger Chohfi (azchohfi) changed the title Expand NuGet dotnet run option support Make dotnet run pass its arguments to the app, as it does without the package Aug 11, 2026
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) changed the base branch from main to azchohfi-nuget-run-properties August 11, 2026 23:59
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) marked this pull request as ready for review August 12, 2026 00:31
Base automatically changed from azchohfi-nuget-run-properties to main August 12, 2026 00:48
… package

`dotnet run --devtools` failed with "Unrecognized argument" for a project that
references this package, and `dotnet run -- --devtools` failed identically. The
same commands work on any other project, so referencing the package silently
changed what `dotnet run` means.

The .NET SDK appends application arguments to $(RunArguments) verbatim, and
System.CommandLine consumes any standalone separator while parsing `dotnet run`
itself and never re-emits it, so both spellings arrive at winapp as a bare token
in winapp's own option namespace. RunArguments now ends with a separator, which
puts everything the SDK appends into winapp's passthrough region instead.

That is a one line change and needs no CLI change: `winapp run` keeps rejecting
unknown options, so a typo in a hand-typed invocation still fails loudly.

BREAKING: options written after `dotnet run` used to configure the launcher.
`dotnet run --detach` now detaches nothing and passes --detach to the app. Use
the WinAppRun* properties instead, for example -p:WinAppRunDetach=true.

MSBuild cannot warn about this because it never sees those tokens, so winapp
does: when invoked as the NuGet caller and a forwarded argument matches one of
its own options, it names the property that replaces it. Unknown arguments stay
silent -- they never had a winapp meaning -- and a direct `winapp run . -- x`
stays silent too, since that is an explicit request to forward.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
@azchohfi

Copy link
Copy Markdown
Collaborator Author

Copilot review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs:795

  • isJson only reflects launcher options parsed before the separator. The NuGet path places forwarded arguments after --, and GlobalOptionPreScan explicitly stops there, so dotnet run --json leaves isJson false and this method later emits a notice for --json. The same applies to forwarded --quiet, contradicting the PR's promised JSON/quiet suppression. Check the forwarded option names as well, including aliases and attached-value forms.
            if (isJson || forwardedArgs.Count == 0 || !logger.IsEnabled(LogLevel.Information))
            {
                return;
            }

docs/dotnet-run-support.md:210

  • This PR introduces the routing change, so saying it happened "in a previous release" gives users incorrect migration timing. Describe it as a breaking change in this release (or name the exact version where it starts).
> This changed in a previous release. Options written directly after `dotnet run` used to configure
> WinApp, so `dotnet run --detach` detached the launcher; now it reaches your application. If winapp

src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs:837

  • Exact token matching misses accepted attached-value spellings such as --executable=foo.exe and --detach=true. Those forms previously configured winapp and now reach the app, but receive no migration notice. Normalize the portion before the first = for option lookup, deduplication, and property mapping while retaining the original token in the message/fallback.
            foreach (var arg in forwardedArgs)
            {
                if (!knownOptions.Contains(arg) || !alreadyReported.Add(arg))
                {

Copilot AI commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Copilot review

Reviewed the current PR state. I found no actionable code changes to make; the latest CI runs for the current commit are green, and the older failed build was superseded by the current passing runs.

Three review follow-ups.

An option attached to its value (--executable=foo.exe, --detach=true) configured
winapp before this change just as the separated spelling did, but exact token
matching missed it, so those invocations changed meaning with no notice at all.
Lookup, de-duplication and property mapping now use the name before the '=',
while the message still quotes the token as the user typed it.

The docs said this changed "in a previous release". It is introduced here, so
the note now says so and is marked as breaking.

The claim that --json and --quiet are suppressed was wrong for the NuGet path.
With the trailing separator, `dotnet run --json` forwards --json to the app
rather than putting winapp in JSON mode, so isJson stays false and the notice
fires -- which is the correct behavior, because the token really did go to the
app. The guard still does its job when winapp itself is in JSON or quiet mode.
Only the description was inaccurate; no behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
WaitForPidFile_EmptyThenPopulated_WaitsForAParsableValue failed on CI with a 15
second TimeoutException. Both tests added in #732 populated or released the file
from a Task.Run continuation and then waited on the helper, so each depended on
the thread pool scheduling that continuation promptly. On a loaded agent running
four test workers that is not guaranteed -- reintroducing, in the tests meant to
remove flakiness, exactly the kind of timing dependence they were fixing.

Neither test needs concurrency to prove its point:

- The sharing-violation test now asserts the exception TYPE with nothing ever
  releasing the handle. The old code threw IOException on the first poll; the
  retry swallows it and runs out the clock, so TimeoutException is the signal.
  Verified it still fails in 72ms when the retry is removed.
- The empty-file test asserts that a permanently empty file times out, which is
  what the parse guard is for. The populated path is already covered by the
  tree-kill test end to end.

Both now run in well under a second and cannot be perturbed by machine load.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
Review follow-up. Rewriting the exclusive-handle test to assert a timeout left
both remaining tests asserting failure modes, so nothing at unit level proved a
readable PID file is actually parsed -- only the tree-kill test did, and that
exercises the whole launcher. The comment claiming otherwise was stale.

Adds WaitForPidFile_PopulatedFile_ReturnsThePid: write a value, call the helper,
assert the value. No concurrency, so it keeps the determinism the rest of this
change is about, and corrects the comment to point at it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
@azchohfi

Copy link
Copy Markdown
Collaborator Author

Addressing the three suppressed comments from the last Copilot review, since they don't create reply-able threads. All three were legitimate; two were fixed and one was a documentation error on my side rather than a behavior bug.

1. Attached-value spellings missed the notice — fixed in c0e4de79.

Confirmed on the built binary: --executable=foo.exe produced no notice at all, so an invocation that previously configured winapp changed meaning silently. Lookup, de-duplication, and property mapping now use the name before the =, while the message still quotes the token as typed:

ℹ '--executable=foo.exe' was passed to your application, not to winapp.
  To configure winapp, use -p:WinAppRunExecutable=<path> instead.
ℹ '--detach=true' was passed to your application, not to winapp.
  To configure winapp, use -p:WinAppRunDetach=true instead.

--devtools=1 still stays silent, so unknown options don't start generating noise. Covered by a new test.

2. Docs said "in a previous release" — fixed in c0e4de79. That was plainly wrong, since this PR introduces the change; it now reads as a breaking change in this release and is marked [!IMPORTANT].

3. --json / --quiet suppression claim — PR description corrected; no behavior change.

You were right that the claim was false, and I verified it:

> winapp run . --caller nuget-package -- --json
ℹ '--json' was passed to your application, not to winapp. ...

But the behavior is correct as-is. With the separator in place, dotnet run --json sends --json to the app rather than putting winapp in JSON mode, so isJson is legitimately false and the notice is accurate — that forwarded token really did go to the application. There's also no JSON document to corrupt, because winapp isn't emitting one. The guard still does its job when winapp itself is in JSON or quiet mode (winapp run --json, or -p:WinAppRunArgs="--json"). So only my PR description was wrong, and it's been updated.

Two later commits also landed here: 5fd410f8 and b33bcb16 cherry-pick #737, which fixes a flaky PID-file test I introduced in #732 that was failing build-and-package on this branch and on main.

@azchohfi

Copy link
Copy Markdown
Collaborator Author

Copilot review

Copilot AI commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Copilot review

Reviewed the latest four commits. I found no actionable code changes to make; the only potential concern overlaps with the intentional migration notice behavior already called out above. I also checked CI: the older c0e4de7 build failure was the PID-file timeout fixed by later commits; the latest full completed build/test set on 5fd410f passed, while the newest b33bcb1 build/test runs were still in progress when checked.

Copilot AI and others added 2 commits August 11, 2026 19:53
Nikola Metulev (nmetulev) pushed a commit that referenced this pull request Aug 12, 2026
## Problem

`WaitForPidFile_EmptyThenPopulated_WaitsForAParsableValue` — added by me
in #732 and now on `main` — failed on CI:

```
System.TimeoutException: The descendant PID file '...empty_....pid'
was not written within 00:00:15.
```

Both tests #732 added did the same thing: populate (or release) the file
from a `Task.Run` continuation, then `await` the helper. That makes each
one depend on the thread pool scheduling that continuation promptly. On
a loaded agent running 4 test workers, it isn't.

Which is the irony worth naming: these are the tests meant to *remove*
flakiness from this file, and they reintroduced exactly the kind of
timing dependence they were fixing. A 150 ms delay under a 15 s budget
looks generous right up until the pool is saturated.

## Fix

Neither test needs concurrency to prove its point.

**Sharing violation** — assert the exception *type*, with nothing ever
releasing the handle:

```csharp
using var exclusive = new FileStream(pidFile, FileMode.Open, FileAccess.Write, FileShare.None);

await Assert.ThrowsAsync<TimeoutException>(
    async () => await WaitForPidFileAsync(pidFile, TimeSpan.FromMilliseconds(300), ct));
```

The old code threw `IOException` on the very first poll; the retry
swallows it and runs out the clock. `TimeoutException` *is* the signal
that the retry works — no second thread required.

**Empty file** — assert that a permanently empty file times out, which
is precisely what the parse guard is for. The populated path is already
covered end-to-end by the tree-kill test.

## Validation

- 3/3 pass, both rewritten tests now finishing in well under a second
instead of 15.
- Regression coverage intact: with the `catch (IOException)` removed,
`WaitForPidFile_WriterHoldsFileExclusively_RetriesInsteadOfThrowing`
still fails in **72 ms**.
- No production code touched — `DotNetService` is unchanged; this is
tests only.

This unblocks `build-and-package`, which is currently red on `main` and
on #724.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: azchohfi <527713+azchohfi@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be

@nmetulev Nikola Metulev (nmetulev) left a comment

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.

🤖 AI-generated review (winappcli pr-review skill) — verify before acting.

Requesting changes for three validated NuGet-path issues. The core separator change is correctly isolated: direct winapp run, npm-tagged calls, project mode, and RunPackagedApp retain their existing behavior.

Comment thread docs/dotnet-run-support.md Outdated
Comment thread docs/guides/dotnet.md Outdated
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.cs Outdated
Review follow-up on three validated findings.

The notice matched every option name reachable from the parser, so ordinary
application flags triggered it. `--help`, `--configuration`, `-p`, `--no-build`
and the other project-mode options are ignored in folder mode -- the only mode
the NuGet targets use -- so they never had a winapp meaning on this path and
there is nothing to migrate.

The generic fallback also emitted advice that fails. It suggested
WinAppRunArgs="<option>" without the value, so for `--configuration Release` the
recommended command errors with "Required argument missing for option:
'--configuration'". OptionToMSBuildProperty is now the whole trigger set, which
removes the false positives and the broken suggestion together: every notice
names a property that actually replaces the option.

The docs claimed a standalone separator is optional and has no effect. That
holds only until the app's flag collides with a `dotnet run` option: verified
that `dotnet run --configuration Release` reaches the app with nothing, while
`dotnet run -- --configuration Release` forwards both tokens. Qualified in
dotnet-run-support.md, usage.md, guides/dotnet.md, the package README and the
frameworks skill.

The .NET guide also showed WinAppRunDebugOutput and WinAppRunDetach together,
which the CLI rejects as mutually exclusive -- in the same PR that documents
that constraint. Reduced to one property.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a209763e-7185-4986-bf0b-98e52c06b4be
@azchohfi

Copy link
Copy Markdown
Collaborator Author

Nikola Metulev (@nmetulev) — all three addressed in 2c7e479c, each reproduced on the built binary first. Re-requested your review.

1. Migration notices on legitimate app flags. Took both of your options rather than either: OptionToMSBuildProperty is now the entire trigger set and the generic fallback is gone. One change fixes both halves — an option only produces a notice when a property genuinely replaces it, so no suggestion can silently drop a value.

Verified before/after:

forwarded before after
--help, --configuration, -p, --no-build, --verbose notice silent
--detach, --clean, --executable=foo.exe notice notice

Your framing was the useful part: the project-mode options are ignored in folder mode, which is the only mode the NuGet targets use, so they never had a winapp meaning on this path. Nothing to migrate. Guarded by a [DataRow] regression test plus one asserting --configuration Release reaches the app with its value intact.

2. Impossible example. Confirmed — --debug-output --detach errors as mutually exclusive, in the same PR that adds the constraint table. Reduced to one property and pointed the surrounding text at the table.

3. -- is not optional on collision. Confirmed with a probe app:

dotnet run --configuration Release      → APPARGS=[]
dotnet run -- --configuration Release   → APPARGS=[--configuration|Release]

Qualified in all five places you listed, each naming the colliding option set and showing the working form. Code can't fix this one — the SDK consumes those tokens before winapp is involved — so it stays a docs qualification.

138/138 RunCommandTests pass; 29/29 checks green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants