Skip to content

Commit 1b88880

Browse files
Adding a LocalSocketProtocol for the ShellExecutor to talk to the ShellMain.
PiperOrigin-RevId: 690674500
1 parent 02ce11c commit 1b88880

File tree

4 files changed

+234
-0
lines changed

4 files changed

+234
-0
lines changed

services/CHANGELOG.md

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

1212
**New Features**
1313

14+
* Adding a LocalSocket-based protocol for the ShellExecutor to talk to the
15+
ShellMain. This obsoletes SpeakEasy; if androidx.test.services is killed
16+
(e.g. by the low memory killer) between the start of the app_process that
17+
invokes LocalSocketShellMain and the start of the test, the test is still able
18+
to talk to LocalSocketShellMain.
19+
1420
**Breaking Changes**
1521

1622
**API Changes**

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

+26
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@ 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 = [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
/**
42+
* Composes a RunCommandRequest and sends it over the LocalSocket.
43+
*
44+
* @param secret The secret to authenticate the request.
45+
* @param argv The argv of the command line to run.
46+
* @param env The environment variables to provide to the process.
47+
* @param timeout The timeout for the command; infinite or nonpositive values mean no timeout.
48+
*/
49+
fun LocalSocket.sendRequest(
50+
secret: String,
51+
argv: List<String>,
52+
env: Map<String, String>? = null,
53+
timeout: Duration,
54+
) {
55+
val builder = RunCommandRequest.newBuilder().setSecret(secret).addAllArgv(argv)
56+
env?.forEach { (k, v) -> builder.putEnvironment(k, v) }
57+
if (timeout.isInfinite() || timeout.isNegative() || timeout == Duration.ZERO) {
58+
builder.setTimeoutMs(0) // <= 0 means no timeout
59+
} else {
60+
builder.setTimeoutMs(timeout.inWholeMilliseconds)
61+
}
62+
builder.build().writeDelimitedTo(outputStream)
63+
}
64+
65+
/** Reads a RunCommandRequest from the LocalSocket. */
66+
fun LocalSocket.readRequest(): RunCommandRequest {
67+
return RunCommandRequest.parseDelimitedFrom(inputStream)!!
68+
}
69+
70+
/** Composes a RunCommandResponse and sends it over the LocalSocket. */
71+
fun LocalSocket.sendResponse(
72+
buffer: ByteArray? = null,
73+
size: Int = 0,
74+
exitCode: Int? = null,
75+
): Boolean {
76+
val builder = RunCommandResponse.newBuilder()
77+
buffer?.let {
78+
val bufferSize = if (size > 0) size else it.size
79+
builder.buffer = ByteString.copyFrom(it, 0, bufferSize)
80+
}
81+
// Since we're currently stuck on a version of protobuf where we don't have hasExitCode(), we
82+
// use a magic value to indicate that exitCode is not set. When we upgrade to a newer version
83+
// of protobuf, we can obsolete this.
84+
if (exitCode != null) {
85+
builder.exitCode = exitCode
86+
} else {
87+
builder.exitCode = HAS_NOT_EXITED
88+
}
89+
90+
try {
91+
builder.build().writeDelimitedTo(outputStream)
92+
} catch (x: IOException) {
93+
// Sadly, the only way to discover that the client cut the connection is an exception that
94+
// can only be distinguished by its text.
95+
if (x.message.equals("Broken pipe")) {
96+
Log.i(TAG, "LocalSocket stream closed early")
97+
} else {
98+
Log.w(TAG, "LocalSocket write failed", x)
99+
}
100+
return false
101+
}
102+
return true
103+
}
104+
105+
/** Reads a RunCommandResponse from the LocalSocket. */
106+
fun LocalSocket.readResponse(): RunCommandResponse? {
107+
return RunCommandResponse.parseDelimitedFrom(inputStream)
108+
}
109+
110+
/**
111+
* Is this the end of the stream?
112+
*
113+
* Once we upgrade to a newer version of protobuf, we can switch to hasExitCode().
114+
*/
115+
fun RunCommandResponse.hasExited() = exitCode != HAS_NOT_EXITED
116+
117+
/**
118+
* Builds a "binder key", given the server address and secret. (We are not actually using a Binder
119+
* here, but the ShellExecutor interface calls the secret for connecting client to server a
120+
* "binder key", so we stick with that nomenclature.) Binder keys should be opaque outside
121+
* this directory.
122+
*
123+
* The address can contain spaces, and since it gets passed through a command line, we need to
124+
* encode it so it doesn't get split by argv. java.net.URLEncoder is conveniently available on all
125+
* SDK versions.
126+
*/
127+
@JvmStatic
128+
fun LocalSocketAddress.asBinderKey(secret: String) = buildString {
129+
append(":")
130+
append(URLEncoder.encode(name, "UTF-8")) // Will convert any : to %3A
131+
append(":")
132+
append(URLEncoder.encode(secret, "UTF-8"))
133+
append(":")
134+
}
135+
136+
/** Extracts the address from a binder key. */
137+
@JvmStatic
138+
fun addressFromBinderKey(binderKey: String) =
139+
LocalSocketAddress(URLDecoder.decode(binderKey.split(":")[1], "UTF-8"))
140+
141+
/** Extracts the secret from a binder key. */
142+
@JvmStatic
143+
fun secretFromBinderKey(binderKey: String) = URLDecoder.decode(binderKey.split(":")[2], "UTF-8")
144+
145+
/** Is this a valid binder key? */
146+
@JvmStatic
147+
fun isBinderKey(maybeKey: String) =
148+
maybeKey.startsWith(':') && maybeKey.endsWith(':') && maybeKey.split(":").size == 4
149+
150+
const val TAG = "LocalSocketProtocol"
151+
private const val HAS_NOT_EXITED = 0xCA7F00D
152+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
syntax = "proto3";
17+
18+
package androidx.test.services.storage;
19+
20+
option java_package = "androidx.test.services.shellexecutor";
21+
option java_outer_classname = 'LocalSocketProtocolProto';
22+
23+
// Message sent from client to server to start a process.
24+
message RunCommandRequest {
25+
// Secret to authenticate the request.
26+
string secret = 1;
27+
28+
// argv of the command line to run.
29+
repeated string argv = 2;
30+
31+
// Environment varialbes to provide.
32+
map<string, string> environment = 3;
33+
34+
// Timeout for the command. Any value <= 0 is treated as "forever".
35+
int64 timeout_ms = 4;
36+
}
37+
38+
// Multiple responses can be streamed back to the client. The one that has an
39+
// exit code indicates the end of the stream.
40+
message RunCommandResponse {
41+
// A buffer of the command's output (stdout and stderr combined by specifying
42+
// redirectErrorStream(true) on ProcessBuilder).
43+
bytes buffer = 1;
44+
45+
// The exit code of the command. While we're stuck on proto3, the magic value
46+
// 0xCA7F00D indicates that the command is still running; once we can move to
47+
// a newer version where we can test hasExitCode(), we will remove the magic
48+
// value.
49+
int32 exit_code = 2;
50+
}

0 commit comments

Comments
 (0)