Skip to content

Commit 3e9313b

Browse files
Integration tests for LocalSocketShellMain.
PiperOrigin-RevId: 681170539
1 parent 02ce11c commit 3e9313b

15 files changed

+1113
-20
lines changed

services/CHANGELOG.md

+9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111

1212
**New Features**
1313

14+
* LocalSocketProtocol: a replacement for SpeakEasy.
15+
* ShellCommandLocalSocketClient: client that speaks LocalSocketProtocol.
16+
* ShellCommandLocalSocketExecutorServer: server that speaks LocalSocketProtocol.
17+
* LocalSocketShellMain: a replacement for ShellMain. Using this in place of
18+
ShellMain avoids the use of the SpeakEasy protocol, so androidx.test.services
19+
can be freely killed and restarted by the operating system without breaking
20+
tests. The ShellExecutorFactory will automatically figure out which protocol
21+
to use based on the binder key passed down by the variant of ShellMain.
22+
1423
**Breaking Changes**
1524

1625
**API Changes**

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

+34
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,43 @@ kt_android_library(
2929
],
3030
)
3131

32+
proto_library(
33+
name = "local_socket_protocol_pb",
34+
srcs = ["local_socket_protocol.proto"],
35+
)
36+
37+
java_lite_proto_library(
38+
name = "local_socket_protocol_pb_java_proto_lite",
39+
visibility = [
40+
"//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__",
41+
],
42+
deps = [":local_socket_protocol_pb"],
43+
)
44+
45+
kt_android_library(
46+
name = "local_socket_protocol",
47+
srcs = ["LocalSocketProtocol.kt"],
48+
visibility = [
49+
"//services/shellexecutor/javatests/androidx/test/services/shellexecutor:__subpackages__",
50+
],
51+
deps = [
52+
":local_socket_protocol_pb_java_proto_lite",
53+
"@com_google_protobuf//:protobuf_javalite",
54+
"@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core",
55+
],
56+
)
57+
3258
kt_android_library(
3359
name = "exec_server",
3460
srcs = [
3561
"BlockingPublish.java",
3662
"FileObserverShellMain.kt",
63+
"LocalSocketShellMain.kt",
3764
"ShellCommand.java",
3865
"ShellCommandExecutor.java",
3966
"ShellCommandExecutorServer.java",
4067
"ShellCommandFileObserverExecutorServer.kt",
68+
"ShellCommandLocalSocketExecutorServer.kt",
4169
"ShellExecSharedConstants.java",
4270
"ShellMain.java",
4371
],
@@ -46,6 +74,8 @@ kt_android_library(
4674
deps = [
4775
":coroutine_file_observer",
4876
":file_observer_protocol",
77+
":local_socket_protocol",
78+
":local_socket_protocol_pb_java_proto_lite",
4979
"//services/speakeasy/java/androidx/test/services/speakeasy:protocol",
5080
"//services/speakeasy/java/androidx/test/services/speakeasy/client",
5181
"//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection",
@@ -62,17 +92,21 @@ kt_android_library(
6292
"ShellCommand.java",
6393
"ShellCommandClient.java",
6494
"ShellCommandFileObserverClient.kt",
95+
"ShellCommandLocalSocketClient.kt",
6596
"ShellExecSharedConstants.java",
6697
"ShellExecutor.java",
6798
"ShellExecutorFactory.java",
6899
"ShellExecutorFileObserverImpl.kt",
69100
"ShellExecutorImpl.java",
101+
"ShellExecutorLocalSocketImpl.kt",
70102
],
71103
idl_srcs = ["Command.aidl"],
72104
visibility = [":export"],
73105
deps = [
74106
":coroutine_file_observer",
75107
":file_observer_protocol",
108+
":local_socket_protocol",
109+
":local_socket_protocol_pb_java_proto_lite",
76110
"//services/speakeasy/java/androidx/test/services/speakeasy:protocol",
77111
"//services/speakeasy/java/androidx/test/services/speakeasy/client",
78112
"//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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.util.Log
22+
import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest
23+
import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandResponse
24+
import com.google.protobuf.ByteString
25+
import java.io.IOException
26+
import java.net.URLDecoder
27+
import java.net.URLEncoder
28+
import kotlin.time.Duration
29+
30+
/**
31+
* Protocol for ShellCommandLocalSocketClient to talk to ShellCommandLocalSocketExecutorServer.
32+
*
33+
* Since androidx.test.services already includes the protobuf runtime, we aren't paying much extra
34+
* for adding some more protos to ship back and forth, which is vastly easier to deal with than
35+
* PersistableBundles (which don't even support ByteArray types).
36+
*
37+
* A conversation consists of a single RunCommandRequest from the client followed by a stream of
38+
* RunCommandResponses from the server; the final response has an exit code.
39+
*/
40+
object LocalSocketProtocol {
41+
/** Composes a RunCommandRequest and sends it over the LocalSocket. */
42+
fun LocalSocket.sendRequest(
43+
secret: String,
44+
argv: List<String>,
45+
env: Map<String, String>? = null,
46+
timeout: Duration,
47+
) {
48+
val builder = RunCommandRequest.newBuilder()
49+
builder.setSecret(secret)
50+
builder.addAllArgv(argv)
51+
env?.forEach { (k, v) -> builder.putEnvironment(k, v) }
52+
if (timeout.isInfinite() || timeout.isNegative() || timeout == Duration.ZERO) {
53+
builder.setTimeoutMs(0) // <= 0 means no timeout
54+
} else {
55+
builder.setTimeoutMs(timeout.inWholeMilliseconds)
56+
}
57+
builder.build().writeDelimitedTo(outputStream)
58+
}
59+
60+
/** Reads a RunCommandRequest from the LocalSocket. */
61+
fun LocalSocket.readRequest(): RunCommandRequest {
62+
return RunCommandRequest.parseDelimitedFrom(inputStream)!!
63+
}
64+
65+
/** Composes a RunCommandResponse and sends it over the LocalSocket. */
66+
fun LocalSocket.sendResponse(
67+
buffer: ByteArray? = null,
68+
size: Int = 0,
69+
exitCode: Int? = null,
70+
): Boolean {
71+
val builder = RunCommandResponse.newBuilder()
72+
buffer?.let {
73+
val bufferSize = if (size > 0) size else it.size
74+
builder.buffer = ByteString.copyFrom(it, 0, bufferSize)
75+
}
76+
// Since we're currently stuck on a version of protobuf where we don't have hasExitCode(), we
77+
// use a magic value to indicate that exitCode is not set. When we upgrade to a newer version
78+
// of protobuf, we can obsolete this.
79+
if (exitCode != null) {
80+
builder.exitCode = exitCode
81+
} else {
82+
builder.exitCode = HAS_NOT_EXITED
83+
}
84+
85+
try {
86+
builder.build().writeDelimitedTo(outputStream)
87+
} catch (x: IOException) {
88+
// Sadly, the only way to discover that the client cut the connection is an exception that
89+
// can only be distinguished by its text.
90+
if (x.message.equals("Broken pipe")) {
91+
Log.i(TAG, "LocalSocket stream closed early")
92+
} else {
93+
Log.w(TAG, "LocalSocket write failed", x)
94+
}
95+
return false
96+
}
97+
return true
98+
}
99+
100+
/** Reads a RunCommandResponse from the LocalSocket. */
101+
fun LocalSocket.readResponse(): RunCommandResponse? {
102+
return RunCommandResponse.parseDelimitedFrom(inputStream)
103+
}
104+
105+
/**
106+
* Is this the end of the stream?
107+
*
108+
* Once we upgrade to a newer version of protobuf, we can switch to hasExitCode().
109+
*/
110+
fun RunCommandResponse.hasExited() = exitCode != HAS_NOT_EXITED
111+
112+
/**
113+
* Builds a binder key, given the server address and secret. Binder keys should be opaque outside
114+
* this directory.
115+
*
116+
* The address can contain spaces, and since it gets passed through a command line, we need to
117+
* encode it so it doesn't get split by argv. java.net.URLEncoder is conveniently available on all
118+
* SDK versions.
119+
*/
120+
@JvmStatic
121+
fun LocalSocketAddress.asBinderKey(secret: String) = buildString {
122+
append(":")
123+
append(URLEncoder.encode(name, "UTF-8")) // Will convert any : to %3A
124+
append(":")
125+
append(URLEncoder.encode(secret, "UTF-8"))
126+
append(":")
127+
}
128+
129+
/** Extracts the address from a binder key. */
130+
@JvmStatic
131+
fun addressFromBinderKey(binderKey: String) =
132+
LocalSocketAddress(URLDecoder.decode(binderKey.split(":")[1], "UTF-8"))
133+
134+
/** Extracts the secret from a binder key. */
135+
@JvmStatic
136+
fun secretFromBinderKey(binderKey: String) = URLDecoder.decode(binderKey.split(":")[2], "UTF-8")
137+
138+
/** Is this a valid binder key? */
139+
@JvmStatic
140+
fun isBinderKey(maybeKey: String) =
141+
maybeKey.startsWith(':') && maybeKey.endsWith(':') && maybeKey.split(":").size == 4
142+
143+
const val TAG = "LocalSocketProtocol"
144+
private const val HAS_NOT_EXITED = 0xCA7F00D
145+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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.util.Log
20+
import java.io.IOException
21+
import java.io.InputStream
22+
import java.io.OutputStream
23+
import java.util.concurrent.Executors
24+
import kotlin.time.Duration.Companion.milliseconds
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.asCoroutineDispatcher
27+
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.runBlocking
29+
import kotlinx.coroutines.runInterruptible
30+
31+
/** Variant of ShellMain that uses a LocalSocket to communicate with the client. */
32+
class LocalSocketShellMain {
33+
34+
suspend fun run(args: Array<String>): Int {
35+
val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher())
36+
val server = ShellCommandLocalSocketExecutorServer(scope = scope)
37+
server.start()
38+
39+
val processArgs = args.toMutableList()
40+
processArgs.addAll(
41+
processArgs.size - 1,
42+
listOf("-e", ShellExecSharedConstants.BINDER_KEY, server.binderKey()),
43+
)
44+
val pb = ProcessBuilder(processArgs.toList())
45+
46+
val exitCode: Int
47+
48+
try {
49+
val process = pb.start()
50+
51+
val stdinCopier = scope.launch { copyStream("stdin", System.`in`, process.outputStream) }
52+
val stdoutCopier = scope.launch { copyStream("stdout", process.inputStream, System.out) }
53+
val stderrCopier = scope.launch { copyStream("stderr", process.errorStream, System.err) }
54+
55+
runInterruptible { process.waitFor() }
56+
exitCode = process.exitValue()
57+
58+
stdinCopier.cancel() // System.`in`.close() does not force input.read() to return
59+
stdoutCopier.join()
60+
stderrCopier.join()
61+
} finally {
62+
server.stop(100.milliseconds)
63+
}
64+
return exitCode
65+
}
66+
67+
suspend fun copyStream(name: String, input: InputStream, output: OutputStream) {
68+
val buf = ByteArray(1024)
69+
try {
70+
while (true) {
71+
val size = input.read(buf)
72+
if (size == -1) break
73+
output.write(buf, 0, size)
74+
}
75+
output.flush()
76+
} catch (x: IOException) {
77+
Log.e(TAG, "IOException on $name. Terminating.", x)
78+
}
79+
}
80+
81+
companion object {
82+
private const val TAG = "LocalSocketShellMain"
83+
84+
@JvmStatic
85+
public fun main(args: Array<String>) {
86+
System.exit(runBlocking { LocalSocketShellMain().run(args) })
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)