|
| 1 | +/* |
| 2 | + * Copyright (C) 2024 The Android Open Source Project |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +package androidx.test.services.shellexecutor |
| 18 | + |
| 19 | +import android.net.LocalSocket |
| 20 | +import android.net.LocalSocketAddress |
| 21 | +import android.os.Build |
| 22 | +import android.util.Log |
| 23 | +import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey |
| 24 | +import androidx.test.services.shellexecutor.LocalSocketProtocol.hasExited |
| 25 | +import androidx.test.services.shellexecutor.LocalSocketProtocol.readResponse |
| 26 | +import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey |
| 27 | +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendRequest |
| 28 | +import java.io.IOException |
| 29 | +import java.io.InputStream |
| 30 | +import java.io.PipedInputStream |
| 31 | +import java.io.PipedOutputStream |
| 32 | +import java.util.concurrent.Executors |
| 33 | +import kotlin.time.Duration |
| 34 | +import kotlin.time.Duration.Companion.milliseconds |
| 35 | +import kotlin.time.measureTime |
| 36 | +import kotlin.time.toKotlinDuration |
| 37 | +import kotlinx.coroutines.CoroutineScope |
| 38 | +import kotlinx.coroutines.asCoroutineDispatcher |
| 39 | +import kotlinx.coroutines.delay |
| 40 | +import kotlinx.coroutines.launch |
| 41 | +import kotlinx.coroutines.runBlocking |
| 42 | +import kotlinx.coroutines.runInterruptible |
| 43 | +import kotlinx.coroutines.withTimeout |
| 44 | + |
| 45 | +/** |
| 46 | + * Client that sends requests to the ShellCommandLocalSocketExecutorServer. |
| 47 | + * |
| 48 | + * This client is designed to be callable from Java. |
| 49 | + */ |
| 50 | +class ShellCommandLocalSocketClient(binderKey: String) { |
| 51 | + private val address: LocalSocketAddress = addressFromBinderKey(binderKey) |
| 52 | + private val secret: String = secretFromBinderKey(binderKey) |
| 53 | + private lateinit var socket: LocalSocket |
| 54 | + |
| 55 | + /** |
| 56 | + * Composes a request and sends it to the server, and streams the resulting output. |
| 57 | + * @param command The command to run. |
| 58 | + * @param parameters The parameters to the command. command + parameters = argv |
| 59 | + * @param shellEnv The environment variables to provide to the process. |
| 60 | + * @param executeThroughShell Whether to execute the command through a shell, making the argv |
| 61 | + * "sh" "-c" "command parameters". |
| 62 | + * @param timeout The timeout for the command; infinite or nonpositive values mean no timeout. |
| 63 | + * @return An InputStream that can be used to read the output of the command. |
| 64 | + */ |
| 65 | + @kotlin.time.ExperimentalTime |
| 66 | + fun request( |
| 67 | + command: String?, |
| 68 | + parameters: List<String>?, |
| 69 | + shellEnv: Map<String, String>?, |
| 70 | + executeThroughShell: Boolean, |
| 71 | + timeout: Duration, |
| 72 | + ): InputStream { |
| 73 | + if (command == null || command.isEmpty()) { |
| 74 | + throw IllegalArgumentException("Null or empty command") |
| 75 | + } |
| 76 | + |
| 77 | + lateinit var result: InputStream |
| 78 | + |
| 79 | + // The call to runBlocking causes Android to emit "art: Note: end time exceeds epoch:". This is |
| 80 | + // in InitTimeSpec in runtime/utils.cc. I don't see a way to invoke it in such a way that it |
| 81 | + // doesn't clutter the logcat. |
| 82 | + runBlocking(scope.coroutineContext) { |
| 83 | + withTimeout(timeout) { |
| 84 | + runInterruptible { |
| 85 | + socket = LocalSocket(LocalSocket.SOCKET_STREAM) |
| 86 | + // While there *is* a timeout option on connect(), in the Android source, it throws |
| 87 | + // UnsupportedOperationException! So we leave the timeout up to withTimeout + |
| 88 | + // runInterruptible. Capture the time taken to connect so we can subtract it from the |
| 89 | + // overall timeout. (Calling socket.setSoTimeout() before connect() throws IOException |
| 90 | + // "socket not created".) |
| 91 | + val connectTime = measureTime { socket.connect(address) } |
| 92 | + |
| 93 | + val argv = mutableListOf<String>() |
| 94 | + if (executeThroughShell) { |
| 95 | + argv.addAll(listOf("sh", "-c")) |
| 96 | + argv.add((listOf(command) + (parameters ?: emptyList())).joinToString(" ")) |
| 97 | + } else { |
| 98 | + argv.add(command) |
| 99 | + parameters?.let { argv.addAll(it) } |
| 100 | + } |
| 101 | + |
| 102 | + socket.sendRequest(secret, argv, shellEnv, timeout - connectTime) |
| 103 | + socket.shutdownOutput() |
| 104 | + |
| 105 | + // We read responses off the socket, write buffers to the pipe, and close the pipe when we |
| 106 | + // get an exit code. The existing ShellExecutor API doesn't provide for *returning* that |
| 107 | + // exit code, but it's useful as a way to know when to close the stream. By using the pipe |
| 108 | + // as an intermediary, we can respond to exceptions sensibly. |
| 109 | + val upstream = PipedOutputStream() |
| 110 | + val downstream = PipedInputStream(upstream) |
| 111 | + |
| 112 | + scope.launch { |
| 113 | + try { |
| 114 | + socket.inputStream.use { |
| 115 | + while (true) { |
| 116 | + val response = socket.readResponse() |
| 117 | + if (response == null) break // EOF |
| 118 | + if (response.buffer.size() > 0) response.buffer.writeTo(upstream) |
| 119 | + if (response.hasExited()) { |
| 120 | + Log.i(TAG, "Process ${argv[0]} exited with code ${response.exitCode}") |
| 121 | + break |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + } catch (x: IOException) { |
| 126 | + if (x.isPipeClosed()) { |
| 127 | + Log.i(TAG, "LocalSocket relay for ${argv[0]} closed early") |
| 128 | + } else { |
| 129 | + Log.w(TAG, "LocalSocket relay for ${argv[0]} failed", x) |
| 130 | + } |
| 131 | + } finally { |
| 132 | + upstream.flush() |
| 133 | + upstream.close() |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + result = downstream |
| 138 | + } |
| 139 | + } |
| 140 | + } |
| 141 | + return result |
| 142 | + } |
| 143 | + |
| 144 | + /** Java-friendly wrapper for the above. */ |
| 145 | + @kotlin.time.ExperimentalTime |
| 146 | + fun request( |
| 147 | + command: String?, |
| 148 | + parameters: List<String>?, |
| 149 | + shellEnv: Map<String, String>?, |
| 150 | + executeThroughShell: Boolean, |
| 151 | + timeout: java.time.Duration, |
| 152 | + ): InputStream = |
| 153 | + request(command, parameters, shellEnv, executeThroughShell, timeout.toKotlinDuration()) |
| 154 | + |
| 155 | + private companion object { |
| 156 | + private const val TAG = "SCLSClient" // up to 23 characters |
| 157 | + |
| 158 | + // Keep this around for all clients; if you create a new one with every object, you can wind up |
| 159 | + // running out of threads. |
| 160 | + private val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +// Sadly, the only way to distinguish the downstream pipe being closed is the text |
| 165 | +// of the exception thrown when you try to write to it. Which varies by API level. |
| 166 | +private fun IOException.isPipeClosed() = |
| 167 | + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| 168 | + message.equals("Pipe closed") |
| 169 | + } else { |
| 170 | + message.equals("Pipe is closed") |
| 171 | + } |
0 commit comments