diff --git a/services/CHANGELOG.md b/services/CHANGELOG.md index 9d62f882d..b8c32d723 100644 --- a/services/CHANGELOG.md +++ b/services/CHANGELOG.md @@ -11,6 +11,14 @@ **New Features** +* LocalSocketProtocol: a replacement for SpeakEasy. +* ShellCommandLocalSocketClient: client that speaks LocalSocketProtocol. +* ShellCommandLocalSocketExecutorServer: server that speaks LocalSocketProtocol. +* LocalSocketShellMain: a replacement for ShellMain. Using this in place of + ShellMain avoids the use of the SpeakEasy protocol, so androidx.test.services + can be freely killed and restarted by the operating system without breaking + tests. + **Breaking Changes** **API Changes** diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD index c1257b98d..ab0d75c07 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD @@ -29,15 +29,43 @@ kt_android_library( ], ) +proto_library( + name = "local_socket_protocol_pb", + srcs = ["local_socket_protocol.proto"], +) + +java_lite_proto_library( + name = "local_socket_protocol_pb_java_proto_lite", + visibility = [ + "//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__", + ], + deps = [":local_socket_protocol_pb"], +) + +kt_android_library( + name = "local_socket_protocol", + srcs = ["LocalSocketProtocol.kt"], + visibility = [ + "//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__", + ], + deps = [ + ":local_socket_protocol_pb_java_proto_lite", + "@com_google_protobuf//:protobuf_javalite", + "@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core", + ], +) + 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", ], @@ -46,6 +74,8 @@ kt_android_library( 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", "//services/speakeasy/java/androidx/test/services/speakeasy/client", "//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection", @@ -62,6 +92,7 @@ kt_android_library( "ShellCommand.java", "ShellCommandClient.java", "ShellCommandFileObserverClient.kt", + "ShellCommandLocalSocketClient.kt", "ShellExecSharedConstants.java", "ShellExecutor.java", "ShellExecutorFactory.java", @@ -73,6 +104,8 @@ kt_android_library( 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", "//services/speakeasy/java/androidx/test/services/speakeasy/client", "//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection", diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt new file mode 100644 index 000000000..09fb1a8d6 --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketProtocol.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2024 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.net.LocalSocket +import android.net.LocalSocketAddress +import android.util.Log +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandResponse +import com.google.protobuf.ByteString +import java.io.IOException +import java.net.URLDecoder +import java.net.URLEncoder +import kotlin.time.Duration + +/** + * Protocol for ShellCommandLocalSocketClient to talk to ShellCommandLocalSocketExecutorServer. + * + * Since androidx.test.services already includes the protobuf runtime, we aren't paying much extra + * for adding some more protos to ship back and forth, which is vastly easier to deal with than + * PersistableBundles (which don't even support ByteArray types). + * + * A conversation consists of a single RunCommandRequest from the client followed by a stream of + * RunCommandResponses from the server; the final response has an exit code. + */ +object LocalSocketProtocol { + /** Composes a RunCommandRequest and sends it over the LocalSocket. */ + fun LocalSocket.sendRequest( + secret: String, + argv: List, + env: Map? = null, + timeout: Duration, + ) { + val builder = RunCommandRequest.newBuilder() + builder.setSecret(secret) + builder.addAllArgv(argv) + env?.forEach { (k, v) -> builder.putEnvironment(k, v) } + if (timeout.isInfinite() || timeout.isNegative() || timeout == Duration.ZERO) { + builder.setTimeoutMs(0) // <= 0 means no timeout + } else { + builder.setTimeoutMs(timeout.inWholeMilliseconds) + } + builder.build().writeDelimitedTo(outputStream) + } + + /** Reads a RunCommandRequest from the LocalSocket. */ + fun LocalSocket.readRequest(): RunCommandRequest { + return RunCommandRequest.parseDelimitedFrom(inputStream)!! + } + + /** Composes a RunCommandResponse and sends it over the LocalSocket. */ + fun LocalSocket.sendResponse( + buffer: ByteArray? = null, + size: Int = 0, + exitCode: Int? = null, + ): Boolean { + val builder = RunCommandResponse.newBuilder() + buffer?.let { + val bufferSize = if (size > 0) size else it.size + builder.buffer = ByteString.copyFrom(it, 0, bufferSize) + } + // Since we're currently stuck on a version of protobuf where we don't have hasExitCode(), we + // use a magic value to indicate that exitCode is not set. When we upgrade to a newer version + // of protobuf, we can obsolete this. + if (exitCode != null) { + builder.exitCode = exitCode + } else { + builder.exitCode = HAS_NOT_EXITED + } + + try { + builder.build().writeDelimitedTo(outputStream) + } catch (x: IOException) { + // Sadly, the only way to discover that the client cut the connection is an exception that + // can only be distinguished by its text. + if (x.message.equals("Broken pipe")) { + Log.i(TAG, "LocalSocket stream closed early") + } else { + Log.w(TAG, "LocalSocket write failed", x) + } + return false + } + return true + } + + /** Reads a RunCommandResponse from the LocalSocket. */ + fun LocalSocket.readResponse(): RunCommandResponse? { + return RunCommandResponse.parseDelimitedFrom(inputStream) + } + + /** + * Is this the end of the stream? + * + * Once we upgrade to a newer version of protobuf, we can switch to hasExitCode(). + */ + fun RunCommandResponse.hasExited() = exitCode != HAS_NOT_EXITED + + /** + * Builds a binder key, given the server address and secret. Binder keys should be opaque outside + * this directory. + * + * The address can contain spaces, and since it gets passed through a command line, we need to + * encode it so it doesn't get split by argv. java.net.URLEncoder is conveniently available on all + * SDK versions. + */ + @JvmStatic + fun LocalSocketAddress.asBinderKey(secret: String) = buildString { + append(":") + append(URLEncoder.encode(name, "UTF-8")) // Will convert any : to %3A + append(":") + append(URLEncoder.encode(secret, "UTF-8")) + append(":") + } + + /** Extracts the address from a binder key. */ + @JvmStatic + fun addressFromBinderKey(binderKey: String) = + LocalSocketAddress(URLDecoder.decode(binderKey.split(":")[1], "UTF-8")) + + /** Extracts the secret from a binder key. */ + @JvmStatic + fun secretFromBinderKey(binderKey: String) = URLDecoder.decode(binderKey.split(":")[2], "UTF-8") + + /** Is this a valid binder key? */ + @JvmStatic + fun isBinderKey(maybeKey: String) = + maybeKey.startsWith(':') && maybeKey.endsWith(':') && maybeKey.split(":").size == 4 + + const val TAG = "LocalSocketProtocol" + private const val HAS_NOT_EXITED = 0xCA7F00D +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt new file mode 100644 index 000000000..e68d4c5d0 --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2024 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 kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible + +/** Variant of ShellMain that uses a LocalSocket to communicate with the client. */ +class LocalSocketShellMain { + + suspend fun run(args: Array): Int { + val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) + val server = ShellCommandLocalSocketExecutorServer(scope = scope) + server.start() + + val processArgs = args.toMutableList() + processArgs.addAll( + processArgs.size - 1, + listOf("-e", ShellExecSharedConstants.BINDER_KEY, server.binderKey()), + ) + val pb = ProcessBuilder(processArgs.toList()) + + val 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) } + + runInterruptible { process.waitFor() } + exitCode = process.exitValue() + + stdinCopier.cancel() // System.`in`.close() does not force input.read() to return + stdoutCopier.join() + stderrCopier.join() + } finally { + server.stop(100.milliseconds) + } + 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 = "LocalSocketShellMain" + + @JvmStatic + public fun main(args: Array) { + System.exit(runBlocking { LocalSocketShellMain().run(args) }) + } + } +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketClient.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketClient.kt new file mode 100644 index 000000000..aef9e2cac --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketClient.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 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.net.LocalSocket +import android.net.LocalSocketAddress +import android.os.Build +import android.util.Log +import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.hasExited +import androidx.test.services.shellexecutor.LocalSocketProtocol.readResponse +import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendRequest +import java.io.IOException +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.Executors +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.measureTime +import kotlin.time.toKotlinDuration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout + +/** + * Client that sends requests to the ShellCommandLocalSocketExecutorServer. + * + * This client is designed to be callable from Java. + */ +class ShellCommandLocalSocketClient(binderKey: String) { + private val address: LocalSocketAddress = addressFromBinderKey(binderKey) + private val secret: String = secretFromBinderKey(binderKey) + private lateinit var socket: LocalSocket + + /** Composes a request and sends it to the server, and streams the resulting output. */ + @kotlin.time.ExperimentalTime + fun request( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + timeout: Duration, + ): InputStream { + if (command == null || command.isEmpty()) { + throw IllegalArgumentException("Null or empty command") + } + + lateinit var result: InputStream + + // The call to runBlocking causes Android to emit "art: Note: end time exceeds epoch:". This is + // in InitTimeSpec in runtime/utils.cc. I don't see a way to invoke it in such a way that it + // doesn't clutter the logcat. + runBlocking(scope.coroutineContext) { + withTimeout(timeout) { + runInterruptible { + socket = LocalSocket(LocalSocket.SOCKET_STREAM) + // While there *is* a timeout option on connect(), in the Android source, it throws + // UnsupportedOperationException! So we leave the timeout up to withTimeout + + // runInterruptible. Capture the time taken to connect so we can subtract it from the + // overall timeout. (Calling socket.setSoTimeout() before connect() throws IOException + // "socket not created".) + val connectTime = measureTime { socket.connect(address) } + + val argv = mutableListOf() + if (executeThroughShell) { + argv.addAll(listOf("sh", "-c")) + argv.add((listOf(command) + (parameters ?: emptyList())).joinToString(" ")) + } else { + argv.add(command) + parameters?.let { argv.addAll(it) } + } + + socket.sendRequest(secret, argv, shellEnv, timeout - connectTime) + socket.shutdownOutput() + + // We read responses off the socket, write buffers to the pipe, and close the pipe when we + // get an exit code. The existing ShellExecutor API doesn't provide for *returning* that + // exit code, but it's useful as a way to know when to close the stream. By using the pipe + // as an intermediary, we can respond to exceptions sensibly. + val upstream = PipedOutputStream() + val downstream = PipedInputStream(upstream) + + scope.launch { + try { + socket.inputStream.use { + while (true) { + val response = socket.readResponse() + if (response == null) { + if (socket.fileDescriptor.valid()) { + delay(1.milliseconds) + continue + } else { + Log.w(TAG, "Unexpected EOF on LocalSocket for ${argv[0]}!") + break + } + } + if (response.buffer.size() > 0) response.buffer.writeTo(upstream) + if (response.hasExited()) { + Log.i(TAG, "Process ${argv[0]} exited with code ${response.exitCode}") + break + } + } + } + } catch (x: IOException) { + if (x.isPipeClosed()) { + Log.i(TAG, "LocalSocket relay for ${argv[0]} closed early") + } else { + Log.w(TAG, "LocalSocket relay for ${argv[0]} failed", x) + } + } finally { + upstream.flush() + upstream.close() + } + } + + result = downstream + } + } + } + return result + } + + /** Java-friendly wrapper for the above. */ + @kotlin.time.ExperimentalTime + fun request( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + timeout: java.time.Duration, + ): InputStream = + request(command, parameters, shellEnv, executeThroughShell, timeout.toKotlinDuration()) + + private companion object { + private const val TAG = "SCLSClient" // up to 23 characters + + // Keep this around for all clients; if you create a new one with every object, you can wind up + // running out of threads. + private val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) + } +} + +// Sadly, the only way to distinguish the downstream pipe being closed is the text +// of the exception thrown when you try to write to it. Which varies by API level. +private fun IOException.isPipeClosed() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + message.equals("Pipe closed") + } else { + message.equals("Pipe is closed") + } diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt new file mode 100644 index 000000000..411d9f4fb --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2024 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.net.LocalServerSocket +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.os.Process as AndroidProcess +import android.util.Log +import androidx.test.services.shellexecutor.LocalSocketProtocol.asBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.readRequest +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendResponse +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest +import java.io.IOException +import java.io.InterruptedIOException +import java.security.SecureRandom +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout + +/** Server that run shell commands for a client talking over a LocalSocket. */ +final class ShellCommandLocalSocketExecutorServer +@JvmOverloads +constructor( + private val scope: CoroutineScope = + CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) +) { + // Use the same secret generation as SpeakEasy does. + private val secret = java.lang.Long.toHexString(SecureRandom().nextLong()) + lateinit var socket: LocalServerSocket + lateinit var address: LocalSocketAddress + // Since LocalServerSocket.accept() has to be interrupted, we keep that in its own Job... + lateinit var serverJob: Job + // ...while all the child jobs are under a single SupervisorJob that we can join later. + val shellJobs = SupervisorJob() + val running = AtomicBoolean(true) + + /** Returns the binder key to pass to client processes. */ + fun binderKey(): String { + // The address can contain spaces, and since it gets passed through a command line, we need to + // encode it. java.net.URLEncoder is conveniently available in all SDK versions. + return address.asBinderKey(secret) + } + + /** Runs a simple server. */ + private suspend fun server() = coroutineScope { + while (running.get()) { + val connection = + try { + runInterruptible { socket.accept() } + } catch (x: Exception) { + // None of my tests have managed to trigger this one. + Log.e(TAG, "LocalServerSocket.accept() failed", x) + break + } + launch(scope.coroutineContext + shellJobs) { handleConnection(connection) } + } + } + + /** + * Relays the output of process to connection with a series of RunCommandResponses. + * + * @param process The process to relay output from. + * @param connection The connection to relay output to. + * @return false if there was a problem, true otherwise. + */ + private suspend fun relay(process: Process, connection: LocalSocket): Boolean { + val buffer = ByteArray(4096) + var size: Int + + // LocalSocket.isOutputShutdown() throws UnsupportedOperationException, so we can't use + // that as our loop constraint. + while (true) { + try { + size = runInterruptible { process.inputStream.read(buffer) } + if (size < 0) return true // EOF + if (size == 0) { + delay(1.milliseconds) + continue + } + } catch (x: InterruptedIOException) { + // We start getting these at API 24 when the timeout handling kicks in. + Log.i(TAG, "Interrupted while reading from ${process}: ${x.message}") + return false + } catch (x: IOException) { + Log.i(TAG, "Error reading from ${process}; did it time out?", x) + return false + } + + if (!connection.sendResponse(buffer = buffer, size = size)) { + return false + } + } + } + + /** Handle one connection. */ + private suspend fun handleConnection(connection: LocalSocket) { + // connection.localSocketAddress is always null, so no point in logging it. + + // Close the connection when done. + connection.use { + val request = connection.readRequest() + + if (request.secret.compareTo(secret) != 0) { + Log.w(TAG, "Ignoring request with wrong secret: $request") + return + } + + val pb = request.toProcessBuilder() + pb.redirectErrorStream(true) + + val process: Process + try { + process = pb.start() + } catch (x: IOException) { + Log.e(TAG, "Failed to start process", x) + connection.sendResponse( + buffer = x.stackTraceToString().toByteArray(), + exitCode = EXIT_CODE_FAILED_TO_START, + ) + return + } + + // We will not be writing anything to the process' stdin. + process.outputStream.close() + + // Close the process' stdout when we're done reading. + process.inputStream.use { + // Launch a coroutine to relay the process' output to the client. If it times out, kill the + // process and cancel the job. This is more coroutine-friendly than using waitFor() to + // handle timeouts. + val ioJob = scope.async { relay(process, connection) } + + try { + withTimeout(request.timeout()) { + if (!ioJob.await()) { + Log.w(TAG, "Relaying ${process} output failed") + } + runInterruptible { process.waitFor() } + } + } catch (x: TimeoutCancellationException) { + Log.e(TAG, "Process ${process} timed out after ${request.timeout()}") + process.destroy() + ioJob.cancel() + connection.sendResponse(exitCode = EXIT_CODE_TIMED_OUT) + return + } + + connection.sendResponse(exitCode = process.exitValue()) + } + } + } + + /** Starts the server. */ + fun start() { + socket = LocalServerSocket("androidx.test.services ${AndroidProcess.myPid()}") + address = socket.localSocketAddress + Log.i(TAG, "Starting server on ${address.name}") + + // Launch a coroutine to call socket.accept() + serverJob = scope.launch { server() } + } + + /** Stops the server. */ + fun stop(timeout: Duration) { + running.set(false) + // Closing the socket does not interrupt accept()... + socket.close() + runBlocking(scope.coroutineContext) { + try { + // ...so we simply cancel that job... + serverJob.cancel() + // ...and play nicely with all the shell jobs underneath. + withTimeout(timeout) { + shellJobs.complete() + shellJobs.join() + } + } catch (x: TimeoutCancellationException) { + Log.w(TAG, "Shell jobs did not stop after $timeout", x) + shellJobs.cancel() + } + } + } + + private fun RunCommandRequest.timeout(): Duration = + if (timeoutMs <= 0) { + Duration.INFINITE + } else { + timeoutMs.milliseconds + } + + /** + * Sets up a ProcessBuilder with information from the request; other configuration is up to the + * caller. + */ + private fun RunCommandRequest.toProcessBuilder(): ProcessBuilder { + val pb = ProcessBuilder(argvList) + val redacted = argvList.map { it.replace(secret, "(SECRET)") } // Don't log the secret! + Log.i(TAG, "Command to execute: [${redacted.joinToString("] [")}] within ${timeout()}") + if (environmentMap.isNotEmpty()) { + pb.environment().putAll(environmentMap) + val env = environmentMap.entries.map { (k, v) -> "$k=$v" }.joinToString(", ") + Log.i(TAG, "Environment: $env") + } + return pb + } + + private companion object { + const val TAG = "SCLSEServer" // up to 23 characters + + const val EXIT_CODE_FAILED_TO_START = -1 + const val EXIT_CODE_TIMED_OUT = -2 + } +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto b/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto new file mode 100644 index 000000000..e631d05ab --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/local_socket_protocol.proto @@ -0,0 +1,43 @@ +// +// Copyright (C) 2024 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. + +syntax = "proto3"; + +package androidx.test.services.storage; + +option java_package = "androidx.test.services.shellexecutor"; +option java_outer_classname = 'LocalSocketProtocolProto'; + +// Message sent from client to server to start a process. +message RunCommandRequest { + // Secret to authenticate the request. + string secret = 1; + + // argv of the command line to run. + repeated string argv = 2; + + // Environment varialbes to provide. + map environment = 3; + + // Timeout for the command. Any value <= 0 is treated as "forever". + int64 timeout_ms = 4; +} + +// Multiple responses can be streamed back to the client. The one that has an +// exit code indicates the end of the stream. +message RunCommandResponse { + bytes buffer = 1; + int32 exit_code = 2; +} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD index 0655cd59d..ff90f5063 100644 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD @@ -79,6 +79,22 @@ axt_android_library_test( ], ) +axt_android_library_test( + name = "ShellCommandLocalSocketClientTest", + srcs = [ + "ShellCommandLocalSocketClientTest.kt", + ], + deps = [ + "//runner/monitor", + "//services/shellexecutor:exec_client", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol_pb_java_proto_lite", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_android", + ], +) + axt_android_library_test( name = "ShellCommandFileObserverExecutorServerTest", srcs = [ @@ -93,6 +109,23 @@ axt_android_library_test( ], ) +axt_android_library_test( + name = "ShellCommandLocalSocketExecutorServerTest", + srcs = [ + "ShellCommandLocalSocketExecutorServerTest.kt", + ], + deps = [ + "//runner/monitor", + "//services/shellexecutor:exec_server", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol_pb_java_proto_lite", + "@com_google_protobuf//:protobuf_javalite", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_android", + ], +) + axt_android_library_test( name = "ShellExecutorTest", srcs = [ diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketClientTest.kt b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketClientTest.kt new file mode 100644 index 000000000..6edb303fd --- /dev/null +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketClientTest.kt @@ -0,0 +1,99 @@ +package androidx.test.services.shellexecutor + +import android.net.LocalServerSocket +import android.net.LocalSocketAddress +import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.asBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.readRequest +import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest +import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ShellCommandLocalSocketClientTest { + + @Before fun setUp() {} + + @Test + fun binderkey_success() { + val address = LocalSocketAddress("binderkey_success 12345") + val binderKey = address.asBinderKey(SECRET) + assertThat(addressFromBinderKey(binderKey).name).isEqualTo(address.name) + assertThat(addressFromBinderKey(binderKey).namespace).isEqualTo(address.namespace) + assertThat(secretFromBinderKey(binderKey)).isEqualTo(SECRET) + } + + @Test + fun request_regular() { + val server = LocalServerSocket("request_regular") + val client = ShellCommandLocalSocketClient(server.localSocketAddress.asBinderKey(SECRET)) + + val request: RunCommandRequest + + runBlocking { + val result = async { + val socket = server.accept() + socket.readRequest() + } + + client.request( + "foo", + listOf("bar", "baz"), + mapOf("quem" to "quux", "potrzebie" to "furshlugginer"), + executeThroughShell = false, + timeout = 1.seconds, + ) + request = result.await() + } + + assertThat(request.secret).isEqualTo(SECRET) + assertThat(request.argvList).containsExactly("foo", "bar", "baz") + assertThat(request.environmentMap) + .containsExactlyEntriesIn(mapOf("quem" to "quux", "potrzebie" to "furshlugginer")) + // The overall timeout will have the connect time shaved off. This is usually quite low, but + // I've seen it as high as 61ms. + assertThat(request.timeoutMs).isGreaterThan(900) + } + + @Test + fun request_executeThroughShell() { + val server = LocalServerSocket("request_executeThroughShell") + val client = ShellCommandLocalSocketClient(server.localSocketAddress.asBinderKey(SECRET)) + + val request: RunCommandRequest + + runBlocking { + val result = async { + val socket = server.accept() + socket.readRequest() + } + + client.request( + "foo", + listOf("bar", "baz"), + mapOf("quem" to "quux", "potrzebie" to "furshlugginer"), + executeThroughShell = true, + timeout = 1.seconds, + ) + request = result.await() + } + + assertThat(request.secret).isEqualTo(SECRET) + assertThat(request.argvList).containsExactly("sh", "-c", "foo bar baz") + assertThat(request.environmentMap) + .containsExactlyEntriesIn(mapOf("quem" to "quux", "potrzebie" to "furshlugginer")) + // The overall timeout will have the connect time shaved off. + assertThat(request.timeoutMs).isGreaterThan(900) + } + + private companion object { + const val SECRET = "foo:bar" + } +} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt new file mode 100644 index 000000000..230ea9820 --- /dev/null +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt @@ -0,0 +1,104 @@ +package androidx.test.services.shellexecutor + +import android.net.LocalSocket +import android.os.Build +import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.hasExited +import androidx.test.services.shellexecutor.LocalSocketProtocol.readResponse +import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendRequest +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandResponse +import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ShellCommandLocalSocketExecutorServerTest { + + @Test + fun success_simple() { + val responses = mutableListOf() + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + secretFromBinderKey(server.binderKey()), + listOf("echo", "\${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + do { + client.readResponse()?.let { responses.add(it) } + } while (!responses.last().hasExited()) + server.stop(100.milliseconds) + } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { + // On API 21 and 22, echo only exists as a shell builtin! + assertThat(responses).hasSize(1) + assertThat(responses[0].exitCode).isEqualTo(-1) + assertThat(responses[0].buffer.toStringUtf8()).contains("Permission denied") + } else { + // On rare occasions, the output of the command will come back in two packets! So to keep + // this test from being 1% flaky: + val stdout = buildString { + for (response in responses) { + if (response.buffer.size() > 0) append(response.buffer.toStringUtf8()) + } + } + assertThat(stdout).isEqualTo("\${POTRZEBIE}\n") + assertThat(responses.last().hasExited()).isTrue() + assertThat(responses.last().exitCode).isEqualTo(0) + } + } + + @Test + fun success_shell_expansion() { + val responses = mutableListOf() + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + secretFromBinderKey(server.binderKey()), + listOf("sh", "-c", "echo \${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + do { + client.readResponse()?.let { responses.add(it) } + } while (!responses.last().hasExited()) + server.stop(100.milliseconds) + } + val stdout = buildString { + for (response in responses) { + if (response.buffer.size() > 0) append(response.buffer.toStringUtf8()) + } + } + assertThat(stdout).isEqualTo("furshlugginer\n") + assertThat(responses.last().hasExited()).isTrue() + assertThat(responses.last().exitCode).isEqualTo(0) + } + + @Test + fun failure_bad_secret() { + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + "potrzebie!", + listOf("sh", "-c", "echo \${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + assertThat(client.inputStream.read()).isEqualTo(-1) + } + } +} diff --git a/tools/release/java/androidx/test/tools/releaseupdater/ReleaseUpdater.kt b/tools/release/java/androidx/test/tools/releaseupdater/ReleaseUpdater.kt index a8a4dadc7..2fb69d6ac 100644 --- a/tools/release/java/androidx/test/tools/releaseupdater/ReleaseUpdater.kt +++ b/tools/release/java/androidx/test/tools/releaseupdater/ReleaseUpdater.kt @@ -18,6 +18,8 @@ class ReleaseUpdater { * passes * 2) X.X.X-suffix1 -> X.X.X-suffix2 where validateSuffixIncrement(suffix1, suffix2) passes * + * For more details (and the full source-of-truth), see go/androidx/versioning. // copybara:strip + * * @param oldVersion The original version * @param newVersion The incremented "+1" version * @throws IllegalArgumentException If the version was incorrectly incremented