Skip to content

Fix rooted-device setup failure: chroot bind mounts silently skipped + TMPDIR/HOME env leak breaking apt postinst - #37

Open
lota09 wants to merge 1 commit into
orailnoor:mainfrom
lota09:fix/chroot-mount-and-tmpdir
Open

lota09 wants to merge 1 commit into
orailnoor:mainfrom
lota09:fix/chroot-mount-and-tmpdir

Conversation

@lota09

@lota09 lota09 commented Jul 20, 2026

Copy link
Copy Markdown

Summary

On rooted devices using the chroot-based Linux runtime, running the setup wizard reliably failed at the desktop environment install step with:

Setup Failed: Bad state: Desktop Essentials package installation failed

with a dpkg/apt-get error surfaced from the ca-certificates postinst script. This reproduced consistently, including after a full device factory reset and fresh install, which ruled out any stale/corrupted local state.

This PR fixes two independent, confirmed root causes in ChrootRuntime.kt, both verified via adb logcat before and after the fix on a physical rooted device.

Root cause #1 — mountIfNeeded()'s "already mounted" pre-check always returns a false positive

ensureMounts() calls mountIfNeeded() for each of the 7 required mount points (/dev, /dev/pts, /dev/shm, /proc, /sys, /run, /tmp). The original implementation pre-checked whether a path was already mounted by running su -c mount and string-matching the output:

val mounts = rootShell.exec("mount")
if (mounts.any { it.contains(" on $absolute ") }) {
    // considered already mounted, skip
    return
}

On the affected device, this check always evaluated true, even on a freshly extracted rootfs where none of the paths were actually mounted yet. As a result, every single mountIfNeeded() call short-circuited and no mount was ever attempted.

This was confirmed via logcat: between the initial su exec: mount (table fetch) call and the final execChroot("mkdir -p /tmp/.X11-unix ...") call, there were zero su exec: mkdir -p ... && mount ... log lines — proving all 7 mount attempts were being skipped, not attempted-and-failed.

Downstream, this meant /dev, /proc, /sys, and a writable /tmp were never available inside the chroot, which is what ultimately broke mktemp/update-ca-certificates later in the install (see root cause #2 for the specific failure once mounts were restored).

Fix

Removed the fragile string-matching pre-check entirely. mountIfNeeded() now always attempts the mount and trusts the real process exit code (via the two-arg RootShell.exec(command, onOutput): Int overload, instead of the one-arg exec(command): String overload that discarded the exit status):

fun ensureMounts() {
    if (!hasRoot()) return
    mountIfNeeded("/dev", "--bind /dev")
    mountIfNeeded("/dev/pts", "--bind /dev/pts")
    mountIfNeeded("/dev/shm", "-t tmpfs tmpfs")
    mountIfNeeded("/proc", "--bind /proc")
    mountIfNeeded("/sys", "--bind /sys")
    mountIfNeeded("/run", "-t tmpfs tmpfs")
    mountIfNeeded("/tmp", "-t tmpfs tmpfs")
    // Create runtime dirs after tmpfs is mounted
    execChroot("mkdir -p /tmp/.X11-unix /tmp/runtime-root /root")
}

private fun mountIfNeeded(relative: String, mountArgs: String) {
    val target = File(rootfsDir, relative).absolutePath
    val log = StringBuilder()
    // Don't pre-check via string-matching `mount` output — that check
    // was confirmed (via logcat) to always report a false "already
    // mounted" positive on this device, silently skipping every mount.
    // Just attempt it every time and trust the real exit code instead.
    val exit = rootShell.exec(
        "mkdir -p \"$target\" && mount $mountArgs \"$target\""
    ) { chunk -> log.append(chunk) }
    if (exit == 0) {
        Log.i(TAG, "Mounted $target")
    } else {
        Log.w(TAG, "Mount failed for $target (exit $exit): $log")
    }
}

Mount is idempotent-safe in practice here: re-mounting an already-mounted bind/tmpfs target is harmless, and any real failure is now surfaced via the exit code and logged instead of being silently swallowed.

Root cause #2 — Android app's TMPDIR/HOME env vars leak into the chroot, breaking ca-certificates postinst

Once root cause #1 was fixed and all 7 mounts started succeeding, the install progressed further but still failed on ca-certificates with apt-get install exiting 100. Digging into the on-screen error log revealed:

mktemp: failed to create file via template '/data/user/0/com.orailnoor.droiddesk/cache/ca-certificates.tmp.XXXXXX': No such file or directory

This path (/data/user/0/com.orailnoor.droiddesk/cache/...) is the Android app process's own private cache directory, not anything inside the chroot. The Android app's TMPDIR (and HOME) environment variables were leaking through into the su/chroot invocation, so when update-ca-certificates's postinst script called mktemp, it respected the inherited $TMPDIR and tried (and failed) to create a file at a path that doesn't exist from inside the chroot's mount namespace.

Notably, startSession()'s runScript in this same file already resets these variables (export TMPDIR=/tmp; export HOME=/root; export PREFIX=/usr) for interactive sessions — this reset had simply never been applied to the package-installation code path (executeCommand() / execChroot()), which is what installDesktopEnvironment() uses.

Fix

Applied the same environment-reset pattern to both executeCommand() and execChroot():

fun executeCommand(command: String, onOutput: ((String) -> Unit)? = null): String {
    if (!hasRoot()) return "Error: root access required"
    val wrapped = "export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; " +
            "export TMPDIR=/tmp; export HOME=/root; $command"
    return if (onOutput != null) {
        val code = rootShell.exec("chroot ${rootfsDir.absolutePath} /bin/bash -c ${shellQuote(wrapped)}") { chunk ->
            onOutput(chunk)
        }
        "Exit code: $code"
    } else {
        rootShell.exec("chroot ${rootfsDir.absolutePath} /bin/bash -c ${shellQuote(wrapped)}")
    }
}

private fun execChroot(command: String, onLog: (String) -> Unit = {}): Int {
    val wrapped = "export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; " +
            "export TMPDIR=/tmp; export HOME=/root; $command"
    val output = rootShell.exec("chroot ${rootfsDir.absolutePath} /bin/bash -c ${shellQuote(wrapped)}") { chunk ->
        Log.d(TAG, "OUT: $chunk")
        onLog(chunk)
    }
    Log.d(TAG, "chroot command exit code: $output")
    return output
}

This ensures every command executed inside the chroot — not just interactive shell sessions — gets a clean, chroot-appropriate environment, regardless of what the host Android app process's own environment looks like.

Testing

Both fixes were verified end-to-end on a physical rooted device (factory reset beforehand to rule out any leftover state from prior chroot experimentation):

  • Before fix: logcat showed zero mount attempts (root cause Plasma is unable to start as it could not correctly use OpenGL #1); after partially patching just the mount fix, install progressed but failed on ca-certificates with the mktemp/TMPDIR error visible both in logcat and in the app's on-screen setup log (root cause Major Overhaul: Session Management, Termux:Widget Support, and Firefox ESR #2).
  • After both fixes: logcat confirms all 7 mount points succeed (Mounted ... logs with real exit codes), followed by successful apt-get install completion for ca-certificates, Mesa GPU drivers, XFCE desktop packages, and Desktop Essentials tools, ending in Desktop environment installation complete.
  • The app's setup wizard now reaches "Setup Complete! Your Linux desktop is ready to launch." at 100%, and the desktop launches successfully afterward.
  • Repro steps used: factory reset device → grant root (Magisk) → install app → run setup wizard with root enabled → observe failure/success at the Desktop Essentials install stage.

Notes for reviewers

  • No existing automated tests or CI workflow cover this code path (repo currently has no .github/workflows), so verification here was manual/device-based as described above.
  • Scope is intentionally limited to ChrootRuntime.kt (the rooted chroot runtime). The non-root Termux/proot runtime (RootfsManager.kt) has its own separate install path and was not touched.
  • Separately noticed but not addressed in this PR: app_state.dart's _runChrootSetup()/installDesktopEnvironment() always throw the same hardcoded StateError('Desktop Essentials package installation failed') regardless of which native install stage actually failed, which made initial diagnosis harder from the UI alone. Happy to open a follow-up PR to surface the real native error message in the UI if that's of interest — let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant