Skip to content

refactor: build the secondary activation payload without AppEnvironment - #985

Merged
albertospelta merged 9 commits into
mainfrom
refactor/secondary-payload-without-appenvironment
Oct 20, 2026
Merged

albertospelta merged 9 commits into
mainfrom
refactor/secondary-payload-without-appenvironment

Conversation

@albertospelta

Copy link
Copy Markdown
Member

A secondary instance launched with arguments, for example as a Power BI Desktop External Tool with --server and
--database, now builds its activation payload without running the AppEnvironment static constructor, as one
launched without arguments already did. That constructor probes the WebView2 runtime, the deployment mode and the
registry, which a process that only forwards its arguments does not need.

Changes

  • Power BI Desktop facts move to PowerBIDesktop: the process and Analysis Services image names as constants, and
    GetReportName, which removes the application name from a main window title. The suffixes become a private
    ImmutableArray, matched ordinally.
  • Process lookups become Process extension members that take the Windows session from the caller or the process:
    Process.TryGetById and process.GetParentProcess.
  • Startup command line: StartupCommandLine.Parse turns the arguments into values; StartupSettings resolves the
    launching process with the session of the current process and tests the MSIX package directly. An invalid --ppid
    is reported as a command line error instead of throwing.
  • Process tree: the WMI queries (about 200 ms each, on a dedicated STA thread, dependent on the WMI service) are
    replaced by a Toolhelp32 snapshot taken through CsWin32 (ProcessInterop) and read by ProcessTree. Listing the
    open reports takes one snapshot for all Power BI Desktop processes. System.Management is no longer referenced.
  • Fix: a parent or child whose process identifier was reused is ignored: a parent must have started before its
    child, compared in UTC.
  • Cleanup: GetChildrenPIDs is renamed GetChildProcessIds; parent lookups left without callers, Ntdll and
    AppEnvironment.ProcessId are removed.
  • Docs: the single-instance contract in docs/design/architecture.md states that the payload code does not
    reference AppEnvironment.

Tests

  • ClaimOrRedirect_DoesNotReferenceAppEnvironment walks the IL reachable from BravoSingleInstance.ClaimOrRedirect()
    and fails if any method references AppEnvironment; a positive control checks the walker on a method that does.
  • Unit tests for StartupCommandLine, ProcessTree, PowerBIDesktop.GetReportName and the process extension members.
  • The test of the localized window title formats also finds the Microsoft Store package of Power BI Desktop (41
    resource files of 2.159 pass).

Verification

  • Every commit builds and passes its tests; dotnet build Bravo.sln adds no warnings; 198 tests pass.
  • End to end: with a fake primary holding the real pipe name and a temporary probe in the static constructor, secondaries
    launched with and without arguments delivered the same payload as main and never ran the static constructor; the
    probe detects the constructor on main with arguments.
  • Not verified on a live Power BI Desktop: the msmdsrv.exe child lookup used to list open reports, and the MSIX
    --ppid path.

The process name and the Analysis Services image name move to
PowerBIDesktop as constants. A constant is compiled into its callers, so
reading it no longer runs the AppEnvironment static constructor; this
follows the split of AppEnvironment into small immutable types.
PowerBIDesktop.GetReportName removes the application name from a main
window title. It replaces the GetPBIDesktopMainWindowTitle extension,
which read the window and parsed its title in one step and returned a
report name under the name of a title, so the parsing is now testable
without a process.

The suffixes become a private ImmutableArray instead of a public array
field on AppEnvironment, which any caller could change. They are matched
with an ordinal comparison: they are literal characters, not text to
compare by culture.

The test of the localized title formats moves from StringExtensionsTests
and now checks the report name; new tests cover each separator.
Process.TryGetById and Process.GetParentProcess are extension members
that take the session from the caller or from the process itself instead
of AppEnvironment, so a caller that must not initialize AppEnvironment
can use them. The WMI query of the parent lookup is unchanged.

ProcessHelper.SafeGetProcessById and ProcessHelper.GetParentProcess keep
their callers and delegate to them with the current session. A process
found in another session, or one that has exited, is now disposed
instead of being dropped with its handle open.
StartupCommandLine.Parse turns the arguments into values without reading
the process environment, so the parsing is testable. FromCommandLine then
finds the process that launched Bravo with the session of the current
process, and tests the MSIX package directly instead of reading
AppEnvironment.DeploymentMode.

A secondary instance launched with arguments, for example as a Power BI
Desktop External Tool, therefore builds its activation payload without
running the AppEnvironment static constructor, as one launched without
arguments already did. The payload is unchanged.
The test of the localized window title formats also finds the Microsoft
Store package of Power BI Desktop, through the documented package APIs,
since its WindowsApps folder cannot be listed. The resource assemblies
are loaded from their bytes: files of a package folder can be read but
not loaded as images.
process.GetParentProcess and GetChildrenPIDs read a Toolhelp32 process
snapshot instead of querying WMI. The WMI query took about 200 ms, ran on
a dedicated STA thread for each call and depended on the WMI service; a
snapshot takes a few milliseconds and needs no service. A snapshot that
cannot be taken throws, as a failed WMI query did, and is reported by the
API boundary or the global handler.

ProcessTree holds the snapshot and answers the parent and child queries
without native calls, so they are unit tested. ProcessInterop takes the
snapshot through CsWin32. PBIDesktopService takes one snapshot for all
the Power BI Desktop processes it lists. Child processes are still
limited to the session of their parent, now through Process.TryGetById,
which also skips those that have exited.

System.Management is no longer referenced.
A process records the identifier of its parent when it is created, and
Windows reuses identifiers once a process exits. GetParentProcess and
GetChildrenPIDs could therefore return an unrelated process that took
the identifier of an exited parent or child. A parent must now have
started before its child; a process whose start time cannot be read is
treated as not found.
Aligns the name with the .NET naming guidelines for acronyms and compound words. No behavior change.
InteropGetParent, the NtQueryInformationProcess lookup kept as obsolete,
ProcessHelper.GetParentProcess and GetParentProcessMainWindowHandle have
no callers once process.GetParentProcess reads the Toolhelp32 snapshot,
and AppEnvironment.ProcessId was read only by the WMI query. Ntdll goes
with InteropGetParent, its only user.
@albertospelta
albertospelta merged commit ed8cd96 into main Oct 20, 2026
1 check passed
@albertospelta
albertospelta deleted the refactor/secondary-payload-without-appenvironment branch October 20, 2026 15:51
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.

1 participant