Skip to content

Commit 35bdab8

Browse files
Adding a ShellCommandLocalSocketClient for the ShellExecutor to talk to the ShellMain.
PiperOrigin-RevId: 693898624
1 parent 1b88880 commit 35bdab8

File tree

4 files changed

+305
-0
lines changed

4 files changed

+305
-0
lines changed

services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD

+3
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ kt_android_library(
8888
"ShellCommand.java",
8989
"ShellCommandClient.java",
9090
"ShellCommandFileObserverClient.kt",
91+
"ShellCommandLocalSocketClient.kt",
9192
"ShellExecSharedConstants.java",
9293
"ShellExecutor.java",
9394
"ShellExecutorFactory.java",
@@ -99,6 +100,8 @@ kt_android_library(
99100
deps = [
100101
":coroutine_file_observer",
101102
":file_observer_protocol",
103+
":local_socket_protocol",
104+
":local_socket_protocol_pb_java_proto_lite",
102105
"//services/speakeasy/java/androidx/test/services/speakeasy:protocol",
103106
"//services/speakeasy/java/androidx/test/services/speakeasy/client",
104107
"//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
}

services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD

+16
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,22 @@ axt_android_library_test(
7979
],
8080
)
8181

82+
axt_android_library_test(
83+
name = "ShellCommandLocalSocketClientTest",
84+
srcs = [
85+
"ShellCommandLocalSocketClientTest.kt",
86+
],
87+
deps = [
88+
"//runner/monitor",
89+
"//services/shellexecutor:exec_client",
90+
"//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol",
91+
"//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol_pb_java_proto_lite",
92+
"@maven//:com_google_truth_truth",
93+
"@maven//:junit_junit",
94+
"@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_android",
95+
],
96+
)
97+
8298
axt_android_library_test(
8399
name = "ShellCommandFileObserverExecutorServerTest",
84100
srcs = [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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.LocalServerSocket
20+
import android.net.LocalSocketAddress
21+
import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey
22+
import androidx.test.services.shellexecutor.LocalSocketProtocol.asBinderKey
23+
import androidx.test.services.shellexecutor.LocalSocketProtocol.readRequest
24+
import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey
25+
import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest
26+
import com.google.common.truth.Truth.assertThat
27+
import kotlin.time.Duration.Companion.seconds
28+
import kotlinx.coroutines.async
29+
import kotlinx.coroutines.runBlocking
30+
import org.junit.Before
31+
import org.junit.Test
32+
import org.junit.runner.RunWith
33+
import org.junit.runners.JUnit4
34+
35+
@RunWith(JUnit4::class)
36+
class ShellCommandLocalSocketClientTest {
37+
38+
@Before fun setUp() {}
39+
40+
@Test
41+
fun binderkey_success() {
42+
val address = LocalSocketAddress("binderkey_success 12345")
43+
val binderKey = address.asBinderKey(SECRET)
44+
assertThat(addressFromBinderKey(binderKey).name).isEqualTo(address.name)
45+
assertThat(addressFromBinderKey(binderKey).namespace).isEqualTo(address.namespace)
46+
assertThat(secretFromBinderKey(binderKey)).isEqualTo(SECRET)
47+
}
48+
49+
@Test
50+
fun request_regular() {
51+
val server = LocalServerSocket("request_regular")
52+
val client = ShellCommandLocalSocketClient(server.localSocketAddress.asBinderKey(SECRET))
53+
54+
val request: RunCommandRequest
55+
56+
runBlocking {
57+
val result = async {
58+
val socket = server.accept()
59+
socket.readRequest()
60+
}
61+
62+
client.request(
63+
"foo",
64+
listOf("bar", "baz"),
65+
mapOf("quem" to "quux", "potrzebie" to "furshlugginer"),
66+
executeThroughShell = false,
67+
timeout = 1.seconds,
68+
)
69+
request = result.await()
70+
}
71+
72+
assertThat(request.secret).isEqualTo(SECRET)
73+
assertThat(request.argvList).containsExactly("foo", "bar", "baz")
74+
assertThat(request.environmentMap)
75+
.containsExactlyEntriesIn(mapOf("quem" to "quux", "potrzebie" to "furshlugginer"))
76+
// The overall timeout will have the connect time shaved off. This is usually quite low, but
77+
// I've seen it as high as 61ms.
78+
assertThat(request.timeoutMs).isGreaterThan(900)
79+
}
80+
81+
@Test
82+
fun request_executeThroughShell() {
83+
val server = LocalServerSocket("request_executeThroughShell")
84+
val client = ShellCommandLocalSocketClient(server.localSocketAddress.asBinderKey(SECRET))
85+
86+
val request: RunCommandRequest
87+
88+
runBlocking {
89+
val result = async {
90+
val socket = server.accept()
91+
socket.readRequest()
92+
}
93+
94+
client.request(
95+
"foo",
96+
listOf("bar", "baz"),
97+
mapOf("quem" to "quux", "potrzebie" to "furshlugginer"),
98+
executeThroughShell = true,
99+
timeout = 1.seconds,
100+
)
101+
request = result.await()
102+
}
103+
104+
assertThat(request.secret).isEqualTo(SECRET)
105+
assertThat(request.argvList).containsExactly("sh", "-c", "foo bar baz")
106+
assertThat(request.environmentMap)
107+
.containsExactlyEntriesIn(mapOf("quem" to "quux", "potrzebie" to "furshlugginer"))
108+
// The overall timeout will have the connect time shaved off.
109+
assertThat(request.timeoutMs).isGreaterThan(900)
110+
}
111+
112+
private companion object {
113+
const val SECRET = "foo:bar"
114+
}
115+
}

0 commit comments

Comments
 (0)