Skip to content

[FastDev] Add faster FastDeploy2#11698

Open
simonrozsival wants to merge 10 commits into
dotnet:mainfrom
simonrozsival:android-fastdeploy2-clean
Open

[FastDev] Add faster FastDeploy2#11698
simonrozsival wants to merge 10 commits into
dotnet:mainfrom
simonrozsival:android-fastdeploy2-clean

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

This is the cleaned-up follow-up to the experimentation PR:

This PR keeps legacy FastDeploy in place, adds a new FastDeploy2 strategy, and makes FastDeploy2 the default for app install fast deployment.

$(_AndroidFastDevStrategy)=FastDeploy|FastDeploy2

FastDeploy remains selectable as a fallback.

Review follow-up

Jon's review feedback is addressed in the latest cleanup commit:

  • FastDeploy2 no longer accepts legacy FastDeploy task inputs it does not use.
  • FastDeploy2 is now a single concrete task with no FastDeploy2Base inheritance split.
  • Development-only FastDeploy2 diagnostic/telemetry JSON was removed, so production builds only keep the functional manifest state.
  • FastDeploy2 manifest JSON serialization uses a System.Text.Json source-generated context.
  • Repeated remote path, shell quoting, and directory grouping helpers are consolidated.
  • Remote staging now uses Android's writable /data/local/tmp/fastdeploy2 instead of /tmp/fastdeploy2, because /tmp is read-only on CI emulators.

A fresh dotnet new maui Android sample build binlog is available here:

FastDeploy2 approach

FastDeploy2 uses the final direction from the experiment PR:

  1. Build a local manifest of FastDev inputs:

    target path -> local source path, size, mtime
    
  2. Compare with the last successful deploy manifest.

  3. Push only changed files to device temp storage, without adb push --sync:

    adb push -z any changed-file-1 changed-file-2 /data/local/tmp/fastdeploy2/<package>/<user>/<abi>/
  4. Remove stale staged files for removed inputs.

  5. Maintain files/.__override__ as symlinks to staged files with batched run-as sh -c commands. The generated shell scripts use short d/s variables plus cd so long source/target directories are not repeated for every file.

  6. Write the manifest only after a successful deploy.

Why this shape is viable now

The existing FastDeploy v2.0 design was added when Android 11 broke the older external-storage based fast deployment model. At that time, adb compression and multi-file push/sync behavior were still new: Android SDK Platform-Tools 30.0.0 (April 2020) introduced client-side compression for adb {push,pull,sync} when used with Android 11 devices, and Platform-Tools 30.0.5 (November 2020) later fixed adb push --sync with multiple inputs and improved pushing many files over high-latency links.

Today it is more reasonable to depend on a modern Android SDK Platform-Tools package for developer inner-loop scenarios. adb is supplied by the Android SDK Platform-Tools package, and developers using .NET for Android / MAUI can update it independently of end-user device support. With Platform-Tools 36.0.0, adb push -z any was also tested against API 24 and API 29 emulators that did not advertise sendrecv_v2* compression features; both accepted the command and transferred the file, apparently negotiating down to uncompressed transfer.

This PR also no longer relies on adb push --sync: the local manifest tells us exactly which files changed, so FastDeploy2 pushes only those files and avoids an adb-side scan of the full directory.

What else we tried but abandoned

From the experiment PR:

  • Existing FastDeploy is excellent for very small changes but very slow for full payloads.
  • adb push --sync over the whole directory is much faster for full payloads, but still scans/probes too much for tiny changes.
  • One adb push per file is too slow because every adb invocation has ~40ms median overhead even when skipped.
  • A local manifest gives us the changed set without probing all files through adb.
  • Symlinks avoid app-private copy after the initial setup.
  • A shell-based symlink update was faster than a native helper (maui.link), so this PR does not include a native helper.

How legacy FastDeploy could be improved instead

If we wanted to keep the native FastDeploy tool model, the most promising improvement would be to make xamarin.sync batch-aware. Instead of invoking run-as ... xamarin.sync once per changed file, the host could open a single run-as <package> xamarin.sync-batch stdin stream and write all changed files one after another using a small binary protocol:

magic/version
file-count
repeat file-count times:
  target-path-length
  target-path
  uncompressed-size
  mtime
  compressed-blocks
removed-file-count
repeat removed-file-count times:
  target-path-length
  target-path

The device helper would create directories, write each file to a temporary path, set mtime/permissions, rename into place, and remove stale files. This would keep the existing app-private real-file model and avoid symlink assumptions, while removing most per-file adb/run-as process overhead.

That approach is intentionally not part of this PR because it keeps the custom native tool/protocol complexity. FastDeploy2 tries the simpler path first: use adb's built-in transfer/compression support, keep the changed-file decision local, and leave legacy FastDeploy available as a fallback while we validate the symlink approach across more devices.

Preliminary benchmark from the experiment PR

Device: Samsung Galaxy A16 (SM-A165F, R58Y30HZ65V)
Project: samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csproj

Strategy Case Deploy task Wall Upload/transfer Changed
FastDeploy (existing) first 22.11s 31.61s 19.09s 194
FastDeploy (existing) one DLL 0.68s 10.10s 0.14s 2
FastDeploy (existing) app DLLs 0.69s 10.10s 0.20s 3
FastDeploy (existing) all DLLs 17.72s 27.27s 17.16s 189
FastDeploy2 first 5.41s 14.51s 3.09s 195
FastDeploy2 one DLL 0.56s 9.70s 0.06s 2
FastDeploy2 app DLLs 0.59s 9.99s 0.04s 3
FastDeploy2 all DLLs 3.02s 12.40s 2.42s 189

Validation

Focused task builds pass in this branch:

dotnet build-server shutdown
 dotnet build build-tools/xa-prep-tasks/xa-prep-tasks.csproj --nologo -v:minimal -m:1
 dotnet build src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj --no-restore --nologo -v:minimal -m:1
 dotnet build build-tools/xa-prep-tasks/xa-prep-tasks.csproj -c Release --nologo -v:minimal -m:1
 dotnet build src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj -c Release --no-restore --nologo -v:minimal -m:1

Device/emulator validation passed:

Device Scenario Result
Samsung Galaxy A16 (SM-A165F, R58Y30HZ65V) clean first install with default FastDeploy2 symlink mode passed; app launched successfully
Samsung Galaxy A16 (SM-A165F, R58Y30HZ65V) one-DLL incremental install with default FastDeploy2 symlink mode passed; app launched successfully
Samsung Galaxy A16 (SM-A165F, R58Y30HZ65V) one-DLL incremental install with copy fallback mode passed; app launched successfully
API 36 emulator clean first install with shortened d/s shell-variable symlink scripts passed; symlink tree created successfully
API 36 emulator Symlink-mode install followed by Copy-mode install passed; symlinks were replaced by regular files and the symlink marker was removed

Observed validation details:

first default: deploy=15.84s, mode=Symlink, changed=195, pushed=195, symlink update=161ms
one DLL default: deploy=0.89s, mode=Symlink, changed=2, pushed=2, symlink update=4ms
one DLL Copy fallback: deploy=2.80s, mode=Copy, changed=2, pushed=2, copy=977ms

Additional adb compatibility probes with Platform-Tools 36.0.0:

Target Device adb features adb push -z any
API 24 emulator cmd only, no sendrecv_v2* passed
API 29 emulator cmd only, no sendrecv_v2* passed
API 36 emulator sendrecv_v2, brotli, lz4, zstd passed

Risks

  • Legacy FastDeploy is still present and selectable.
  • Symlinks from app-private files/.__override__ to /data/local/tmp/fastdeploy2/... worked on the tested Samsung device.
  • The new approach has only been tested on a single device from a single vendor so far; it needs broader Android/OEM/API/device testing.
  • If symlink creation fails, this code can fallback to copy mode and clears symlink-managed override state before copying.
  • If the local manifest is missing or the remote ready marker is missing, FastDeploy2 does a full push.
  • Developers with very old Android SDK Platform-Tools may need to update adb for adb push -z any; older devices can still fall back to uncompressed transfer when driven by a modern adb.

Future work

  • Delete the old FastDeploy implementation once we are confident the new approach is better across a broader device matrix.
  • Delete the old native FastDeploy helper tools once FastDeploy no longer needs them.
  • If symlink validation across devices is not good enough, consider a batched xamarin.sync protocol as the app-private real-file fallback design.

Add a new FastDeploy2 strategy that uses a local manifest to push only changed files to temporary device storage and mirrors the app override directory with shell-created symlinks. Keep legacy FastDeploy selectable while making FastDeploy2 the default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove existing override paths before copying changed files so Copy mode can safely recover from a symlink-based override tree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival marked this pull request as ready for review June 18, 2026 10:30
Copilot AI review requested due to automatic review settings June 18, 2026 10:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a new manifest-driven fast deployment implementation (FastDeploy2) alongside the legacy FastDeploy, and makes FastDeploy2 the default strategy for app install fast deployment.

Changes:

  • Register and invoke the new FastDeploy2 MSBuild task, with _AndroidFastDevStrategy defaulting to FastDeploy2.
  • Add FastDeploy2Base (core orchestration + adb/run-as helpers) plus a manifest-based FastDeploy2 implementation that pushes only changed files and updates overrides via symlink (with copy fallback).
  • Add MSBuild-time validation for supported strategy/transfer-mode values, and a new adb compression algorithm property default (any).
Show a summary per file
File Description
src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Common.Debugging.targets Registers FastDeploy2, selects strategy, sets defaults for strategy/transfer mode/compression, and wires task invocation into _Upload.
src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Adds FastDeploy2Base task implementation: device/package orchestration, adb invocation, file staging/stat/diff/copy paths, diagnostics logging, and error handling.
src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs Implements manifest-driven incremental upload, remote “ready” marker handling, stale cleanup, and shell-symlink override updates with copy fallback.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 8

Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs Outdated
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Outdated
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Outdated
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Outdated
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Outdated
Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs Outdated
@simonrozsival simonrozsival changed the title [WIP] Add manifest-driven FastDeploy2 [FastDev] Add manifest-driven FastDeploy2 Jun 18, 2026
simonrozsival and others added 4 commits June 18, 2026 13:01
Fix manifest reset handling, device-scoped manifest state, adb batching edge cases, symlink/copy recovery, and diagnostics logging concurrency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use shell variables and cd to avoid repeating long staging and override paths in generated run-as symlink scripts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop leftover experimental staging and symlink helper methods that are no longer referenced by the manifest-driven direct-push implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Skip missing FastDev inputs before manifest creation and clear symlink-managed override state before Copy mode so stale symlinks cannot survive mode switches or fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival changed the title [FastDev] Add manifest-driven FastDeploy2 [FastDev] Add faster FastDeploy2 Jun 18, 2026
@simonrozsival

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

Android PR Reviewer completed successfully!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Needs Changes

Thorough review of the new FastDeploy2 implementation. The architecture is well thought out — manifest-based incremental diffing, a clean strategy/mode split in the targets with condition-first <Error> validation, careful shell-argument quoting (ShellQuote single-quote escaping) and package-name sanitization (IsSafePackageNameForShell) to prevent shell injection, ADB cancellation handling with process kill + output draining, and reuse of the existing XA012x/XA013x error codes. Nice work.

A few things to address before merge:

Issues by severity

  • ⚠️ 2 warnings
    • Order-dependent shell globbing (FastDeploy2.Manifest.cs:132): the full-refresh rm -f ./* / ln -sf "$s"/* . globs also match subdirectories such as the staged {abi}/environment. Because directories is iterated in nondeterministic HashSet order, this can either delete real staged files through a directory symlink or silently fall back to copy. Please handle nested directories explicitly.
    • Dead/duplicated base implementation (FastDeploy2.cs:549): the virtual DeployFastDevFilesWithAdbPush and its exclusive helpers are fully superseded by the override and never run — including a duplicated CreatePushArgsPrefix that still uses --sync. Recommend deleting it (or making the method abstract).
  • 💡 2 suggestions
    • MarkRemoteReady ignores the adb exit code (FastDeploy2.Manifest.cs:447).
    • ManifestEntry.LocalPath is persisted but never read (FastDeploy2.Manifest.cs:496).

See the inline comments for mechanisms and concrete fixes.

CI: license/cla passed; the Azure DevOps builds (Linux/Mac/Windows) are still in progress at review time, so green CI is not yet confirmed — please ensure the internal pipeline passes before merge.

The symlink-glob behavior is the main correctness concern; the rest are maintainability cleanups. I couldn't reproduce on-device, so if I've misread the staging layout for the glob case, a quick confirmation of how {abi}/environment is handled during a full refresh would resolve it.

Generated by Android PR Reviewer for issue #11698 · 1.4K AIC · ⌖ 48.9 AIC · ⊞ 37.8K
Comment /review to run again


async Task MarkRemoteReady (string remoteStagingPath)
{
await RunAdbCommand ("shell", "touch", $"{remoteStagingPath}/{RemoteReadyMarker}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Error handlingMarkRemoteReady discards the RunAdbCommand result, so a failed touch of the ready marker goes unnoticed. Every other adb call in this file checks result.ExitCode (e.g. line 305). If the marker isn't written, IsRemoteReady returns false next time and the deploy silently degrades to a full push — correct, but a hidden perf cliff. Consider checking the exit code and logging a diagnostic on failure.

{Rule: Check command exit codes consistently}

LogDebugMessage ($"{PackageName} Terminated.");
}

protected virtual async Task<bool> DeployFastDevFilesWithAdbPush (string overridePath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 ⚠️ Code organizationFastDeploy2Base.DeployFastDevFilesWithAdbPush is virtual and fully overridden by FastDeploy2 (in FastDeploy2.Manifest.cs), which never calls base.. Since FastDeploy2Base is abstract and FastDeploy2 is its only subclass, this entire base implementation is unreachable — along with the helpers only it reaches: RemoveStaleRemoteStagingFiles (line 729), UploadFiles (947), BatchPushFiles (972), CreatePushArgs (999), CreatePushArgsPrefix (1007), EstimateCommandLength (1018), and SetAdbPushFileCounts (1042, which has no callers at all).

Three of these (CreatePushArgs / CreatePushArgsPrefix / EstimateCommandLength) are also duplicated in the live override, and the dead base CreatePushArgsPrefix still appends --sync — the exact behavior the PR description says FastDeploy2 no longer relies on. Recommend deleting the dead base implementation (or making the method abstract) so only the shipped manifest-based path remains, to avoid future readers maintaining or copying the abandoned --sync variant.

{Rule: Ship only what's needed (YAGNI / dead code)}

public string RelativePath { get; set; }

[JsonPropertyName ("localPath")]
public string LocalPath { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Code organizationManifestEntry.LocalPath is written in CreateManifest (line 255) and serialized to manifest.json, but never read back — GetChangedFiles only compares Size and LastWriteTimeUtcTicks, and the key is RelativePath. It bloats the on-disk manifest and persists machine-specific absolute paths to disk. Consider dropping the field and its localPath JSON property.

{Rule: Ship only what's needed (YAGNI / dead code)}

string sourceDirectory = string.IsNullOrEmpty (directory) ? remoteStagingPath : $"{remoteStagingPath}/{directory}";

if (currentInDirectory.Count > 0 && (previousManifest == null || newInDirectory.Count == currentInDirectory.Count)) {
string script = $"d={ShellQuote (targetDirectory)};s={ShellQuote (sourceDirectory)};mkdir -p \"$d\"&&cd \"$d\"&&rm -f ./*&&ln -sf \"$s\"/* .";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 ⚠️ Patterns — In the full-refresh branch, rm -f ./* and ln -sf "$s"/* . glob everything in the directory, including subdirectories. The staged tree does contain subdirectories — the environment file is staged as {PrimaryCpuAbi}/environment (FastDeploy2.cs line 648) — so directories (line 119) holds both "" and "{abi}", iterated in nondeterministic HashSet<string> order.

  • If root "" is processed first, ln -sf "$s"/* . creates a symlink {abi} -> staging/{abi} in the override dir; the later {abi} iteration then cds through that symlink and rm -f ./* deletes the real staged files under staging.
  • In the opposite order, rm -f ./* hits the real {abi} subdirectory and errors with Is a directory, tripping IsShellError (output, "rm") → silent fallback to copy.

Either way the result depends on iteration order. Consider excluding child directories from the root-level glob, or routing any directory that has nested subdirectories through the explicit per-file CreateShellSymlinkScripts path (which lists filenames rather than *).

{Rule: Avoid order-dependent shell globbing}

@jonathanpeppers jonathanpeppers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think I want to see a .binlog of a deploy to understand what it does -- I might have time to manually test some later.

Overall, I like what it does -- introduce a private property by default you can opt out of if something goes wrong.

Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.cs Outdated
Comment on lines +478 to +489
static string GetSafeFileName (string value)
{
if (string.IsNullOrEmpty (value)) {
return "_";
}

var builder = new StringBuilder (value.Length);
foreach (char c in value) {
builder.Append (char.IsLetterOrDigit (c) || c == '.' || c == '-' || c == '_' ? c : '_');
}
return builder.ToString ();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there an existing helper somewhere that does this? I can think of maybe things related to Java names, or Android resources.

Comment thread src/Xamarin.Android.Build.Debugging.Tasks/Tasks/FastDeploy2.Manifest.cs Outdated
@simonrozsival

Copy link
Copy Markdown
Member Author

@jonathanpeppers I captured a binlog from building a fresh dotnet new maui sample app with this branch's local SDK:

https://gist.github.com/simonrozsival/c06cacff00c99ee3504c256205e24492

Command used for the successful binlog:

./dotnet-local.sh build JonMauiBinlog/JonMauiBinlog.csproj \
  -f net11.0-android \
  -p:TargetFrameworks=net11.0-android \
  -p:SupportedOSPlatformVersion=24.0 \
  -bl:JonMauiBinlog-net11.0-android.binlog

The TFM override keeps the template Android-only because I only installed maui-android locally; the supported OS override matches the local Android SDK minimum of 24.

simonrozsival and others added 2 commits June 18, 2026 16:22
Move FastDeploy2 diagnostic JSON helpers out of the main task, use System.Text.Json source generation for FastDeploy2 JSON, remove unused FastDeploy2 task inputs, and consolidate repeated path/grouping helpers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Flatten FastDeploy2 into a single concrete task, remove the development diagnostics property bag and JSON payload, and keep only the functional manifest serialization state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers

Copy link
Copy Markdown
Member

@jonathanpeppers I captured a binlog from building a fresh dotnet new maui sample app with this branch's local SDK:

https://gist.github.com/simonrozsival/c06cacff00c99ee3504c256205e24492

Command used for the successful binlog:

./dotnet-local.sh build JonMauiBinlog/JonMauiBinlog.csproj \
  -f net11.0-android \
  -p:TargetFrameworks=net11.0-android \
  -p:SupportedOSPlatformVersion=24.0 \
  -bl:JonMauiBinlog-net11.0-android.binlog

The TFM override keeps the template Android-only because I only installed maui-android locally; the supported OS override matches the local Android SDK minimum of 24.

I think your command needs -t:Install to see the FastDeploy2 task run.

@simonrozsival

Copy link
Copy Markdown
Member Author

@jonathanpeppers you're right — the earlier binlog was build-only. I updated the same gist with a new -t:Install binlog that includes the FastDeploy2 task:

https://gist.github.com/simonrozsival/c06cacff00c99ee3504c256205e24492

New file:

JonMauiBinlog-net11.0-android-install-fastdeploy2.binlog

Command used:

./dotnet-local.sh build JonMauiBinlog/JonMauiBinlog.csproj \
  -t:Install \
  -f net11.0-android \
  --no-restore \
  -m:1 \
  -nr:false \
  -p:TargetFrameworks=net11.0-android \
  -p:SupportedOSPlatformVersion=24.0 \
  -p:AdbTarget=-s%20R58Y30HZ65V \
  -p:_FastDeploymentDiagnosticLogging=False \
  -bl:JonMauiBinlog-net11.0-android-install-fastdeploy2.binlog \
  -clp:PerformanceSummary \
  -v:minimal

The console log shows FastDeploy2 ran and the build succeeded:

11089 ms  FastDeploy2  1 calls
Build succeeded.

Avoid logging empty run-as command output and buffer optional missing-file messages behind FastDeploy2 diagnostics so normal install binlogs stay readable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jonathanpeppers

Copy link
Copy Markdown
Member

There are many log messages that just say rm returned:

image

I think we should probably skip these messages in general unless _FastDeploymentDiagnosticLogging=true. And maybe it should not log if blank?

But then I probably need to see a log where _FastDeploymentDiagnosticLogging is true.

Stage FastDeploy2 files under /data/local/tmp instead of /tmp so Android emulators with read-only /tmp can install. Also remove existing override contents recursively before full symlink refreshes so resource/culture directories do not fail rm.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants