[FastDev] Add faster FastDeploy2#11698
Conversation
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>
There was a problem hiding this comment.
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
FastDeploy2MSBuild task, with_AndroidFastDevStrategydefaulting toFastDeploy2. - Add
FastDeploy2Base(core orchestration + adb/run-as helpers) plus a manifest-basedFastDeploy2implementation 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
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>
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
⚠️ 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-refreshrm -f ./*/ln -sf "$s"/* .globs also match subdirectories such as the staged{abi}/environment. Becausedirectoriesis iterated in nondeterministicHashSetorder, 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): thevirtual DeployFastDevFilesWithAdbPushand its exclusive helpers are fully superseded by the override and never run — including a duplicatedCreatePushArgsPrefixthat still uses--sync. Recommend deleting it (or making the methodabstract).
- Order-dependent shell globbing (
- 💡 2 suggestions
MarkRemoteReadyignores the adb exit code (FastDeploy2.Manifest.cs:447).ManifestEntry.LocalPathis 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
jonathanpeppers
left a comment
There was a problem hiding this comment.
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.
| 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 (); | ||
| } |
There was a problem hiding this comment.
Is there an existing helper somewhere that does this? I can think of maybe things related to Java names, or Android resources.
There was a problem hiding this comment.
I looked and couldn't find an existing helper for turning an arbitrary string into a filesystem-safe path segment. The Java-name and Android-resource helpers (identifier mangling / resource-designer naming) solve a different problem with different rules — they're about valid C#/Java identifiers, not reversible, collision-free directory names. Here we just need a unique cache directory per device/package/user/abi, which is what Uri.EscapeDataString gives (reversible, no path separators). Happy to switch to a shared helper if you have one in mind — leaving this thread open for your call.
|
@jonathanpeppers I captured a binlog from building a fresh 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.binlogThe TFM override keeps the template Android-only because I only installed |
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>
I think your command needs |
|
@jonathanpeppers you're right — the earlier binlog was build-only. I updated the same gist with a new https://gist.github.com/simonrozsival/c06cacff00c99ee3504c256205e24492 New file: 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:minimalThe console log shows |
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>
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>
|
@jonathanpeppers to clarify the physical-device validation: that was a Samsung Galaxy A16 ( The emulator validation rows in the PR description are separate API 24/29/36 emulator checks. |
Add target identity to FastDeploy2 manifests and store the manifest hash on both the remote staging tree and the app override tree. Read both device-side hashes in one adb shell command before deciding whether incremental deploy state can be trusted, and expand adb diagnostics to include exact commands, exit codes, stdout, and stderr. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
FastDeploy2 (the new default fast-deployment strategy) broke several
existing on-device tests. Three deterministic, device-verified root causes:
- Missing sync log markers: tests assert `NotifySync CopyFile`/
`NotifySync SkipCopyFile` per file. FastDeploy2 only logged
`Prepared X => Y`, so IncrementalFastDeployment and
SkipFastDevAlreadyInstalledFile failed even though deployment
succeeded. Emit the markers from the manifest's changed-file set.
- Symlink mode followed subdirectory symlinks into staging: the full
rewrite used `ln -sf "$s"/* .`, which also symlinked staging
subdirectories. Processing a child directory then `cd`'d through that
symlink into the shell-owned /data/local/tmp staging area and
`rm -rf ./*` failed with "Permission denied" under run-as (XA0133 /
XA0129). This broke any app with culture/satellite subdirectories
(LocalizedAssemblies_ShouldBeFastDeployed, DotNetNewAndroidTest). Only
symlink regular files, and process parent directories before children
so a parent's `rm -rf ./*` cannot delete a freshly created child.
- Copy fallback's stat format was split by the device shell: the
`find ... -exec stat -c "%n|%s|%Y" {} +` argv reached the device shell
unquoted, so `|` was treated as a pipe (exit 127, XA0129). Build the
command as a single string with the format single-quoted, matching the
RunAsShell quoting pattern.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Self-review follow-up. The previous fix replaced `ln -sf "$s"/* .` with a files-only loop but still ran `rm -rf ./*`, which deletes child directories. The old glob accidentally masked an incremental-deploy bug by symlinking whole staging subdirectories: on an incremental deploy where a parent directory gains all-new files while a child directory keeps unchanged files, the parent's `rm -rf ./*` wiped the child's still-valid symlinks. Clear only the regular files in each directory (`for e in ./*; do [ -d "$e" ] || rm -f "$e"; done`) so subdirectories and their contents survive, and drop the now-unnecessary parents-first ordering. Verified on an emulator: the parent relinks its files, the child subdirectory and its symlink are preserved, and the staging area is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Self-review follow-up. The abi-skip `NotifySync SkipCopyFile` marker logged the local build path (file.ItemSpec) while the per-file sync markers log the device-relative target path, making the NotifySync marker family inconsistent within the task. Use GetAdbPushTargetPath for the abi-skip marker too so all markers report the same device-relative identifier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The device-state readiness check built its probe with `printf '\noverride='` / `printf '\n'`. The `\n` escape does not survive the adb + device-shell quoting layers and arrives as a literal two-character "\n", so the device emits a single line: remote=<hash>\noverride=<hash>\n ParseDeviceManifestState splits on real newlines, so the whole tail lands in RemoteHash and OverrideHash stays empty. RemoteHash never equals the previous manifest hash, `remoteReady` is always false, and every incremental install resets the staging directory and redeploys *all* files (observed as `changed files: 329` in CI), so unchanged assemblies are reported as NotifySync CopyFile instead of SkipCopyFile. Build the probe with `echo` + command substitution instead, which emits real newlines and contains no backslash escapes to be mangled. Verified on an API 36 emulator: the device now returns two parseable lines and `remoteReady` becomes true on an unchanged reinstall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ManifestEntry.LocalPath was written into manifest.json and folded into the device-side manifest hash, but it is host-specific (an absolute build path) and nothing reads it for change detection (GetChangedFiles compares Size and LastWriteTimeUtcTicks; the dictionary key is RelativePath). Persisting and hashing it bloats the manifest and couples the device readiness check to the build machine's paths for no benefit. Remove the field, its JSON property, and its contribution to the canonical manifest text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove unused TryParsePushSummary/AdbPushSummaryRegex dead code. - Make DeployFastDevFilesWithAdbPush failure explicit at the call site. - Document new FastDeploy2 strategy/transfer-mode/compression knobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
✅ LGTM — Re-reviewed after the latest commits. All three suggestions from the prior pass are addressed: dead |
|
Superseded by #11795, which is based on a branch in dotnet/android (origin) instead of a fork. Continuing review there. |
## Summary This is the cleaned-up follow-up to the experimentation PR: - Experiment PR: #11692 This PR keeps legacy `FastDeploy` in place, adds a new `FastDeploy2` strategy, and makes `FastDeploy2` the default for app install fast deployment. ```xml $(_AndroidFastDevStrategy)=FastDeploy|FastDeploy2 ``` `FastDeploy` remains selectable as a fallback. The final incremental FastDeploy2 hot path uses three serial `adb` processes: 1. one compound live state probe plus app force-stop; 2. one batched push for the changed files; and 3. one combined staging/override manifest finalization. Switching between FastDeploy2 and legacy FastDeploy is supported without uninstalling the app, including after `dotnet clean`. If the marker-based fast path discovers that the remote staging filesystem is incomplete, it retries once with a fresh staging and override deployment. ## Review follow-up Jonathan's review feedback is addressed: - 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. - The local manifest now records device/package/user/ABI identity, and device-side staging/override state is validated with manifest hash markers read in a single adb shell call. - FastDeploy2 diagnostic logging now records exact adb commands, exit codes, stdout, and stderr for device-state sanity checks. ### Latest review round (commits `b54be792e`, `da0bfe45c`) Jonathan's final review identified two remaining gaps: roughly 500 ms of FastDeploy2 overhead on a one-assembly incremental deployment, and failure when switching a symlink-managed FastDeploy2 app back to legacy FastDeploy. - **Warm state and app termination are one live probe.** On an eligible incremental deployment, one tagged `adb shell` command reads `log.redirect-stdio`, `ro.boot.disable_runas`, the staging/override markers, the app-private path and process id, validates `run-as`, and force-stops the app. If that probe is incomplete, FastDeploy2 falls back to the individual checks so the existing `XA0128` and `XA0131`–`XA0137` diagnostics remain actionable. - **The returned marker state is reused.** FastDeploy2 no longer repeats the device-marker probe later in the task. - **Redundant commands were removed.** An existing-directory DLL update skips `mkdir`, and staging/override markers are finalized together. - **Strategy switching is configuration-aware.** The effective strategy and transfer mode are part of the deployment configuration cache. Legacy FastDeploy removes the FastDeploy2 override tree before `xamarin.sync` runs; a missing cache is treated as changed, so this also works after `dotnet clean`. - **Unexpected remote state has a bounded recovery path.** The marker match remains the fast-path filesystem contract. If `adb push` proves an expected staging path is missing or has the wrong type, FastDeploy2 clears staging and override state and retries once as a full deployment. Success rewrites both remote markers and the local manifest; unrelated adb failures still surface normally. - Added focused parser/directory/error-classification host tests and API 36 device regressions for `FastDeploy2 → Clean → FastDeploy → FastDeploy2` and partial staging-directory deletion. ### Earlier review round (commit `ccc45ff6a`) A second round of review comments was addressed: - **Leaked `ManualResetEvent` handles.** `RunAdbCommand` created stdout/stderr completion events but never disposed them, leaking OS handles across the many adb invocations in a build. They are now `using var`. - **Magic-number batching constants are now overridable.** `StaleFileRemovalBatchSize`, `CopyBatchSize`, `MaxShellCommandLength`, and `MaxAdbCommandLength` became `FastDeploy2` task properties (same defaults), wired through `Xamarin.Android.Common.Debugging.targets` as internal `_AndroidFastDeploy*` properties — e.g. `-p:_AndroidFastDeployCopyBatchSize=100` to exercise batching while testing. - **FastDeploy2 no longer depends on `Mono.AndroidTools`/`Xamarin.AndroidTools`.** All device operations were reimplemented directly on top of `adb` in a new `FastDeploy2.Adb.cs`: `getprop` (properties), `pm uninstall`/`adb install -r -d` (with output-based retry + `ADB####` error classification), `pidof`, and `am force-stop`. `ProcessArgumentBuilder` was replaced by a local quoting helper. The assemblies themselves are intentionally **not** deleted in this PR (legacy `FastDeploy` and `AndroidHelper.ParseTarget` still use them) — that is tracked as separate future work. - **Documentation.** Added [`Documentation/guides/FastDeploy2.md`](https://github.com/dotnet/android/blob/ccc45ff6a58f4b49eef2a2439fb94341378816d2/Documentation/guides/FastDeploy2.md), a design doc describing each deployment stage and the exact `adb` commands it runs, linked from release note `11795.md`. **Device validation of the rewritten install path.** The `Mono.AndroidTools`-free install/uninstall/`getprop`/`pidof`/`force-stop` code path was validated on a physical Samsung Galaxy A16 (`SM-A165F`, `R58Y30HZ65V`) over USB with the `samples/HelloWorld` Debug app (every adb command returned exit code 0, 0 build warnings): | Scenario | Result | Exercised | |---|---|---| | Debug fresh install | app launched, no crash | `getprop`, `run-as pidof`, `adb install -d`, `am force-stop`, manifest-hash probe, `push -z any`, override symlink setup | | Incremental (one-DLL source change) | only the changed files pushed (rest `SkipCopyFile`), app launched | manifest diff + selective `push -z`, override symlinks | | ReInstall (`-p:_ReInstall=true`) | app launched | `pm uninstall -k`, then `adb install -r -d` | Confirmed on-device that the override symlinks point into `/data/local/tmp/fastdeploy2/<package>/<user>/<abi>/`, the staging and override `.fastdeploy2-manifest-hash` markers match, and that a modern `adb install` (without `-r`) replaces an already-installed package without hitting the `INSTALL_FAILED_ALREADY_EXISTS` retry. The signature-mismatch / `RequiresUninstall` retry branch was not force-triggered on a single device, but its building blocks (`pm uninstall` + `adb install -r`) are both exercised by the ReInstall scenario. A fresh `dotnet new maui` Android sample build binlog is available here: - https://gist.github.com/simonrozsival/c06cacff00c99ee3504c256205e24492 ## CI on-device test fixes The first full CI run surfaced regressions in existing fast-deployment device tests now that `FastDeploy2` is the default. Three deterministic root causes were found (each reproduced and verified on an API 36 emulator) and fixed: - **Missing sync log markers.** `IncrementalFastDeployment` and `SkipFastDevAlreadyInstalledFile` assert per-file `NotifySync CopyFile` / `NotifySync SkipCopyFile` markers. FastDeploy2 only logged `Prepared X => Y`, so these tests failed even though deployment succeeded. FastDeploy2 now emits the markers from the manifest's changed-file set, and the abi-skip marker uses the same device-relative identifier for consistency. - **Symlink mode followed subdirectory symlinks into staging.** The full rewrite used `ln -sf "$s"/* .`, which also symlinked staging subdirectories. Processing a child directory then `cd`'d through that symlink into the shell-owned `/data/local/tmp` staging area, and `rm -rf ./*` failed with "Permission denied" under `run-as` (`XA0133` / `XA0129`). This broke any app with culture/satellite subdirectories (`LocalizedAssemblies_ShouldBeFastDeployed`, `DotNetNewAndroidTest`). The full rewrite now clears and symlinks only the regular files in each directory and leaves subdirectories to their own iterations, so it no longer follows or deletes child directories. - **Copy-fallback `stat` format split by the device shell.** `find ... -exec stat -c "%n|%s|%Y" {} +` reached the device shell unquoted, so `|` was treated as a pipe (exit 127 → `XA0129`) and the copy fallback also failed. The command is now built as a single string with the format single-quoted (matching the `RunAsShell` quoting pattern). - **Device-state probe `printf '\n'` arrived as a literal `\n`.** The readiness check built its probe with `printf '\noverride='` / `printf '\n'`; the backslash escape does not survive the adb + device-shell quoting layers, so the device emitted `remote=<hash>\noverride=<hash>\n` on a single line. `ParseDeviceManifestState` (splitting on real newlines) put the whole tail into `RemoteHash` and left `OverrideHash` empty, so `remoteReady` was **always false** and every incremental install reset the staging directory and redeployed **all** files (`changed files: 329` in CI), reporting unchanged assemblies as `CopyFile`. The probe is now built with `echo` + command substitution (real newlines, no backslash escapes); verified on an API 36 emulator that `remoteReady` becomes true on an unchanged reinstall. ## FastDeploy2 approach FastDeploy2 uses the final direction from the experiment PR: 1. Build a local manifest of the deployment target and FastDev inputs: ```text device id, package, Android user, ABI target path -> local source path, size, mtime ``` 2. Compare with the last successful deploy manifest. On a warm deployment, one live probe validates compatibility properties, `run-as`, package/process state, and the device-side staging/override manifest markers, then stops the app. 3. Push only changed files to device temp storage, without `adb push --sync`: ```bash 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. After a successful deploy, write the matching device-side manifest hash markers in one command, then write the local manifest: ```text /data/local/tmp/fastdeploy2/<package>/<user>/.fastdeploy2-manifest-hash files/.__override__/.fastdeploy2-manifest-hash ``` 7. If an incremental push reports that an expected staging path is missing or has the wrong type, clear the per-package/user staging and override trees and retry once as a full deployment. ## 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: ```text 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. ## Final one-assembly head-to-head benchmark The final review scenario was measured on the same local API 36 arm64 emulator with two identical `dotnet new android` apps using separate package, `obj`, and device state. Each timed sample: - started with the corresponding app running; - changed one fixed-width source value that changed only the app assembly; and - measured the complete `dotnet build -t:Install -c Debug --no-restore` wall clock plus target/task durations from a compact binlog. Each implementation state used 5 warmups plus **30 measured samples per strategy**, interleaved in alternating `ABBA` / `BAAB` blocks. All 120 measured samples were accepted with no retries or rejections. Positive paired values mean FastDeploy2 was slower. | Paired FastDeploy2 − FastDeploy median | Before final review fixes | After | |---|---:|---:| | Deploy task | **+204.2 ms** | **−33.3 ms** | | `_Upload` target | **+201.6 ms** | **−7.3 ms** | | Full `dotnet build` wall time | +8.3 ms | +225.1 ms | The paired deploy-task comparison improved by **237.5 ms at the median** and reversed from FastDeploy2 being slower to slightly faster than legacy FastDeploy. `_Upload` similarly reached parity. Whole-build wall time remained very noisy (the paired interquartile range crossed zero in both matrices), so these samples do not demonstrate an end-to-end CLI wall-clock improvement outside the deployment target itself. The measured one-assembly FastDeploy2 hot path dropped from nine serial `adb` processes to three: compound probe/stop, batched push, and combined marker finalization. ## Historical benchmark from the experiment PR Device: physical Samsung Galaxy A16 (`SM-A165F`, `R58Y30HZ65V`) connected by USB 3 cable 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: ```bash ./dotnet-local.sh build \ src/Xamarin.Android.Build.Debugging.Tasks/Xamarin.Android.Build.Debugging.Tasks.csproj \ -c Release --no-restore --nologo -v:minimal -m:1 ./dotnet-local.sh test \ bin/TestRelease/net10.0/Xamarin.Android.Build.Tests.dll \ --filter 'Name~FastDeploy2' ``` The focused FastDeploy2 host suite passes 8/8 tests. Existing `SkipFastDevAlreadyInstalledFile` and APK `IncrementalFastDeployment` device tests also pass with the locally built SDK. Device/emulator validation passed: | Device | Scenario | Result | |---|---|---:| | Physical Samsung Galaxy A16 (`SM-A165F`, `R58Y30HZ65V`) over USB 3 cable | clean first install with default FastDeploy2 symlink mode | passed; app launched successfully | | Physical Samsung Galaxy A16 (`SM-A165F`, `R58Y30HZ65V`) over USB 3 cable | one-DLL incremental install with default FastDeploy2 symlink mode | passed; app launched successfully | | Physical Samsung Galaxy A16 (`SM-A165F`, `R58Y30HZ65V`) over USB 3 cable | 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 | | API 36 emulator | FastDeploy2 → Clean → FastDeploy → FastDeploy2 | passed; symlinks/regular files transitioned correctly and unrelated app data was preserved | | API 36 emulator | delete one staging ABI directory while retaining the root marker | passed; incremental push detected the invalid state, performed one fresh deployment, and restored symlinks/files/markers | Observed validation details: ```text 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 physical Samsung device over USB. - Physical-device symlink coverage is currently limited to one Samsung model; broader Android/OEM/API/device testing is still needed. - 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, does not match the current device/package/user/ABI, or does not match the device-side manifest hash marker, FastDeploy2 does a full push. - The fast path intentionally trusts matching markers instead of probing every expected directory. A push that proves the staging layout is incomplete triggers one bounded fresh deployment. - Staging is isolated by package name and Android user: `/data/local/tmp/fastdeploy2/<package>/<user>`. Different apps do not conflict; two projects with the same `ApplicationId` on the same device/user intentionally share one installed app and staging tree. - 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 - Consider separate content and topology/generation markers so a content-only DLL update can avoid the final marker `adb` process while retaining safe partial-deployment recovery. - If two adb processes are still too expensive, revisit a small batched transfer/commit helper or `xamarin.sync` protocol that uploads all changes and commits marker state atomically in one session. - Measure Visual Studio separately and consider `RegisterTaskObject` caching for IDE-only state; it does not help separate CLI invocations and must not be required for correctness. - Validate the warm probe, symlink behavior, strategy transitions, and fresh recovery across more API levels and OEM devices. - 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 (`xamarin.sync`, `xamarin.find`, `xamarin.stat`, `xamarin.cp` under `tools/fastdev/`) once `FastDeploy` no longer needs them. These are the only consumers of the `termux-elf-cleaner` external submodule (used to strip unsupported ELF entries so the Android linker does not warn when loading these device-side binaries), so removing them also lets us drop the `elfcleaner` build step and the `termux-elf-cleaner` submodule entirely. --- Replaces #11698 (moved off fork branch to origin). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Summary
This is the cleaned-up follow-up to the experimentation PR:
This PR keeps legacy
FastDeployin place, adds a newFastDeploy2strategy, and makesFastDeploy2the default for app install fast deployment.FastDeployremains selectable as a fallback.Review follow-up
Jon's review feedback is addressed in the latest cleanup commit:
FastDeploy2Baseinheritance split.System.Text.Jsonsource-generated context./data/local/tmp/fastdeploy2instead of/tmp/fastdeploy2, because/tmpis read-only on CI emulators.A fresh
dotnet new mauiAndroid sample build binlog is available here:CI on-device test fixes
The first full CI run surfaced regressions in existing fast-deployment device tests now that
FastDeploy2is the default. Three deterministic root causes were found (each reproduced and verified on an API 36 emulator) and fixed:IncrementalFastDeploymentandSkipFastDevAlreadyInstalledFileassert per-fileNotifySync CopyFile/NotifySync SkipCopyFilemarkers. FastDeploy2 only loggedPrepared X => Y, so these tests failed even though deployment succeeded. FastDeploy2 now emits the markers from the manifest's changed-file set, and the abi-skip marker uses the same device-relative identifier for consistency.ln -sf "$s"/* ., which also symlinked staging subdirectories. Processing a child directory thencd'd through that symlink into the shell-owned/data/local/tmpstaging area, andrm -rf ./*failed with "Permission denied" underrun-as(XA0133/XA0129). This broke any app with culture/satellite subdirectories (LocalizedAssemblies_ShouldBeFastDeployed,DotNetNewAndroidTest). The full rewrite now clears and symlinks only the regular files in each directory and leaves subdirectories to their own iterations, so it no longer follows or deletes child directories.statformat split by the device shell.find ... -exec stat -c "%n|%s|%Y" {} +reached the device shell unquoted, so|was treated as a pipe (exit 127 →XA0129) and the copy fallback also failed. The command is now built as a single string with the format single-quoted (matching theRunAsShellquoting pattern).printf '\n'arrived as a literal\n. The readiness check built its probe withprintf '\noverride='/printf '\n'; the backslash escape does not survive the adb + device-shell quoting layers, so the device emittedremote=<hash>\noverride=<hash>\non a single line.ParseDeviceManifestState(splitting on real newlines) put the whole tail intoRemoteHashand leftOverrideHashempty, soremoteReadywas always false and every incremental install reset the staging directory and redeployed all files (changed files: 329in CI), reporting unchanged assemblies asCopyFile. The probe is now built withecho+ command substitution (real newlines, no backslash escapes); verified on an API 36 emulator thatremoteReadybecomes true on an unchanged reinstall.FastDeploy2 approach
FastDeploy2 uses the final direction from the experiment PR:
Build a local manifest of the deployment target and FastDev inputs:
Compare with the last successful deploy manifest, then verify that the device-side staging tree and app override tree both carry the expected manifest hash.
Push only changed files to device temp storage, without
adb push --sync:Remove stale staged files for removed inputs.
Maintain
files/.__override__as symlinks to staged files with batchedrun-as sh -ccommands. The generated shell scripts use shortd/svariables pluscdso long source/target directories are not repeated for every file.After a successful deploy, write the manifest and matching device-side manifest hash markers:
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 fixedadb push --syncwith 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.
adbis 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 anywas also tested against API 24 and API 29 emulators that did not advertisesendrecv_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:
FastDeployis excellent for very small changes but very slow for full payloads.adb push --syncover the whole directory is much faster for full payloads, but still scans/probes too much for tiny changes.adb pushper file is too slow because every adb invocation has ~40ms median overhead even when skipped.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.syncbatch-aware. Instead of invokingrun-as ... xamarin.synconce per changed file, the host could open a singlerun-as <package> xamarin.sync-batchstdin stream and write all changed files one after another using a small binary protocol: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
FastDeployavailable as a fallback while we validate the symlink approach across more devices.Preliminary benchmark from the experiment PR
Device: physical Samsung Galaxy A16 (
SM-A165F,R58Y30HZ65V) connected by USB 3 cableProject:
samples/HelloWorld/HelloWorld/HelloWorld.DotNet.csprojValidation
Focused task builds pass in this branch:
Device/emulator validation passed:
SM-A165F,R58Y30HZ65V) over USB 3 cableSM-A165F,R58Y30HZ65V) over USB 3 cableSM-A165F,R58Y30HZ65V) over USB 3 cabled/sshell-variable symlink scriptsObserved validation details:
Additional adb compatibility probes with Platform-Tools 36.0.0:
adb push -z anycmdonly, nosendrecv_v2*cmdonly, nosendrecv_v2*sendrecv_v2,brotli,lz4,zstdRisks
FastDeployis still present and selectable.files/.__override__to/data/local/tmp/fastdeploy2/...worked on the tested physical Samsung device over USB.adb push -z any; older devices can still fall back to uncompressed transfer when driven by a modern adb.Future work
FastDeployimplementation once we are confident the new approach is better across a broader device matrix.FastDeployno longer needs them.xamarin.syncprotocol as the app-private real-file fallback design.