From 4888903160229bc6af946c4aca6364030f8fb9a9 Mon Sep 17 00:00:00 2001 From: AndroidX Test Team Date: Mon, 21 Oct 2024 16:54:43 -0700 Subject: [PATCH] Removing the FileObserver protocol that intended to replace SpeakEasy, as the LocalSocket protocol is superior and the FileObserver protocol was never adopted. PiperOrigin-RevId: 688320259 --- services/CHANGELOG.md | 3 +- .../test/services/shellexecutor/BUILD | 30 --- .../shellexecutor/CoroutineFileObserver.kt | 197 ---------------- .../shellexecutor/FileObserverProtocol.kt | 123 ---------- .../shellexecutor/FileObserverShellMain.kt | 86 ------- .../ShellCommandFileObserverClient.kt | 112 --------- .../ShellCommandFileObserverExecutorServer.kt | 223 ------------------ .../shellexecutor/ShellExecutorFactory.java | 7 +- .../ShellExecutorFileObserverImpl.kt | 97 -------- .../test/services/shellexecutor/BUILD | 28 --- .../ShellCommandFileObserverClientTest.kt | 133 ----------- ...llCommandFileObserverExecutorServerTest.kt | 170 ------------- 12 files changed, 4 insertions(+), 1205 deletions(-) delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/CoroutineFileObserver.kt delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverProtocol.kt delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverShellMain.kt delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverClient.kt delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServer.kt delete mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFileObserverImpl.kt delete mode 100644 services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverClientTest.kt delete mode 100644 services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServerTest.kt diff --git a/services/CHANGELOG.md b/services/CHANGELOG.md index 8edd877ae..85417e9fb 100644 --- a/services/CHANGELOG.md +++ b/services/CHANGELOG.md @@ -15,7 +15,8 @@ ShellMain. This obsoletes SpeakEasy; if androidx.test.services is killed (e.g. by the low memory killer) between the start of the app_process that invokes LocalSocketShellMain and the start of the test, the test is still able - to talk to LocalSocketShellMain. + to talk to LocalSocketShellMain. The FileObserver-based protocol has been + removed. **Breaking Changes** diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD index fa3373d20..372bd63a2 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD @@ -7,28 +7,6 @@ package(default_applicable_licenses = ["//services:license"]) licenses(["notice"]) -kt_android_library( - name = "coroutine_file_observer", - srcs = [ - "CoroutineFileObserver.kt", - ], - visibility = ["//visibility:private"], - deps = [ - "@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core", - ], -) - -kt_android_library( - name = "file_observer_protocol", - srcs = [ - "FileObserverProtocol.kt", - "Messages.kt", - ], - visibility = [ - "//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__", - ], -) - proto_library( name = "local_socket_protocol_pb", srcs = ["local_socket_protocol.proto"], @@ -59,12 +37,10 @@ kt_android_library( name = "exec_server", srcs = [ "BlockingPublish.java", - "FileObserverShellMain.kt", "LocalSocketShellMain.kt", "ShellCommand.java", "ShellCommandExecutor.java", "ShellCommandExecutorServer.java", - "ShellCommandFileObserverExecutorServer.kt", "ShellCommandLocalSocketExecutorServer.kt", "ShellExecSharedConstants.java", "ShellMain.java", @@ -72,8 +48,6 @@ kt_android_library( idl_srcs = ["Command.aidl"], visibility = [":export"], deps = [ - ":coroutine_file_observer", - ":file_observer_protocol", ":local_socket_protocol", ":local_socket_protocol_pb_java_proto_lite", "//services/speakeasy/java/androidx/test/services/speakeasy:protocol", @@ -91,20 +65,16 @@ kt_android_library( "ClientNotConnected.java", "ShellCommand.java", "ShellCommandClient.java", - "ShellCommandFileObserverClient.kt", "ShellCommandLocalSocketClient.kt", "ShellExecSharedConstants.java", "ShellExecutor.java", "ShellExecutorFactory.java", - "ShellExecutorFileObserverImpl.kt", "ShellExecutorImpl.java", "ShellExecutorLocalSocketImpl.kt", ], idl_srcs = ["Command.aidl"], visibility = [":export"], deps = [ - ":coroutine_file_observer", - ":file_observer_protocol", ":local_socket_protocol", ":local_socket_protocol_pb_java_proto_lite", "//services/speakeasy/java/androidx/test/services/speakeasy:protocol", diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/CoroutineFileObserver.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/CoroutineFileObserver.kt deleted file mode 100644 index 1888f5483..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/CoroutineFileObserver.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import android.os.Build -import android.os.FileObserver -import android.util.Log -import java.io.File -import java.util.concurrent.LinkedBlockingQueue -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.onClosed -import kotlinx.coroutines.channels.onFailure -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.runBlocking - -/** - * A FileObserver that is friendly with Kotlin coroutines. - * - * Note that the documentation on FileObserver is wrong: it doesn't see events from subdirectories. - */ -@Suppress("DEPRECATION") // the non-deprecated constructor needs API 29 -open class CoroutineFileObserver(public val watch: File) : - FileObserver(watch.toString(), FileObserver.ALL_EVENTS) { - private data class Event(val event: Int, val file: File) - - private val eventChannel: EventChannel - protected var logLevel: Int = 0 // by default, don't log at all; derived classes can override - protected var logTag = "CoroutineFileObserver" - - init { - // On API 21 and 22, about 1% of the time, Channel code will deadlock and, thirty seconds later, - // crash the application with 'art/runtime/thread_list.cc:170] Thread suspend timeout'. In that - // case, we resort to Java. - if ( - Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || - Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1 - ) { - eventChannel = CoroutineEventChannel() - } else { - eventChannel = WorkaroundEventChannel() - } - } - - // This runs on a special FileObserver thread provided by Android. - final override fun onEvent(event: Int, path: String?) { - val file = - when { - path == null -> watch - path.startsWith("/") -> File(path) - else -> File(watch, path) - } - eventChannel.send(Event(event, file)) - } - - final fun stop(): Unit = eventChannel.stop() - - // Events are processed in order by run(). If you do nontrivial work in one of the handlers, - // launch it in another job. - final suspend fun run() { - startWatching() - try { - onWatching() - eventChannel.receive { event -> handleEvent(event) } - } catch (x: Exception) { - Log.w(logTag, "Error while processing events", x) - } finally { - log("stopWatching") - stopWatching() - log("stoppedWatching") - } - } - - private suspend fun handleEvent(event: Event) = - when (event.event) { - FileObserver.ACCESS -> onAccess(event.file) - FileObserver.ATTRIB -> onAttrib(event.file) - FileObserver.CLOSE_NOWRITE -> onCloseNoWrite(event.file) - FileObserver.CLOSE_WRITE -> onCloseWrite(event.file) - FileObserver.CREATE -> onCreate(event.file) - FileObserver.DELETE -> onDelete(event.file) - FileObserver.DELETE_SELF -> onDeleteSelf(event.file) - FileObserver.MODIFY -> onModify(event.file) - FileObserver.MOVED_FROM -> onMovedFrom(event.file) - FileObserver.MOVED_TO -> onMovedTo(event.file) - FileObserver.MOVE_SELF -> onMoveSelf(event.file) - FileObserver.OPEN -> onOpen(event.file) - else -> Unit - } - - protected final fun log(message: String): Unit { - if (logLevel >= Log.VERBOSE) Log.println(logLevel, logTag, message) - } - - protected open suspend fun onAccess(file: File) = log("ACCESS $file") - - protected open suspend fun onAttrib(file: File) = log("ATTRIB $file") - - protected open suspend fun onCloseNoWrite(file: File) = log("CLOSE_NOWRITE $file") - - protected open suspend fun onCloseWrite(file: File) = log("CLOSE_WRITE $file") - - protected open suspend fun onCreate(file: File) = log("CREATE $file") - - protected open suspend fun onDelete(file: File) = log("DELETE $file") - - protected open suspend fun onDeleteSelf(file: File) = log("DELETE_SELF $file") - - protected open suspend fun onModify(file: File) = log("MODIFY $file") - - protected open suspend fun onMovedFrom(file: File) = log("MOVED_FROM $file") - - protected open suspend fun onMovedTo(file: File) = log("MOVED_TO $file") - - protected open suspend fun onMoveSelf(file: File) = log("MOVE_SELF $file") - - protected open suspend fun onOpen(file: File) = log("OPEN $file") - - /** Called in run() after startWatching(). Override as needed. */ - protected open fun onWatching() = Unit - - private companion object { - private interface EventChannel { - /** Send one event. */ - fun send(event: Event) - - /** Receive events until stop() is called. */ - suspend fun receive(handler: suspend (Event) -> Unit) - - /** Stops the channel. */ - fun stop() - } - - private class CoroutineEventChannel : EventChannel { - private val channel: Channel = Channel(Channel.UNLIMITED) - - override fun send(event: Event) { - runBlocking { - try { - channel - .trySendBlocking(event) - .onFailure { t: Throwable? -> - Log.w("CoroutineFileObserver", "Error while sending $event", t) - } - .onClosed { t: Throwable? -> - Log.v("CoroutineFileObserver", "Event channel closed to $event", t) - } - } catch (x: InterruptedException) { - Log.w("CoroutineFileObserver", "Interrupted while sending $event", x) - } - } - } - - override suspend fun receive(handler: suspend (Event) -> Unit) { - channel.receiveAsFlow().collect(handler) - } - - override fun stop() { - channel.close() - } - } - - private class WorkaroundEventChannel : EventChannel { - private val queue = LinkedBlockingQueue() - - override fun send(event: Event) { - queue.put(event) - } - - override suspend fun receive(handler: suspend (Event) -> Unit) { - while (true) { - val event = queue.take() - if (event.event < 0) return - handler(event) - } - } - - override fun stop() { - send(Event(-1, File("."))) - } - } - } -} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverProtocol.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverProtocol.kt deleted file mode 100644 index 099e86fce..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverProtocol.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import java.io.OutputStreamWriter -import java.io.PrintWriter -import java.util.UUID - -/** - * The protocol for communicating by FileObserver is: - * 1. The server creates the server directory in /data/local/tmp. - * 2. The client creates [UUID].request in the server directory. - * 3. The server reads and deletes [UUID].request, then writes [UUID].response. - * 4. The client reads and deletes [UUID].response. - * - * The underlying communication is handled by inotify, which only generates events for the - * directories it is explicitly watching. (The FileObserver documentation makes it sound like it can - * pick things up in subdirectories; this is erroneous.) - * - * The underlying directory and file are set world-readable and -writable so the client can write - * the request and read the response. Because this only works when someone is already running - * FileObserverShellMain, there is very little threat here; if someone is able to put a program onto - * your test device that can watch /data/local/tmp for the appearance of the exchange directory, you - * have bigger problems than whatever it's going to do with root privileges. - */ -@Suppress("SetWorldReadable", "SetWorldWritable") -object FileObserverProtocol { - const val REQUEST = "request" - const val RESPONSE = "response" - - /** Creates the exchange directory with appropriate permissions. */ - fun createExchangeDir(commonDir: File): File { - val exchangeDir = File.createTempFile("androidx", ".tmp", commonDir) - exchangeDir.delete() - exchangeDir.mkdir() - exchangeDir.setReadable(/* readable= */ true, /* ownerOnly= */ false) - exchangeDir.setWritable(/* writable= */ true, /* ownerOnly= */ false) - exchangeDir.setExecutable(/* executable= */ true, /* ownerOnly= */ false) - return exchangeDir - } - - /** - * Writes a request file to the exchange directory. - * - * @return the location for the response file - */ - fun writeRequestFile(exchangeDir: File, message: Messages.Command): File { - val stem = UUID.randomUUID().toString() - val request = File(exchangeDir, "${stem}.$REQUEST") - request.outputStream().use { - request.setReadable(/* readable= */ true, /* ownerOnly= */ false) - request.setWritable(/* writable= */ true, /* ownerOnly= */ false) - message.writeTo(it) - } - return File(exchangeDir, "${stem}.response") - } - - fun isRequestFile(file: File) = file.name.endsWith(".$REQUEST") - - fun calculateResponseFile(requestFile: File) = - File(requestFile.parentFile, "${requestFile.name.split(".").first()}.$RESPONSE") - - /** Reads and deletes the request file */ - fun readRequestFile(request: File): Messages.Command { - val command: Messages.Command - request.inputStream().use { command = Messages.Command.readFrom(it) } - request.delete() - return command - } - - /** Writes the response file */ - fun writeResponseFile(path: File, result: Messages.CommandResult) { - path.outputStream().use { - path.setReadable(/* readable= */ true, /* ownerOnly= */ false) - path.setWritable(/* writable= */ true, /* ownerOnly= */ false) - result.writeTo(it) - } - } - - /** Reads and deletes the response file. */ - fun readResponseFile(response: File): Messages.CommandResult { - try { - val result: Messages.CommandResult - response.inputStream().use { result = Messages.CommandResult.readFrom(it) } - response.delete() - return result - } catch (x: IOException) { - return Messages.CommandResult( - resultType = Messages.ResultType.CLIENT_ERROR, - stderr = x.toByteArray() - ) - } - } -} - -/** - * Writes an exception stack trace to a ByteArray as UTF-8, to make them easy to pass through - * Messages.CommandResult. - */ -public fun Exception.toByteArray(): ByteArray { - val bos = ByteArrayOutputStream() - val pw = PrintWriter(OutputStreamWriter(bos, Charsets.UTF_8)) - printStackTrace(pw) - pw.close() - return bos.toByteArray() -} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverShellMain.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverShellMain.kt deleted file mode 100644 index 35a05fe33..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/FileObserverShellMain.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import android.util.Log -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.Executors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -/** Main runner class for the ShellCommandFileObserverExecutorServer. */ -class FileObserverShellMain { - - suspend fun run(args: Array): Int { - val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) - val server = ShellCommandFileObserverExecutorServer(scope = scope) - server.start() - - val processArgs = args.toMutableList() - processArgs.addAll( - processArgs.size - 1, - listOf("-e", ShellExecSharedConstants.BINDER_KEY, server.exchangeDir.toString()) - ) - val pb = ProcessBuilder(processArgs.toList()) - - var exitCode: Int - - try { - val process = pb.start() - - val stdinCopier = scope.launch { copyStream("stdin", System.`in`, process.outputStream) } - val stdoutCopier = scope.launch { copyStream("stdout", process.inputStream, System.out) } - val stderrCopier = scope.launch { copyStream("stderr", process.errorStream, System.err) } - - exitCode = process.waitFor() - - stdinCopier.cancel() // System.`in`.close() does not force input.read() to return - stdoutCopier.join() - stderrCopier.join() - } finally { - server.stop() - } - return exitCode - } - - suspend fun copyStream(name: String, input: InputStream, output: OutputStream) { - val buf = ByteArray(1024) - try { - while (true) { - val size = input.read(buf) - if (size == -1) break - output.write(buf, 0, size) - } - output.flush() - } catch (x: IOException) { - Log.e(TAG, "IOException on $name. Terminating.", x) - } - } - - companion object { - private const val TAG = "FileObserverShellMain" - - @JvmStatic - public fun main(args: Array) { - System.exit(runBlocking { FileObserverShellMain().run(args) }) - } - } -} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverClient.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverClient.kt deleted file mode 100644 index 989fbcf63..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverClient.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import java.io.File -import java.io.InputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.util.concurrent.Executors -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Semaphore - -/** - * Client that sends requests to the ShellCommandFileObserverExecutorServer. - * - * This client is designed to be callable from Java. - */ -public final class ShellCommandFileObserverClient { - private val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) - - public final fun run(secret: String, message: Messages.Command): Execution { - val execution = Execution(File(secret), message) - execution.start() - return execution - } - - public inner class Execution - internal constructor(val exchangeDir: File, val message: Messages.Command) { - private val messageWritten: Semaphore = Semaphore(1, 1) - private val client = Client(exchangeDir, messageWritten, message) - private lateinit var clientJob: Job - - internal fun start() { - runBlocking { clientJob = scope.launch { client.run() } } - } - - /** Blocks until the message has been written. */ - public fun waitForMessageWritten() { - runBlocking { messageWritten.acquire() } - } - - /** Standard method for obtaining the response. */ - public fun await(): Messages.CommandResult { - runBlocking { clientJob.join() } - return client.result - } - - /** Alternative method for compatibility with methods that expect only an InputStream. */ - public fun asStream(): InputStream { - val output = PipedOutputStream() - val input = PipedInputStream(output) - runBlocking { - scope.launch { - clientJob.join() - output.use { it.write(client.result.stdout) } - } - } - return input - } - } - - private inner class Client( - val exchangeDir: File, - val messageWritten: Semaphore, - val message: Messages.Command - ) : CoroutineFileObserver(exchangeDir) { - private lateinit var response: File - public lateinit var result: Messages.CommandResult - - init { - // Uncomment this line to see the event-level chatter. - // logLevel = Log.INFO - logTag = "${TAG}.Client" - } - - override fun onWatching() { - // Wait to write the request file until we're sure we'll see the response. - response = FileObserverProtocol.writeRequestFile(exchangeDir, message) - // Make sure any interested parties are notified that we've finished creating the request. - runBlocking { messageWritten.release() } - } - - override suspend fun onCloseWrite(file: File) { - super.onCloseWrite(file) - if (file != response) return - result = FileObserverProtocol.readResponseFile(response) - stop() - } - } - - private companion object { - const val TAG = "SCFOC" - } -} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServer.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServer.kt deleted file mode 100644 index d971bb0bd..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServer.kt +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import android.os.Build -import android.util.Log -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.withTimeout - -/** - * Server that handles requests from the ShellCommandFileObserverClient. - * - * This server should be easily callable from Java. - * - * On API 28, System.getProperty("java.io.tmpdir") returns "/tmp", which does not exist on a - * standard emulator! /data/local/tmp seems to be a reliable location. - */ -final class ShellCommandFileObserverExecutorServer -@JvmOverloads -constructor( - private val commonDir: File = File("/data/local/tmp"), - private val scope: CoroutineScope = - CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) -) { - @JvmField val exchangeDir: File - - private lateinit var server: Server - private lateinit var serverJob: Job - - init { - exchangeDir = FileObserverProtocol.createExchangeDir(commonDir) - } - - fun start() { - server = Server(exchangeDir) - runBlocking { - serverJob = scope.launch { server.run() } - server.waitForReady() - } - } - - fun stop() { - server.stop() - runBlocking { serverJob.join() } - try { - exchangeDir.delete() - } catch (x: IOException) { - Log.e(TAG, "Couldn't delete $exchangeDir", x) - } - } - - private final inner class Server(directory: File) : CoroutineFileObserver(directory) { - - private val ready: Semaphore = Semaphore(1, 1) - - init { - // Uncomment this line to see the event-level chatter. - // logLevel = Log.INFO - logTag = "${TAG}.Server" - } - - override fun onWatching() { - ready.release() - } - - suspend fun waitForReady() { - ready.acquire() - } - - override suspend fun onCloseWrite(file: File) { - super.onCloseWrite(file) - if (!FileObserverProtocol.isRequestFile(file)) return - if (file.isDirectory()) { - Log.w(logTag, "$file is a directory") - return - } - if (!file.canRead()) { - Log.w(logTag, "$file cannot be read") - return - } - coroutineScope { launch { handleCommand(file) } } - } - - suspend fun handleCommand(request: File) { - val response = FileObserverProtocol.calculateResponseFile(request) - val command: Messages.Command - try { - command = FileObserverProtocol.readRequestFile(request) - } catch (x: IOException) { - Log.e(logTag, "Couldn't parse command in $request", x) - FileObserverProtocol.writeResponseFile( - response, - Messages.CommandResult( - resultType = Messages.ResultType.SERVER_ERROR, - stderr = x.toByteArray() - ) - ) - return - } - - val process: Process - try { - val argv = mutableListOf() - if (command.executeThroughShell) argv.addAll(listOf("sh", "-c")) - argv.add(command.command) - argv.addAll(command.parameters) - - val pb = ProcessBuilder(argv) - pb.environment().putAll(command.shellEnv) - pb.redirectErrorStream(command.redirectErrorStream) - - process = pb.start() - process.outputStream.close() - - val exitCode = - process.onTimeout(command.timeoutMs) { - // The input streams are not yet closed, so don't try reading to EOF. Instead, read all - // available bytes. (Calling process.destroy() first causes InputStream.available() - // to throw "java.io.IOException: Stream closed", so doing the read after the destroy - // won't work.) - FileObserverProtocol.writeResponseFile( - response, - Messages.CommandResult( - resultType = Messages.ResultType.TIMED_OUT, - stdout = process.inputStream.availableToByteArray(), - stderr = - if (!command.redirectErrorStream) { - process.errorStream.availableToByteArray() - } else { - ByteArray(0) - } - ) - ) - } - - if (exitCode < 0) return // timed out - - FileObserverProtocol.writeResponseFile( - response, - Messages.CommandResult( - resultType = Messages.ResultType.EXITED, - exitCode, - stdout = process.inputStream.readBytes(), - stderr = - if (!command.redirectErrorStream) process.errorStream.readBytes() else ByteArray(0) - ) - ) - } catch (x: Exception) { - FileObserverProtocol.writeResponseFile( - response, - Messages.CommandResult( - resultType = Messages.ResultType.SERVER_ERROR, - stderr = x.toByteArray() - ) - ) - } - } - } - - /** Hide API differences in handling timeouts. */ - private fun Process.onTimeout(timeout: Long, onTimeout: () -> Unit): Int { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!waitFor(timeout, TimeUnit.MILLISECONDS)) { - onTimeout.invoke() - destroy() - return -1 - } - return exitValue() - } else { - var exitCode: Int = -1 - runBlocking { - try { - val job = scope.async { waitFor() } - withTimeout(timeout) { exitCode = job.await() } - } catch (e: TimeoutCancellationException) { - onTimeout.invoke() - destroy() - } - } - return exitCode - } - } - - /** Use this instead of ByteString.readFrom when the stream has not yet been closed. */ - private fun InputStream.availableToByteArray(): ByteArray { - val expected = available() - if (expected == 0) return ByteArray(0) - val bytes = ByteArray(expected) - val amount = read(bytes) - return bytes.sliceArray(0..amount - 1) - } - - private companion object { - const val TAG = "SCFOES" - } -} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java index f0ea4e287..11f5639b7 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java @@ -30,13 +30,10 @@ public ShellExecutorFactory(Context context, String binderKey) { } public ShellExecutor create() { - // Binder keys for SpeakEasy are a string of hex digits. Binder keys for the FileObserver - // protocol are the absolute path of the directory that the server is watching. Binder keys for - // the LocalSocket protocol start and end with a colon. + // Binder keys for SpeakEasy are a string of hex digits. Binder keys for the LocalSocket + // protocol start and end with a colon. if (LocalSocketProtocol.isBinderKey(binderKey)) { return new ShellExecutorLocalSocketImpl(binderKey); - } else if (binderKey.startsWith("/")) { - return new ShellExecutorFileObserverImpl(binderKey); } else { return new ShellExecutorImpl(context, binderKey); } diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFileObserverImpl.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFileObserverImpl.kt deleted file mode 100644 index 20a0d0cb0..000000000 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFileObserverImpl.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import java.io.InputStream - -/** ShellExecutor that talks to FileObserverShellMain. */ -class ShellExecutorFileObserverImpl(private val binderKey: String) : ShellExecutor { - private val client = ShellCommandFileObserverClient() - - override fun getBinderKey() = binderKey - - /** {@inheritDoc} */ - override fun executeShellCommandSync( - command: String?, - parameters: List?, - shellEnv: Map?, - executeThroughShell: Boolean, - timeoutMs: Long - ): String { - if (command == null || command.isEmpty()) { - throw IllegalArgumentException("Null or empty command") - } - - val message = - Messages.Command( - command, - parameters ?: emptyList(), - shellEnv ?: emptyMap(), - executeThroughShell, - redirectErrorStream = true, - if (timeoutMs > 0L) timeoutMs else TIMEOUT_FOREVER - ) - - val execution = client.run(binderKey, message) - return execution.await().stdout.toString(Charsets.UTF_8) - } - - /** {@inheritDoc} */ - override fun executeShellCommandSync( - command: String?, - parameters: List?, - shellEnv: Map?, - executeThroughShell: Boolean - ) = executeShellCommandSync(command, parameters, shellEnv, executeThroughShell, TIMEOUT_FOREVER) - - /** {@inheritDoc} */ - override fun executeShellCommand( - command: String?, - parameters: List?, - shellEnv: Map?, - executeThroughShell: Boolean, - timeoutMs: Long - ): InputStream { - if (command == null || command.isEmpty()) { - throw IllegalArgumentException("Null or empty command") - } - - val message = - Messages.Command( - command, - parameters ?: emptyList(), - shellEnv ?: emptyMap(), - executeThroughShell, - redirectErrorStream = true, - if (timeoutMs > 0L) timeoutMs else TIMEOUT_FOREVER - ) - - return client.run(binderKey, message).asStream() - } - - /** {@inheritDoc} */ - override fun executeShellCommand( - command: String?, - parameters: List?, - shellEnv: Map?, - executeThroughShell: Boolean - ) = executeShellCommand(command, parameters, shellEnv, executeThroughShell, TIMEOUT_FOREVER) - - companion object { - const val TIMEOUT_FOREVER = 24 * 60 * 60 * 1000L - } -} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD index 2c8ee95c6..d833cb2f0 100644 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD @@ -66,20 +66,6 @@ axt_android_library_test( ], ) -axt_android_library_test( - name = "ShellCommandFileObserverClientTest", - srcs = [ - "ShellCommandFileObserverClientTest.kt", - ], - deps = [ - "//runner/monitor", - "//services/shellexecutor:exec_client", - "//services/shellexecutor/java/androidx/test/services/shellexecutor:file_observer_protocol", - "@maven//:com_google_truth_truth", - "@maven//:junit_junit", - ], -) - axt_android_library_test( name = "ShellCommandLocalSocketClientTest", srcs = [ @@ -96,20 +82,6 @@ axt_android_library_test( ], ) -axt_android_library_test( - name = "ShellCommandFileObserverExecutorServerTest", - srcs = [ - "ShellCommandFileObserverExecutorServerTest.kt", - ], - deps = [ - "//runner/monitor", - "//services/shellexecutor:exec_server", - "//services/shellexecutor/java/androidx/test/services/shellexecutor:file_observer_protocol", - "@maven//:com_google_truth_truth", - "@maven//:junit_junit", - ], -) - axt_android_library_test( name = "ShellCommandLocalSocketExecutorServerTest", srcs = [ diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverClientTest.kt b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverClientTest.kt deleted file mode 100644 index 81955b517..000000000 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverClientTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import android.content.Context -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Expect -import com.google.common.truth.Truth.assertThat -import java.io.File -import java.io.FileNotFoundException -import java.io.FilenameFilter -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class ShellCommandFileObserverClientTest { - @get:Rule final var expect = Expect.create() - val exchangeDir = - InstrumentationRegistry.getInstrumentation().getContext().getDir("SCFOCT", Context.MODE_PRIVATE) - lateinit var client: ShellCommandFileObserverClient - - @Before - fun setUp() { - client = ShellCommandFileObserverClient() - } - - private fun getRequestFile(): File { - val files = - exchangeDir.listFiles( - object : FilenameFilter { - override fun accept(dir: File, name: String) = name.endsWith(".request") - } - ) - if (files == null) throw FileNotFoundException() - return files[0] - } - - @Test - fun success_await() { - val execution = - client.run( - exchangeDir.toString(), - Messages.Command( - "command", - listOf("parameters"), - mapOf("name" to "value"), - true, - true, - 1234L - ) - ) - execution.waitForMessageWritten() - val requestFile = getRequestFile() - val request = FileObserverProtocol.readRequestFile(requestFile) - val responseFile = FileObserverProtocol.calculateResponseFile(requestFile) - FileObserverProtocol.writeResponseFile( - responseFile, - Messages.CommandResult( - Messages.ResultType.EXITED, - 123, - "stdout".toByteArray(Charsets.UTF_8), - "stderr".toByteArray(Charsets.UTF_8) - ) - ) - - val response = execution.await() - - expect.that(request.command).isEqualTo("command") - expect.that(request.parameters).containsExactly("parameters") - expect.that(request.shellEnv).containsExactlyEntriesIn(mapOf("name" to "value")) - expect.that(request.executeThroughShell).isTrue() - expect.that(request.redirectErrorStream).isTrue() - expect.that(request.timeoutMs).isEqualTo(1234L) - expect.that(response.resultType).isEqualTo(Messages.ResultType.EXITED) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEqualTo("stdout") - expect.that(response.stderr.toString(Charsets.UTF_8)).isEqualTo("stderr") - expect.that(response.exitCode).isEqualTo(123) - } - - @Test - fun success_asStream() { - val execution = client.run(exchangeDir.toString(), Messages.Command("command", timeoutMs = 0L)) - execution.waitForMessageWritten() - val requestFile = getRequestFile() - FileObserverProtocol.readRequestFile(requestFile) - val responseFile = FileObserverProtocol.calculateResponseFile(requestFile) - - FileObserverProtocol.writeResponseFile( - responseFile, - Messages.CommandResult( - Messages.ResultType.EXITED, - 0, - "foo\nbar\nbaz\n".toByteArray(Charsets.UTF_8) - ) - ) - - val output = execution.asStream().readBytes() - - assertThat(output.toString(Charsets.UTF_8)).isEqualTo("foo\nbar\nbaz\n") - } - - @Test - fun failure_malformedResponse() { - val execution = client.run(exchangeDir.toString(), Messages.Command("command", timeoutMs = 0L)) - execution.waitForMessageWritten() - val requestFile = getRequestFile() - FileObserverProtocol.readRequestFile(requestFile) - val responseFile = FileObserverProtocol.calculateResponseFile(requestFile) - responseFile.writeText("Potrzebie!") - val response = execution.await() - expect.that(response.resultType).isEqualTo(Messages.ResultType.CLIENT_ERROR) - expect - .that(response.stderr.toString(Charsets.UTF_8)) - .startsWith("java.io.StreamCorruptedException") - } -} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServerTest.kt b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServerTest.kt deleted file mode 100644 index af88bba02..000000000 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandFileObserverExecutorServerTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.test.services.shellexecutor - -import android.content.Context -import android.os.Build -import android.util.Log -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Expect -import java.io.File -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 - -@RunWith(JUnit4::class) -class ShellCommandFileObserverExecutorServerTest { - @get:Rule final var expect = Expect.create() - lateinit var server: ShellCommandFileObserverExecutorServer - - @Before - fun setUp() { - server = - ShellCommandFileObserverExecutorServer( - InstrumentationRegistry.getInstrumentation() - .getContext() - .getDir("SCFOEST", Context.MODE_PRIVATE) - ) - server.start() - // The server is stopped in the test, not in tearDown(), because that's the way to guarantee - // that the response file has been fully written and closed. - } - - @Test - fun success_direct() { - val responseFile = - FileObserverProtocol.writeRequestFile( - server.exchangeDir, - Messages.Command( - command = "sh", - parameters = listOf("-c", "echo ${'$'}{POTRZEBIE}"), - shellEnv = mapOf("POTRZEBIE" to "furshlugginer"), - timeoutMs = 1000L - ) - ) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - Log.i("SCFOEST", response.toString()) - expect.that(response.resultType).isEqualTo(Messages.ResultType.EXITED) - expect.that(response.exitCode).isEqualTo(0) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEqualTo("furshlugginer\n") - expect.that(response.stderr.toString(Charsets.UTF_8)).isEmpty() - } - - @Test - fun success_executeThroughShell() { - val responseFile = - FileObserverProtocol.writeRequestFile( - server.exchangeDir, - Messages.Command( - command = "echo ${'$'}{POTRZEBIE}", - shellEnv = mapOf("POTRZEBIE" to "furshlugginer"), - executeThroughShell = true, - timeoutMs = 1000L - ) - ) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - expect.that(response.resultType).isEqualTo(Messages.ResultType.EXITED) - expect.that(response.exitCode).isEqualTo(0) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEqualTo("furshlugginer\n") - expect.that(response.stderr.toString(Charsets.UTF_8)).isEmpty() - } - - @Test - fun success_nonzeroExit() { - val responseFile = - FileObserverProtocol.writeRequestFile( - server.exchangeDir, - Messages.Command(command = "exit 123", executeThroughShell = true, timeoutMs = 1000L) - ) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - expect.that(response.resultType).isEqualTo(Messages.ResultType.EXITED) - expect.that(response.exitCode).isEqualTo(123) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEmpty() - expect.that(response.stderr.toString(Charsets.UTF_8)).isEmpty() - } - - @Test - fun timeout() { - // Echo something then sleep for longer than the timeout. Show we get the timeout; see if we - // get the echoed message. - val responseFile = - FileObserverProtocol.writeRequestFile( - server.exchangeDir, - Messages.Command( - command = "echo ${'$'}{POTRZEBIE} && sleep 10", - shellEnv = mapOf("POTRZEBIE" to "furshlugginer"), - executeThroughShell = true, - timeoutMs = 1000L - ) - ) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - expect.that(response.resultType).isEqualTo(Messages.ResultType.TIMED_OUT) - expect.that(response.exitCode).isEqualTo(-1) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEqualTo("furshlugginer\n") - expect.that(response.stderr.toString(Charsets.UTF_8)).isEmpty() - } - - @Test - fun malformed() { - val bogus = File(server.exchangeDir, "bogus.request") - bogus.writeText("Potrzebie!") - val responseFile = FileObserverProtocol.calculateResponseFile(bogus) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - expect.that(response.resultType).isEqualTo(Messages.ResultType.SERVER_ERROR) - expect.that(response.exitCode).isEqualTo(-1) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEmpty() - expect - .that(response.stderr.toString(Charsets.UTF_8)) - .startsWith("java.io.StreamCorruptedException") - } - - @Test - fun emptyCommand() { - val responseFile = - FileObserverProtocol.writeRequestFile( - server.exchangeDir, - Messages.Command(command = "", timeoutMs = 1000L) - ) - while (!responseFile.exists()) Thread.sleep(10L) - server.stop() - val response = FileObserverProtocol.readResponseFile(responseFile) - expect.that(response.resultType).isEqualTo(Messages.ResultType.SERVER_ERROR) - expect.that(response.exitCode).isEqualTo(-1) - expect.that(response.stdout.toString(Charsets.UTF_8)).isEmpty() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - expect - .that(response.stderr.toString(Charsets.UTF_8)) - .startsWith("java.io.IOException: Cannot run program") - } else { - expect - .that(response.stderr.toString(Charsets.UTF_8)) - .startsWith("java.io.IOException: Error running exec()") - } - } -}