Skip to content

daemon: stop closing adopted serial and input fds twice - #93

Open
collegeming wants to merge 213 commits into
Droid-VM:masterfrom
collegeming:fix/fd-double-close
Open

collegeming wants to merge 213 commits into
Droid-VM:masterfrom
collegeming:fix/fd-double-close

Conversation

@collegeming

Copy link
Copy Markdown

What this fixes

Two descriptor-lifetime defects in the daemon. Both are reachable in normal operation and both close descriptors the code no longer owns.

  1. SerialPipe closes adopted pipe ends twice. The constructor hands both local ends to FDPipeConsoleStream, which adopts them into ParcelFileDescriptor; the stream is the sole owner from then on. close() closed the stream first (releasing those numbers) and then closed the saved integers again. If the kernel hands a released number to another FileInputStream in between, the second close takes that one down. closeRemoteFd() also tested pipe_out while reading pipe_in.

  2. NativeDisplayInputBridge.release() is not atomic. Two concurrent releases can read the same slot map and close the same listening descriptors twice, and the accept loop had no way to stop without another thread closing descriptors it was still using.

Why it matters

On a 0.0.6.r242.g3348830 build, one VM shutdown left the daemon dead:

fdsan: attempted to close file descriptor 114, expected to be unowned, actually owned by FileInputStream
Fatal signal 6 (SIGABRT) in tid 19033 (DefaultRouterWa), pid 18608 (droidvmd)
  #03 libopenjdk.so (UNIXProcess_forkAndExec)
  #06 java.lang.ProcessImpl.start
  #09 java.lang.Runtime.exec
  #14 cn.classfun.droidvm.daemon.network.backend.Netbox.query
  #15 cn.classfun.droidvm.daemon.network.backend.DefaultRouterWatcher.reconcileRules

That abort is a raw close of a number owned by a Java stream while Runtime.exec was building its process pipes: the pipe bookkeeping held a descriptor that someone else had already closed and the kernel had reused. Both defects produce exactly this shape (close of an adopted end after the stream already released it, with a reuse window). I am not claiming it is the only possible source of that abort; what is certain is that both paths close descriptors they do not own, and the serial one runs on every VM stop.

Changes

  • SerialPipe now delegates its descriptor bookkeeping to a small SerialPipeFds owner. It records which ends were transferred to the stream, detaches before invoking any closer, and never raw-closes a transferred end. close() and closeRemoteFd() are serialized. Constructor failure closes only ends that were not adopted.
  • NativeDisplayInputBridge gives each generation a NativeInputSocketOwner. startListening/release are serialized and detach the map atomically; release closes only the wake-pipe writer, whose HUP wakes poll, while the accept worker closes the listener and read end after its last syscall. The listener is non-blocking so a readiness that disappeared cannot leave accept parked. Duplicate slots are rejected before binding, and an old worker never unlinks a path a newer generation owns.

Tests

SerialPipeFdsTest and NativeInputSocketOwnerTest are plain JUnit tests, no Android or JNI:

  • descriptor number reused immediately after a close (the failure this patch prevents),
  • close after closeRemoteFd, reentrant closer, concurrent full/remote close,
  • partial transfer failure in the constructor,
  • stop before the worker starts, concurrent stop, and an old generation after its numbers were reused.

Local static checks (check_ascii, check_string_concat, check_arity, check_translations, check_vector_icons) pass on this branch.

I could not run this on a device: installing a modified APK requires the project signing key, and updating with a different key would wipe app data. If you build a signed dev APK with this applied, I can run the VM start/stop and input-session lifecycle on a Xiaomi tablet (Android 17, arm64, gfxstream and PanVK routes) and report the result here.

Not changed

  • If the constructor fails after adoptFd, that end stays with the caller's stream (the caller keeps the stream and closes it on normal VM teardown). Reclaiming it immediately would need a targeted release API on FDPipeConsoleStream; that is left out of this change.
  • No JNI change, no fdsan suppression, no signature or install changes.

BigfootACA and others added 30 commits May 13, 2026 18:52
feat(vm): add prepare lend mthp option
Optimize UI event handling to reduce main thread saturation
- Add applyForward/removeForward abstract methods to FirewallHelper
- Implement DNAT and forward rules in IptablesBackend with proper
  wildcard host IP handling
- Add insert() method to ChainInfo for ordered rule insertion
- Integrate VMPortForwarder lifecycle into VMInstance start/stop
- Add PortForwardListHandler for IPC port forward queries
- Add VMPortForwarder to manage configured port forwards
- Add PortForwardSetHandler for IPC communication
- Implement VMEditPortForwardTab for editing port forwards
- Add PortForwardEditAdapter and ViewHolder for RecyclerView
- Enhance VMInfoActivity to display active port forwards
- Create PortProtocol enum for TCP/UDP support
- Add UI layouts for port forward editing and display
- Add localized strings for port forwarding features

This commit introduces a complete port forwarding management
system with both edit-time configuration and runtime display.
- Add volatile modifier to ensure thread visibility
- Replace polling-based loop with wait/notify event model
- Refactor reconcile logic with atomic rule switching and better error handling
- Improve handling of unresolved guest IPs across retry attempts
Add DHCP options and prefer prebuilt dnsmasq binary
Implement comprehensive IP-based port forwarding management
- Set prepare_lend_mthp to false in default AgentVM config
Set up run directory ownership and disable mthp lending in agent
feat(settings): add option to clear logs before starting VM
- bump actions/upload-artifact to v7
- set archive: false to avoid re-zipping already-compressed apk
chore(ci): skip re-compression when uploading artifacts
- Added ShareUtils for exporting logs to system share sheet via FileProvider
- Integrated share option in Logs and VM Console menus
- Configured FileProvider with cache directory for safe file sharing
- Added bilingual string resources for share actions
- Refactored VMConsoleActivity to extract console text fetching logic
feat(logs): add share functionality
Rework VM networking around typed network and NIC config, Linux/gVisor bridge backends, VLAN-aware L2/L3 behavior, bridgedhcp PD/DHCP service integration, pbridge/gvswitch tooling, and per-NIC static leases/port forwards.

Add the netbox helper and focused network/store tests, and move the old VM-level port-forward editor into NIC/network configuration.

Co-authored-by: KusakabeShi <github@kskb.eu.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add lbx-backed boot-entry scanning, BootConfig/BootPlan resolution, a VM boot menu, direct Linux boot UI, and protected-VM DMA warnings for kernels that lack CONFIG_DMA_RESTRICTED_POOL.

Expose kernel analysis from URL and LXC image import flows without coupling it to the background download service.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Move disk image downloads out of the import activities into a foreground service backed by a self-written resumable downloader, with notification progress, retry/pause state, and one active import at a time.

Rewire URL, flat image, and LXC import screens to attach to the service state while keeping DownloadWidget display-only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Apply IME inset padding globally while exempting console/display surfaces, preventing the soft keyboard from covering editor UI.

Make the LXC import screen usable immediately from bundled source metadata, then refresh mirrors in the background without clobbering an explicit user selection.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add a one-shot build script for submodules, third-party networking/boot tools, and APK output, and ignore generated third-party checkouts and staged binaries.

Teach the app build and asset extraction path about the new gvswitch, pbridge, bridgedhcp, lbx, and netbox binaries, including lbx JNI packaging for UI-side execution.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
crosvm can't read zlib-compressed qcow2 (guest gets vda I/O errors and no partition table, so any root= hangs). Before starting a crosvm VM, detect compressed qcow2 disks via lbx (new daemon disk_compat handler + BootPlan.hasCompressedClusters) and prompt to convert (decompress in place via DiskOperationActivity, path-mode) then auto-start; no response in 5s = start anyway. URL analysis now uses lbx 'entries --quick' to skip unused per-entry reads. UI: kernel/initrd/cmdline detection fields are read-only but selectable with a copy button; tapping the detected cmdline seeds the (now multiline) override field at the same caret offset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Terminal copy/paste was a no-op: SimpleTerminalSessionClient's
onCopyTextToClipboard/onPasteTextFromClipboard were empty, so Termux's
selection 'Copy'/'Paste' never reached the system clipboard. Implement both
(reusing CopyableField for copy, emulator.paste() for paste) and pass an
owning Context to the client at all three terminal screens (VM console, disk
op, agent op).

Also default the terminals to Maple Mono NL NF via TerminalView.setTypeface,
with a Termux-compatible $HOME/.termux/font.ttf user override and a
system-monospace fallback (TerminalFonts). The ~2.3MB TTF is git-ignored and
fetched in build.sh (pinned v7.9, sha256-verified); only the OFL license is
committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…om DroidVM-Prebuilt-Root

- Drop bundled 7za; decompress the runtime payload in-process with
  org.tukaani:xz + a minimal USTAR reader (AssetUtils).
- Runtime prebuilt is prebuilt-<abi>.tar.xz; the third-party daemons + lbx ship
  inside it (DATA/bin/<name>), so the 7za/thirdparty asset extraction and the
  BINARIES_PREBUILT/THIRDPARTY constants are gone. Stale assets/bin daemons
  removed; *.7z and *-comptime.zip excluded from APK assets.
- lbx also ships in prebuilt-<abi>-comptime.zip, unpacked at Gradle build time
  into lib/<abi>/liblbx.so (UnpackComptimeJniLibsTask).
- Terminal font fetched by a Gradle task (FetchTerminalFontTask) so CI and local
  builds match; build.sh slimmed to preflight + submodules + gradle, with a
  native-dev toolchain check when DroidVM-Prebuilt-Root is present.
- regenPrebuilts runs auto-build.py when DroidVM-Prebuilt-Root is checked out.
- Drop x86_64 from abiFilters (no x86_64 VM runtime exists).
- Workflows: add update-latest-release.yml; add manual workflow_dispatch to
  build.yml and release.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Migrate pre-rework networks.json (flat schema) to the v2 L3/VLAN model;
  the app and daemon load sites validate the migrated result and skip it
  (with a warning) when it does not hold up.

- Fold the legacy VM-level port_forwards array into each NIC's DHCPv4
  lease (structural only -- the lease offset is left unassigned).

- Refuse a static lease with no assigned offset at validation, so a
  migrated VM cannot boot at a wrong default IP.

- When starting a VM from the app, allocate any missing offset
  conflict-free (from 64 up, skipping the dynamic pool) and persist it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FikGHtfpTGfjxfEfuUF1Ra
Convert the Simplified Chinese strings to Traditional Chinese with Taiwan
vocabulary (e.g. memory 内存 -> 記憶體, 软件 -> 軟體, 镜像 -> 映像, 权限 ->
權限) and register zh-TW in the per-app locale config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FikGHtfpTGfjxfEfuUF1Ra
The dev-release and release workflows built and published an unsigned
release APK whenever APP_SIGN_KEY_JKS was absent. Instead fall back to
assembleDebug (signed with the auto-generated debug keystore) so the
published artifact is always installable. The signed release path is
unchanged when the key is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FikGHtfpTGfjxfEfuUF1Ra
…keover

The daemon had no cross-process singleton guard: it picked a random port
and overwrote the pid file, so a racy app-side check (or an explicit start)
could leave two daemons running and fighting over VMs, networks and iptables.

Take an exclusive advisory lock on droidvmd.lock at startup, held for the
process lifetime so the kernel releases it automatically on death (incl.
SIGKILL). A normal launch exits when the lock is held; with --force the new
daemon terminates the running one (SIGINT, then SIGKILL) and takes over.
restartDaemon() now force-launches instead of the racy stop-then-wait loop.

Because a SIGKILL'd daemon skips cleanup, its crosvm/qemu children orphan and
survive; reap them on startup alongside the existing dnsmasq/gvswitch/pbridge
cleanup, matched by full prebuilt path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FikGHtfpTGfjxfEfuUF1Ra
HuJK and others added 28 commits September 4, 2026 02:42
Each host module under Settings supplies something a particular
configuration reaches for at run time, and without it the VM fails as
anything but a missing module: a guest that cannot see its own RAM, GPU
allocations failing at random, a big VM refusing to boot with "out of
memory" on a phone with memory to spare. A pre-start guard asks up front,
from what the config actually turns on -- pseudo-unprotected RAM wants
gunyah_host_share, GPU acceleration wants gh_unmovable and udmabuf, and a
VM over 2 GB wants the kvcalloc fix.

Which of those apply to this phone is not decided here. The Kernel Module
list already answers it -- the KMI directory picks the build for the
running kernel and match.json drops what was written for another SoC -- so
a module the list does not offer is skipped without a word rather than
warned about. That is also why the kvcalloc rule needs no "and only on the
8 Gen 3": the fix is built for the 6.1 GKI whose Gunyah driver has the bug,
and for Qualcomm, so it can only surface there. It keeps working as the
module set grows to other vendors.

The dialog offers to start anyway (5s countdown, the answer an unattended
start gets), to open the module page, or to cancel. Nothing here loads a
module on the user's behalf: that is a decision of its own, made where the
modules are managed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTc7yZ1DPCX7xpbYcUo2V
…stead of crashing

Stored values outlive the sets that produced them: an option gets retired
between releases, and several graphics rows build their item set out of
another row's value, so a config written under one combination is routinely
read back under another. EnumPicker answered that with an exception, which
made every restore path a crash waiting for the first user whose VM
predates the current build -- and it arrived: an old VM naming a GL
provider, under a backend whose set no longer lists one, took the editor
down as it opened.

setSelectedItem now lands an item the picker does not list, or lists only
to refuse, on the row's default and returns whether the value itself was
taken. setDefaultItem names that default wherever the head of the list is
not the sensible answer -- a transport ladder whose head is the CPU copy
should not be where "that rung is not built yet" ends up. Refused now means
refused from every direction, a stored config included, so the picker no
longer sits on a value it would not let the user pick; rotate-mode steps
over refused rungs for the same reason.

The graphics tab stops re-deriving which values belong to the current
backend -- the copy of that rule at the call site is what did not cover the
retired provider -- and hands each stored value to the picker that owns the
set. If any row moved, the tab says so once: what is on screen is not what
was saved, and the user should not have to notice that on their own. PanVK
joins the same mechanism as a listed-and-refused item, replacing its
toast-and-revert listener.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BTc7yZ1DPCX7xpbYcUo2V
All five came out of a PR review of this branch. What they have in common
is that each fails somewhere the message does not point at: a black screen
with the stream still arriving, a boot that stops at an ioctl, a daemon
that answers nothing for ten seconds, a prompt about memory a VM never
touches. The review's other findings were checked and left alone -- either
correct as they are, or a pre-existing limitation this branch did not
introduce.

vnc: the sync frame outlives the pipeline that was not there to take it.
The server sends the parameter sets exactly once per client, on the
reset-flagged rect that starts its stream, and the presentation console has
nowhere to put that rect: it connects when it opens and builds its decoder
only once a display has been chosen. The rect was dropped, every bare IDR
after it decoded to nothing, and no reconnect followed because rects were
still arriving. H264SyncFrameCache holds it for the connection instead of
for the pipeline, which also covers the window being dismissed and rebuilt,
and the race where DesktopSize reaches the main thread a post later than
the sync rect reaches the message loop.

hugepage: the reserve is asked about only the VMs that draw on it. The
pre-start check sized every VM against pool_avail, including the ones on
hypervisors that hand no memory away and cannot hit the failure it exists
to avoid. They got the dialog, or ten seconds of waiting, for a pool their
boot will not spend a page of.

display: a refused socket path gives back the sockets bound before it.
startListening() published its slots only after the last one bound, so the
release() the caller already makes on that path freed an empty set --
leaking listening fds, inodes under run/, and an accept thread each, parked
in accept(2) for the life of the daemon. Unreachable today; the two screen
ids are spelt g0 and sfb precisely so their names stay short, and a third
screen is what would arrive without a tag. The test pins the budget such a
name would be chosen against.

daemon: auto-start runs behind the socket, not in front of it. The sweep
waits on the reserve from inside the ServerContext constructor, which is
before the socket binds, before the signal handlers exist and before the VM
event callback is wired. It runs from Server.run() on its own thread now,
which is also why the sweep snapshots its list rather than iterating across
the wait, why start() takes a lock from the state test to the worker, and
why stopAll() can call the wait off: a daemon answering clients while the
sweep runs is a sweep with company.

vm: refuse to start a VM whose LEND parcels this kernel will not take. 6.6
demand-pages a parcel; 6.1 commits every one as it arrives and refuses a
later one, so a 3 GB VM configured for 256 MB parcels does not boot there
and says only "failed to initialize virtual machine". A better default
would not have helped, because the value travels: an imported VM carries
the mode of the phone its package came from. The dialog offers to correct
it and keep the correction.

Verified on a 6.1 Qualcomm device. The daemon bound its socket, logged the
reserve wait 32 ms later, and authenticated a client 122 ms after that with
nine seconds of waiting still to go; a SIGINT mid-wait was answered rather
than queued behind it. The LEND dialog appeared on start, its fix wrote
single to vms.json, the next crosvm command line carried
--prepare-lend-mthp-mode single, and the VM reached RUNNING where it had
been dying at GH_VM_START. 293 unit tests and the five CI source checks
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QzTPwWuSaDfFJg62dtqZW
Signed-off-by: HuJK <gh@hujk.org>
The UEFI branch of the crosvm command builder passed the builtin firmware
whatever the config said, under a comment claiming crosvm had no
custom-firmware support. It has nothing else: the path goes in the same
positional slot a direct-boot kernel would, and crosvm opens it with
open_file_or_duplicate like any other file. Nothing on that side is
attached to the builtin path.

Everything around it was already wired. The editor has the firmware field,
its browse dialog offers builtin or external, and validateInput checks the
path exists; the exporter packs a custom firmware into the package (it is
skipped only when it matches a builtin), and the importer remaps it like
any other boot file. The one line here is what dropped it, so a user could
choose a firmware, save it, pass validation, and have it silently ignored
at start -- worse than the feature being absent.

No size check to add alongside the vars one. That limit is pflash's:
AARCH64_PFLASH_MAX_SIZE is AARCH64_PLATFORM_MMIO_SIZE, 8 MiB, and
setup_pflash refuses a larger image because pflash is a device on the MMIO
bus between the PCI aperture and the Gunyah VMMIO limit. Firmware is a
payload loaded into guest RAM -- the Bios arm of that match passes u64::MAX
as its bound -- so it is limited by the VM's memory and nothing else.

Verified on device: with the firmware set to a copy of the builtin at
/data/local/tmp/fw/my-edk2.fd, the crosvm command line ended in that path
instead of usr/share/droidvm/edk2-gunyah.fd, --pflash still named the
builtin vars, and the VM reached RUNNING.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QzTPwWuSaDfFJg62dtqZW
Signed-off-by: HuJK <gh@hujk.org>
feat: 3d acceleration, bunch of reworks
The editor warned that a width off a 16-pixel grain would cost the GPU copy,
because simplefb's pitch was width * 4 with nothing padding it and the blit's
LINEAR dma-buf import wants 64 bytes. The host pads the row now and decides the
stride in one place, so the width costs nothing and there is nothing to warn
about: the condition, the warning it fed and the test that pinned it all go.

Squashed from 2 commits on wip/usb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
…which

Setting the root password or growing the disk is preparation, not the import.
The download had already succeeded and the image was already there, and a
failure in either ended the run and threw the VM away -- so a mistyped password
or a resize that could not finish cost the user the whole thing.

An action can be optional now. The queue script keeps its outcome in a state
directory instead of acting on it where it happens: fail() and skip_action() run
inside the action's own subshell and only record what went wrong, and
end_action() reads that back and decides what it means. A required action still
ends the run at the first error; an optional one adds itself to FAILED_ACTIONS
and the actions behind it still get their turn.

The import flow then creates the VM whatever the preparation managed, and names
the steps that did not complete -- the resize, the password, or both when the
rescue VM was cancelled or died and can say nothing about either. Those names
survive a rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
…is app has

Choosing English gave Simplified Chinese. The app was doing nothing wrong: the
picker set the locale to "en", localeConfig declares en, and the resource table
carries English. The platform refused it anyway.

Measured on a device whose system list is [en_US, zh_Hans_CN, zh_Hant_TW,
zh_Hant_HK]. Asking for ja, fr, de or ko -- none of which this app translates --
gives the activity a resource configuration of [zh_CN], and so does asking for
en. Asking for zh-Hant-TW gives [zh_TW_#Hant, en_US, ...], the app locale
followed by the system's, which is what a locale the app supports looks like.

The collapse target is the proof. A refused locale falls back to the system
list, and that list begins with en_US -- yet it lands on zh_CN, the second
entry. It skipped English, so English is not in the set the platform matches
against. de and ko collapse too, though AndroidX translates both, so that set is
not the resource table either: it is the app's own locale-qualified folders,
values-zh-rCN and values-zh-rTW. English lives in the unqualified values/, which
is not a locale, so the app appears to have none.

en-US is the exception because it is the system's first locale: asking for what
the system already has needs no override, so nothing is filtered.

That makes this a fix for the device in hand rather than for the cause. Curing
the cause means a values-en holding the English strings, generated at build time
so there is no second copy to keep in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
The hint said the custom firmware field applied to QEMU only. crosvm honours a
custom EDK2 the same way now, so the sentence describes a limit that is no
longer there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
The daemon reached res/xml/qcom.xml through createPackageContext on its system
context, which needs an attached ActivityThread the daemon does not have -- so
the lookup threw where it was called and the host SoC came out as its raw model
string, or as nothing at all. It reads the table straight out of the APK now,
with the package context left as the fallback, and a failure anywhere in the
chain is logged and answered with null instead of thrown.

More chips in the table while we are here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
gh_hugepage_reserve exists because Gunyah could not take guest memory without
migrating it out of CMA first. Qualcomm fixed that in 6.18, so on those kernels
the module is not a fix, it is a module the user is asked to load for nothing.

match.json entries take a kernel_below now, and HostKernel can order two series
the way version numbers go rather than the way strings do -- 6.6 is below 6.18,
which no textual comparison of the two will tell you. A kernel it cannot read
matches as before: not knowing which side of the line a host falls on is not a
reason to hide a module that might be needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
A host USB device can be handed to a running VM, by hand or by rule.

The daemon owns it. It watches /dev/bus/usb and the kernel's uevent socket --
the second because binding or unbinding a driver moves no node and nothing else
reports it -- and keeps an inventory of every device including the root hubs,
whose arrival nothing else reports either and whose absence is a bus on which
nothing can ever enumerate. Attaching takes the interfaces off their host
drivers and hands the device to crosvm; releasing gives them back, and a VMM
that died holding one has its claim taken off it and the device restored.

Rules decide it without anyone watching. Four ordered zones -- exact, port,
device, and the catch-all -- each a list tried in order, each rule naming a
target: a VM's xHCI controller, the host, or idle. A fifth zone nobody sees is
"no match means the host". Passes run on a plug, on a driver change, when a VM
reaches RUNNING, when the rules are saved, and at daemon start, and a device
somebody moved by hand is locked against them until it is unplugged.

The gate is drivers_autoprobe. With passthrough on, an arriving device binds no
driver of its own and the daemon stands in for the kernel: it probes the device
so the configuration is chosen, then its interfaces, and only for what the rules
say goes to the host. What a device is doing is read from the drivers bound to
it -- hostuse, vmuse, idle -- and stored nowhere.

The UI is three surfaces: a USB passthrough page of four folding zones with a
card per rule and a master switch over them, a management page that moves one
device now without touching the rules, and an xHCI controller peripheral in the
VM editor whose card binds rules straight to that controller.

Squashed from 39 commits on wip/usb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
… in lent memory

lbx now reports what each boot entry actually is -- linux or windows -- and can
read a Linux entry's own config-*. Two things the app could only guess at
before follow from that.

The boot menu stops offering a direct kernel boot for an image that holds
Windows. Direct boot loads a kernel and an initrd; a Windows install has
neither, so the entries were listed and the start failed later with nothing
useful to say. The menu now says so where the choice is made, and points at the
built-in kernel or the UEFI protocol.

And a protected VM asks, before it starts, whether this guest can live with its
memory lent to the hypervisor. Two ways it cannot, and the image decides which
applies rather than the boot protocol. A Linux kernel built without
CONFIG_DMA_RESTRICTED_POOL has nothing for virtio to bounce through and comes
up with no disk and no network -- lbx reads that out of the entry's config-*. A
Windows guest with USB passthrough has nowhere to map transfer buffers, so the
rules pointing at it do nothing; Linux guests are fine there, because the pool
they need anyway covers USB too. Both answers are the same answer, which is why
it is one check: pseudo-unprotected shares the memory instead of lending it.

Only what is certain is raised. A bootloader lbx cannot read, an OS this build
has no rule for, a kernel with no config-* to read -- each is a question with no
answer, and a warning nobody can act on is worse than none, so each ends the
check quietly. The dialog offers the run that works (pseudo-unprotected, once) beside the one
that was asked for, and the button counts down so a start nobody is watching is
never blocked. A finger on the dialog stops that countdown for good and puts the
button's own label back -- someone is there, reading, and the answer is theirs
to give, which is what pressing a key during GRUB's menu timeout does. The boot
menu's own countdown stops the same way.

Every guard prompt lays its answers out the same way now, by what pressing one
means: neutral is go ahead changing nothing, positive is go ahead with the
change that fixes it, negative is do not go ahead. The countdown sits on the
neutral button and falls to the positive one only when a dialog has no neutral,
which is to say when going ahead unchanged was never on offer -- so a start
nobody is watching is never blocked and never has a setting changed under it.
The kernel-module guard and the boot menu follow the same layout, and the theme
sets the positive button in bold, since it is the only one that alters anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrfBDYZexyRUKdQyJ3jQ6q
…follows it

A keyboard attached to Android could not send a guest the keys that matter
most in one: Android decides what an Activity may see before the Activity sees
it, and Home, the task switcher, Alt+Tab and every Meta shortcut are taken by
the window manager and never dispatched. No console written on top of
dispatchKeyEvent can be sent them.

So the daemon takes the keyboards instead (EVIOCGRAB), which makes its
descriptor the only recipient the kernel's input core hands events to --
Android's InputReader keeps its own open and is simply never woken again -- and
writes the raw evdev into the same per-screen keyboard socket the console's
on-screen keys already use, so nothing downstream has to know where a key came
from. A keyboard is any /dev/input node whose EV_KEY bitmap has q, a, z, enter
and space: a vendor folio, a Bluetooth keyboard and a USB one alike, while the
volume rocker and the touchscreen are left where they are.

When, and only when: the console is in front, the screen takes input, and the
typing surface is not the one whose text comes from the IME. A grabbed keyboard
is invisible to an IME, so KeyboardMode.SYSTEM hands it straight back and
composed input goes on working exactly as before. A console stays armed while
it holds nothing, because it is usually up before the guest it starts and
because a Bluetooth keyboard comes back as whichever eventN is free: the VM
state hook and a uevent watcher grab those. Releasing lifts every key still
held, so no guest is left with a Ctrl down, and the console's binder token
releases the grab if the console dies. The escape chord is implemented and
unbound -- which combination will not fight the guest is the user's to say once
there is a page to say it on; until then the two ways out want no keyboard
anyway (leave the display, or switch to the system keyboard).

The guest talks back, and nothing was listening. Its driver reports the
keyboard lamps on virtio-input's status queue, crosvm writes them onto the same
socket, and the daemon only ever wrote to it -- so the records sat in a buffer
nobody drained and the caps light on the real keyboard stayed dark. Read them:
the lamps are set on every grabbed keyboard (one that joins late is synced to
what the guest already said) and passed to the console. The guest's own view is
the only correct one, since software inside it can turn caps lock on with no
key involved, and a lamp it has never reported is left unknown rather than
guessed at, because virtio-input gives no way to ask.

The console gets a copy of the keys for the same reason it had none: they never
pass through the app. IPhysicalKeyEcho is oneway, called after the keys have
reached the guest, and registered only in the mode that draws a keyboard. A
drawn key lights from the union of three reasons -- the panel holds it sticky,
the physical keyboard holds it, the guest says its lock is on -- so releasing a
hardware Ctrl no longer un-paints one the panel is holding, and the key faces
follow a physical Shift as well as a sticky one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzSVsTJs6p3V2pFZLaXq9U
The pin was three publishes old (5cc4aa8, from DroidVM-Prebuilt-Root 14c5886):
it predates the USB passthrough work, both simplefb fixes and the keyboard
bitmap, so an APK built from this branch ran a crosvm that had none of them
and the branch could only be tested with a hand-copied binary.

Move it to f0af07a, which CI published from DroidVM-Prebuilt-Root 4215d7b.
Checked rather than assumed: that publish changed one sha256 line and the
payload, its usr/bin/crosvm is sha256 ee531987e942dc2ed22441d915e6d6a403a8761a
3bc1f70683fea2742dcd1334, and that is byte for byte the binary built from
crosvm pr/usb 8d5268b (wip/usb 4fe20fa) and verified on the device -- an Ubuntu
guest started with it advertises KEY_LEFTMETA through KEY_MICMUTE on its virtio
keyboard. The EDK2 firmware in the payload is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzSVsTJs6p3V2pFZLaXq9U
The pin was f0af07a, whose usr/bin/crosvm is
ee531987e942dc2ed22441d915e6d6a403a8761a3bc1f70683fea2742dcd1334 -- the
binary built from crosvm pr/usb 8d5268b. It predates the DMA-window fix, so
an APK built from this branch still died the moment a guest enabled display
output on a passed-through DisplayLink adapter.

Move it to c4998e1, which CI published from DroidVM-Prebuilt-Root 82c59c2,
the merge of Droid-VM/DroidVM-Prebuilt-Root#4. Checked rather than assumed:
that publish's usr/bin/crosvm is sha256
cd3693ba876720f7ab7c08e5d6358101336f3bbcdb3d7d4f324ac29274bf6a7b, which is
byte for byte the binary built from crosvm wip/usb-fix-displaylink-crash
(Droid-VM/crosvm#7, merged) and shipped to the customer who reported the
crash. It carries two fixes:

  usb: a transfer too big for the DMA window goes through a plain buffer
  block: the queue size is a per-disk option, and its default stops
         throttling viostor

c4998e1 rather than 0344eea, the identical payload CI published from the
branch before it merged: that one is reachable only from a wip branch, and a
pin whose commit lives on a branch someone is about to delete breaks
`git submodule update` for everyone on master.

The payload also loses usr/bin/crosvm.bak1400, a 12 MB backup from
2026-09-03 that every APK had carried and every device had unpacked; the
debug APK goes from 130 MB to 126 MB. The EDK2 firmware is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTFaLfTucJb9LBtxtU6Ab
…crash

prebuilts: the payload carries the DisplayLink crash fix
Added a custom sponsorship URL for funding.
Due to both having ARMv9 and they also have gunyah (for most of 7s Gen 3 as well as the sibling of it is 7s Gen 4) and as for SM8635 the sibling of it is SM8650.
Include Snapdragon 7s Gen 3/4 and Snapdragon 8s Gen 3
fix(ui): Material menu not showing on some device
Signed-off-by: Molly Sophia <mollysophia379@gmail.com>
…d-gunyah-only

fix(vm): restrict pseudo-unprotected mode to crosvm Gunyah
SerialPipe hands both local pipe ends to FDPipeConsoleStream, which adopts
them into ParcelFileDescriptor; the stream is their sole owner from then on.
close() closed the stream first and then closed the saved integers again.
The kernel can reuse a released number between those two steps, so the second
close takes down whatever now owns it, and fdsan aborts the daemon when that
happens to be a Java stream. Track which ends were transferred, detach them
before any closer can run, and never close those raw again. closeRemoteFd
also stops reading pipe_in under a pipe_out guard. Constructor failure still
closes only the ends that were not adopted.

NativeDisplayInputBridge released its slot map without synchronizing, so two
concurrent releases could take the same list and close the same listening
descriptors twice. The accept loop also needed a way to stop without another
thread closing the descriptors it was still using. Give each generation one
owner: release detaches the whole map and closes only the wake pipe writer,
whose HUP wakes poll, while the accept worker closes the listener and read
end after its last syscall. The listener is non-blocking so a readiness that
disappeared cannot leave accept parked.

Tests are pure-Java unit tests for both owners: descriptor-number reuse after
a close, concurrent release, and partial transfer failure. They do not run on
Android or exercise the JNI layer.

Signed-off-by: zhengwenming <zhengwenming@apexsoft.com.cn>

@BigfootACA BigfootACA 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.

Please rebase your commits and fix conflicts

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.

9 participants