Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,8 @@ import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.LinkOption
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.zip.GZIPInputStream
Expand All @@ -33,6 +30,7 @@ private const val CLAUDE_KEY_FINGERPRINT = "31DDDE24DDFAB679F42D7BD2BAA929FF1A7E
private const val CONNECT_ATTEMPTS = 3
private const val TERMINATE_GRACE_SECONDS = 5L
private const val PID_POLL_MS = 100L
private const val MAX_SYMLINKS = 8
internal const val TAG = "T3DeviceRuntime"
const val GUEST_PATH = "/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

Expand Down Expand Up @@ -100,7 +98,11 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U
private val libDir = File(context.applicationInfo.nativeLibraryDir)
val proot = File(libDir, "libproot.so")
private val loader = File(libDir, "libproot-loader.so")
val home = File(context.filesDir, "device-runtime")
// filesDir is /data/user/0/..., a symlink to /data/data/.... proot resolves the rootfs but
// not PROOT_L2S_DIR, and its faked hard links then point where the guest can't follow.
private val filesDir = context.filesDir.canonicalFile
val home = File(filesDir, "device-runtime")
private val trash = File(filesDir, "device-runtime.trash")
val rootfs = File(home, "ubuntu")
private val installedMarker = File(home, "ubuntu.installed")
private val architecture = ubuntuArchitecture(libDir.name)
Expand All @@ -116,8 +118,26 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U
@Volatile private var guest: GuestProcess? = null
@Volatile private var cancelled = false

/** Also checks the program proot starts first, which an interrupted delete can take. */
val ubuntuInstalled: Boolean
get() = installedMarker.isFile && installedMarker.readText().trim() == ubuntuTarball
get() = installedMarker.isFile && installedMarker.readText().trim() == ubuntuTarball &&
guestFileExists("usr/bin/env")

// Follows symlinks as the guest sees them. The extractor's hard links point at guest paths;
// the ones proot emulates point at host paths inside the rootfs.
private fun guestFileExists(path: String): Boolean {
var file = File(rootfs, path).toPath()
repeat(MAX_SYMLINKS) {
if (!Files.isSymbolicLink(file)) return Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
val target = Files.readSymbolicLink(file).toString()
file = when {
target.startsWith(rootfs.path + "/") -> File(target).toPath()
target.startsWith("/") -> File(rootfs, target).toPath()
else -> file.resolveSibling(target)
}
}
return false
}

fun cancel() {
cancelled = true
Expand Down Expand Up @@ -147,9 +167,10 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U
throw IOException("$ubuntuTarball has SHA-256 $actual, expected $expected")
}
val staging = File(home, "ubuntu.staging")
deleteTree(staging)
deleteTree(rootfs)
installedMarker.delete()
discard(staging)
discard(rootfs)
clearTrash()
staging.mkdirs()
log("Extracting $ubuntuTarball")
tarball.inputStream().buffered(1 shl 16).use { extractTar(GZIPInputStream(it, 1 shl 16), staging) }
Expand Down Expand Up @@ -205,7 +226,7 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U
}
fakeProc.mkdirs()
val sdcard = File(rootfs, "sdcard").toPath()
if (!Files.exists(sdcard, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
if (!Files.exists(sdcard, LinkOption.NOFOLLOW_LINKS)) {
Os.symlink("/storage/emulated/0", sdcard.toString())
}
}
Expand Down Expand Up @@ -275,6 +296,7 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U
/** Starts a command in the guest with stderr merged into stdout. */
fun start(guestCommand: List<String>, environment: Map<String, String> = emptyMap()): GuestProcess {
checkCancelled()
if (!ubuntuInstalled) throw IOException("Ubuntu on this phone is missing or damaged. Install it again.")
writeGuestConfig()
prootTmp.mkdirs()
hardLinks.mkdirs()
Expand Down Expand Up @@ -329,8 +351,24 @@ class DeviceRuntime(private val context: Context, private val log: (String) -> U

/** Deletes Ubuntu and everything installed in it, including the server's data. */
fun remove() {
deleteTree(home)
discard(home)
deleteTree(prootTmp)
clearTrash()
}

// Moved aside before deleting, so a delete that stops partway can't leave an install that
// still looks whole.
private fun discard(file: File) {
if (!Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS)) return
trash.mkdirs()
if (!file.renameTo(File(trash, "${file.name}.${System.nanoTime()}"))) {
throw IOException("Couldn't move ${file.path} aside to delete it")
}
}

// Leftovers only take space; the next remove or install tries again.
private fun clearTrash() {
runCatching { deleteTree(trash) }.onFailure { Log.w(TAG, "Couldn't delete ${trash.path}", it) }
}

// Phones drop DNS and connections while they switch networks, so connecting retries.
Expand Down Expand Up @@ -521,27 +559,14 @@ fun openPgpFingerprint(armored: String): String {

fun megabytes(bytes: Long): String = "%.1f MB".format(bytes / 1_000_000.0)

/** Deletes a tree without following symlinks, making read-only directories writable first. */
/** Deletes a tree without following symlinks, first giving its owner every directory in full. */
fun deleteTree(root: File) {
val start = root.toPath()
if (!Files.exists(start, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(start, object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attributes: BasicFileAttributes): FileVisitResult {
dir.toFile().setWritable(true, true)
dir.toFile().setExecutable(true, true)
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attributes: BasicFileAttributes): FileVisitResult {
Files.delete(file)
return FileVisitResult.CONTINUE
}

override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
Files.delete(dir)
return FileVisitResult.CONTINUE
}
})
val path = root.toPath()
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
Os.chmod(root.path, OWNER_ALL)
Files.newDirectoryStream(path).use { it.toList() }.forEach { deleteTree(it.toFile()) }
}
Files.deleteIfExists(path)
}

private fun ByteArray.int32(at: Int): Int =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class T3DeviceRuntimeModule : Module() {
mapOf(
"supported" to runtime.supported,
"storageAccess" to hasStorageAccess(requireContext()),
"installed" to installedMarker(runtime).isFile,
"installed" to installed(runtime),
"serverRunning" to DeviceServer.running,
"job" to jobState,
"login" to loginState,
Expand Down Expand Up @@ -138,7 +138,7 @@ class T3DeviceRuntimeModule : Module() {
// The server keeps running its old build until the app restarts it.
AsyncFunction("update") { promise: Promise ->
val context = requireContext()
if (!installedMarker(DeviceRuntime(context) {}).isFile) {
if (!installed(DeviceRuntime(context) {})) {
throw CodedException("ERR_DEVICE_RUNTIME_MISSING", "The on-device runtime isn't installed.", null)
}
startJob(context, "update", UPDATE_STEPS, promise) { runtime, step, script ->
Expand Down Expand Up @@ -235,6 +235,8 @@ class T3DeviceRuntimeModule : Module() {

private fun installedMarker(runtime: DeviceRuntime) = File(runtime.home, "setup.done")

private fun installed(runtime: DeviceRuntime) = installedMarker(runtime).isFile && runtime.ubuntuInstalled

private fun <T> inBackground(promise: Promise, body: () -> T) {
work.execute {
try {
Expand Down Expand Up @@ -266,6 +268,7 @@ class T3DeviceRuntimeModule : Module() {
jobs.execute {
var failure: String? = null
try {
if (kind == "install") installedMarker(runtime).delete()
val script = context.assets.open("device-runtime-setup.sh").bufferedReader().readText()
for ((index, step) in steps.withIndex()) {
setJobState(kind, true, step, index, steps.size, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,12 @@ export function DeviceRuntimeSetupScreen() {
onCancel={() => native.cancelJob()}
/>
),
signIn: <SignInStepBody status={status} checking={status.installed && inspection === null} />,
signIn: (
<SignInStepBody
status={status}
checking={status.installed && inspection === null && error === null}
/>
),
connect: (
<>
<StepText>Starts T3 Code's server on this phone and adds it to your environments.</StepText>
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/src/features/phone/phone-sharing-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () =>
deregisterFailure: null as string | null,
saveFailure: null as string | null,
modeFailure: null as string | null,
phoneEnvironmentId: environmentId as EnvironmentId | null,
};
const setSharingMode = vi.fn(async () => state.modeFailure);
const stopOnServer = vi.fn(async () => {
Expand All @@ -50,8 +51,8 @@ function setup(initial: PhoneSharingRecord | null = null, hydrated = async () =>
if (state.saveFailure !== null) throw new Error(state.saveFailure);
state.record = record;
},
phoneEnvironmentId: () => environmentId,
connectionFor: () => (state.connected ? connection : null),
phoneEnvironmentId: () => state.phoneEnvironmentId,
connectionFor: (id) => (state.connected && id === environmentId ? connection : null),
signedInAccount: () => state.account,
readSharing: async () => (state.connected ? state.sharing : null),
ensureRelayClient: async () => null,
Expand Down Expand Up @@ -306,6 +307,21 @@ describe("createPhoneSharingController", () => {
expect(state.record).toBeNull();
});

it("lets go of a stop or share whose server Ubuntu's reinstall replaced", async () => {
for (const phase of ["stopping", "on"] as const) {
const old = EnvironmentId.make("old-phone");
const { state, controller, stopOnServer, deregister } = setup({
environmentId: old,
accountId: "user_a",
phase,
});
expect(await controller.reconcile()).toBeNull();
expect(stopOnServer).not.toHaveBeenCalled();
expect(deregister).toHaveBeenCalledExactlyOnceWith("user_a", old);
expect(state.record).toBeNull();
}
});

it("keeps the listing's record through a release while signed out", async () => {
const { state, controller } = setup(on);
state.connected = false;
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/phone/phone-sharing-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,27 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) {
return run;
};

/**
* The phone runs one server, so another one answering under a new id means Ubuntu was
* reinstalled, and the old server's tunnel went with it. Only its listing can be left.
*/
const serverReplaced = (record: PhoneSharingRecord) => {
const current = effects.phoneEnvironmentId();
return (
current !== null &&
current !== record.environmentId &&
effects.connectionFor(record.environmentId) === null
);
};

/** Works a stopping or listed record as far as it can go. Null means nothing is left to do. */
const settle = async (): Promise<string | null> => {
let record = effects.loadRecord();
if (record === null || record.phase === "on") return null;
if (record.phase === "stopping" && serverReplaced(record)) {
record = { ...record, phase: "listed" };
await effects.saveRecord(record);
}
if (record.phase === "stopping") {
const connection = effects.connectionFor(record.environmentId);
if (connection === null) return STOP_UNFINISHED;
Expand Down Expand Up @@ -176,7 +193,11 @@ export function createPhoneSharingController(effects: PhoneSharingEffects) {
const account = effects.signedInAccount();
const connection = effects.connectionFor(record.environmentId);
const sharing = connection === null ? null : await effects.readSharing(connection);
if ((account !== null && account !== record.accountId) || sharing?.shared === false) {
if (
(account !== null && account !== record.accountId) ||
sharing?.shared === false ||
serverReplaced(record)
) {
await effects.saveRecord({ ...record, phase: "stopping" });
}
}
Expand Down
Loading
Loading