[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[NativeAOT] Take over native linker invocation from ILC targets - #11148

Merged
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover
Apr 30, 2026
Merged

[NativeAOT] Take over native linker invocation from ILC targets#11148
jonathanpeppers merged 8 commits into
mainfrom
dev/sbomer/nativeaot-linker-takeover

Conversation

@sbomer

Copy link
Copy Markdown
Member

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs after LinkNative and links the ILC .o output into a .so using the NDK clang wrapper. This gives Android full control over the native linker invocation, following the same approach used by macios.

Reproduce the flags that LinkNative and SetupOSSpecificProps would have provided for NativeLib=Shared:

  • -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
  • --version-script, --export-dynamic, --discard-all, --gc-sections (from CustomLinkerArg inside LinkNative)
  • -fuse-ld=lld (from LinkerArg via LinkerFlavor)
  • sections.ld linker script to retain the __modules section

Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly] methods as native symbols, required for JNI entry points.

Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC targets bug where _LinkerVersion detection is skipped for NativeLib=Static but the numeric comparison in LinkNative still evaluates. Context: dotnet/runtime#126978

The resulting linker command line is identical to the original.

Contributes to #10697

Set NativeLib=static so ILC produces a .a archive via ar instead of invoking
the linker directly. Add _AndroidLinkNativeAotSharedLibrary target that runs
after LinkNative and links the ILC .o output into a .so using the NDK clang
wrapper. This gives Android full control over the native linker invocation,
following the same approach used by macios.
Reproduce the flags that LinkNative and SetupOSSpecificProps would have
provided for NativeLib=Shared:
- -shared, -Wl,-e,0x0, -Wl,-z,max-page-size=16384 (from LinkerArg)
- --version-script, --export-dynamic, --discard-all, --gc-sections (from
CustomLinkerArg inside LinkNative)
- -fuse-ld=lld (from LinkerArg via LinkerFlavor)
- sections.ld linker script to retain the __modules section
Set IlcExportUnmanagedEntrypoints=true so ILC exports [UnmanagedCallersOnly]
methods as native symbols, required for JNI entry points.
Clear LinkerFlavor inside _AndroidBeforeIlcCompile to work around an ILC
targets bug where _LinkerVersion detection is skipped for NativeLib=Static
but the numeric comparison in LinkNative still evaluates.
Context: dotnet/runtime#126978
The resulting linker command line is identical to the original.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 17, 2026 17:30

CopilotAI 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

This PR adjusts the NativeAOT MSBuild flow for .NET for Android so Android (NDK clang wrapper) performs the final shared-library link step instead of ILC, aligning linker invocation control with other platforms (e.g., macios).

Changes:

  • Set NativeLib=static so ILC produces a static archive rather than directly linking a .so.
  • Add _AndroidLinkNativeAotSharedLibrary to link ILC output into a .so using the NDK clang wrapper and restore required linker flags/scripts.
  • Ensure unmanaged entrypoints are exported and tweak linker-related properties/soname/publish naming to match Android expectations.

Add Inputs/Outputs to _AndroidLinkNativeAotSharedLibrary so incremental
builds can skip relinking when inputs haven't changed. Add FileWrites
for the .so and sections.ld so Clean can account for generated files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sbomersbomer self-assigned this Apr 17, 2026
Comment on lines +278 to +281
<Target Name="_AndroidLinkNativeAotSharedLibrary"
AfterTargets="LinkNative"
Inputs="$(NativeObject);@(NativeLibrary)"
Outputs="$(NativeOutputPath)$(NativeBinaryPrefix)$(TargetName).so">

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.

Do managed assemblies also need to be Inputs? Someone could add their own [UnmanagedCallersOnly] and it wouldn't get picked up in an incremental build.

Honestly, this is NativeAOT (Release-mode only), maybe we could just let this always run if LinkNative ran?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think if someone adds [UCO] it would cause ILC to re-run, produce a new $(NativeObject) and that would make this target run.

These are the same inputs/outputs as the LinkNative target so it should already be running if LinkNative ran. If we remove the Inputs/Outputs, it would always run even if LinkNative was skipped.

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actionsBot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actionsgithub-actionsBot 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.

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 errors, 0 warnings, 2 suggestions.

Analysis

The PR correctly transitions NativeAOT from delegating shared library linking to ILC's LinkNative target to having Android handle it directly via _AndroidLinkNativeAotSharedLibrary. I verified the approach against the ILC targets in dotnet/runtime:

  • @(NativeLibrary) items are correctly consumed — ILC's Microsoft.NETCore.Native.Unix.targets merges them into @(LinkerArg) (line 243 in the runtime), so @(LinkerArg) is the single source for all linker inputs. ✅
  • NativeLib=static + IlcExportUnmanagedEntrypoints=true correctly makes ILC produce a .a via ar while still exporting JNI entry points. ✅
  • LinkerFlavor clearing is a sound workaround for dotnet/runtime#126978 — with NativeLib=static, ILC skips _LinkerVersion detection but still evaluates a numeric comparison. ✅
  • Hardcoded .so replacing $(NativeBinaryExt) is correct — with NativeLib=static, $(NativeBinaryExt) would be .a, which is wrong for soname and file matching. ✅
  • Incremental build supportInputs/Outputs and FileWrites are properly set. The FileWrites items in the <ItemGroup> inside the target will evaluate even when the target is skipped. ✅
  • Linker flags reproduce what ILC's LinkNative + SetupOSSpecificProps would provide for NativeLib=Shared (verified against the runtime source). ✅

CI Status

  • 🟢 Linux: passed
  • 🔴 Windows: failed (unable to access Azure DevOps logs to confirm if related to this PR or pre-existing)
  • 🟡 macOS: in progress
  • ⏳ Overall: queued

Suggestions

  • 💡 Add a comment explaining that @(NativeLibrary) is consumed indirectly via @(LinkerArg) (Microsoft.Android.Sdk.NativeAOT.targets:299)
  • 💡 Document why sections.ld and --gc-sections are unconditional (NDK always uses LLD) (Microsoft.Android.Sdk.NativeAOT.targets:298)

👍 Well-structured change with excellent inline documentation. The approach of taking over the linker invocation (matching what macios does) gives Android full control over the native link step, which is the right architectural direction.


Review generated by android-reviewer from review guidelines.

Warning

⚠️ Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • dev.azure.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
allowed:
- defaults
- "dev.azure.com"

See Network Configuration for more information.

Generated by Android PR Reviewer for issue #11148 · ● 9.6M

sbomerand others added 6 commits April 17, 2026 17:00
On Windows, ILC's LinkNative writes linker args to a response file instead
of passing them inline — cmd.exe has quoting issues with spaces in paths.
Match that behavior in _AndroidLinkNativeAotSharedLibrary.
Fixes NativeAOT build failures for projects with spaces or special characters
in their names (e.g. CheckProjectWithSpaceInNameWorks tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target defaults CppLibCreator to the host 'ar', which
does not understand ELF objects when cross-compiling for Android. On
macOS this caused Xcode's ranlib to emit spurious 'empty table of
contents' warnings for every ABI.
Set CppLibCreator to llvm-ar (from the NDK toolchain, already on PATH)
so the archiver can correctly process ELF .o files.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ILC's LinkNative target strips symbols via llvm-objcopy after linking, but
those steps are skipped when NativeLib=Static. Add the same three objcopy
invocations to _AndroidLinkNativeAotSharedLibrary:
1. Extract debug info to .dbg file
2. Strip debug symbols from the .so
3. Add gnu-debuglink back to the .so
Without stripping, the .so was ~8MB larger than the original, causing
BuildReleaseArm64 apkdiff regression tests to fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers
jonathanpeppers merged commit 950bcad into mainApr 30, 2026
3 checks passed
@jonathanpeppers
jonathanpeppers deleted the dev/sbomer/nativeaot-linker-takeover branch April 30, 2026 19:27
sbomer added a commit that referenced this pull request May 13, 2026
Adds an opt-in `_AndroidUseWorkloadNativeLinker` property that allows NativeAOT builds to use the linker and sysroot files from the workload runtime pack instead of requiring the Android NDK. This is the foundation for removing the NDK dependency from NativeAOT builds (issue #10697).
### What changed
**Workload linker opt-in** (`Microsoft.Android.Sdk.NativeAOT.targets`)
- When `_AndroidUseWorkloadNativeLinker=true`, the NativeAOT link step resolves CRT objects, system lib stubs, and toolchain libraries from the runtime pack instead of the NDK.
- When false (default), behavior is unchanged — NDK paths are used as before.
**NativeAOT-specific redist directory** (`Step_Android_SDK_NDK.cs`)
- Copies a second set of CRT objects and system lib stubs from NDK API level 24 into `redist-nativeaot/` during `make prepare`.
- NativeAOT needs API 24+ stubs because dotnet/runtime's `libSystem.Native.a` references symbols introduced at that level (`__gnu_strerror_r`, `__write_chk`, `preadv`, `pwritev`, `stderr`).
- Mono/CoreCLR continue using the API 21 stubs from `redist/`.
**Runtime pack contents** (`Microsoft.Android.Runtime.proj`)
- NativeAOT pack now includes CRT objects (`crtbegin_so.o`, `crtend_so.o`) and system lib stubs from `redist-nativeaot/` (API 24).
- Added toolchain libraries (`libc++_static.a`, `libc++abi.a`, `libclang_rt.builtins`, `libunwind.a`) that were previously only in the CoreCLR pack.
**Tests** (`NativeAotBuildTests.cs`)
- `BuildNativeAot_WithoutNdk_Fails`: Verifies that NativeAOT builds fail without NDK when the workload linker is not enabled.
- `BuildNativeAot_WithWorkloadLinker_WithoutNdk`: Verifies that NativeAOT builds succeed with the workload linker and no NDK installed.
### Context
This is the third step in the NDK-free NativeAOT linking series:
1. ✅ #11148 — Take over native linker invocation from ILC (NativeLib=static)
2. ✅ #11256 — Replace clang with direct lld invocation via LinkNativeAotSharedLibrary task
3. **This PR** — Source all linker dependencies from the workload pack
The `_AndroidUseWorkloadNativeLinker` property is underscore-prefixed (internal) for now. A future PR will make it the default.
Contributes to #10697.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@sbomer@jonathanpeppers