Skip to content

Commit 1c0d001

Browse files
committed
Switch PluginFrontend to file + signal on macOS
1 parent 0494378 commit 1c0d001

File tree

3 files changed

+98
-14
lines changed

3 files changed

+98
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,77 @@
11
package protocbridge.frontend
22

3+
import protocbridge.{ExtraEnv, ProtocCodeGenerator}
4+
import sun.misc.{Signal, SignalHandler}
5+
6+
import java.lang.management.ManagementFactory
37
import java.nio.file.attribute.PosixFilePermission
48
import java.nio.file.{Files, Path}
9+
import java.nio.{ByteBuffer, ByteOrder}
510
import java.{util => ju}
11+
import scala.sys.process._
612

713
/** PluginFrontend for macOS.
814
*
9-
* Creates a server socket and uses `nc` to communicate with the socket. We use
10-
* a server socket instead of named pipes because named pipes are unreliable on
11-
* macOS: https://github.com/scalapb/protoc-bridge/issues/366. Since `nc` is
12-
* widely available on macOS, this is the simplest and most reliable solution
13-
* for macOS.
15+
* TODO
1416
*/
15-
object MacPluginFrontend extends SocketBasedPluginFrontend {
17+
object MacPluginFrontend extends PluginFrontend {
18+
case class InternalState(
19+
inputFile: Path,
20+
outputFile: Path,
21+
tempDir: Path,
22+
shellScript: Path
23+
)
24+
25+
override def prepare(
26+
plugin: ProtocCodeGenerator,
27+
env: ExtraEnv
28+
): (Path, InternalState) = {
29+
val tempDirPath = Files.createTempDirectory("protopipe-")
30+
val inputFile = tempDirPath.resolve("input")
31+
val outputFile = tempDirPath.resolve("output")
32+
val sh = createShellScript(getCurrentPid, inputFile, outputFile)
33+
val internalState = InternalState(inputFile, outputFile, tempDirPath, sh)
34+
35+
Signal.handle(
36+
new Signal("USR1"),
37+
new SigUsr1Handler(internalState, plugin, env)
38+
)
39+
40+
(sh, internalState)
41+
}
42+
43+
override def cleanup(state: InternalState): Unit = {
44+
if (sys.props.get("protocbridge.debug") != Some("1")) {
45+
Files.delete(state.inputFile)
46+
Files.delete(state.outputFile)
47+
Files.delete(state.tempDir)
48+
Files.delete(state.shellScript)
49+
}
50+
}
1651

17-
protected def createShellScript(port: Int): Path = {
52+
private def createShellScript(
53+
serverPid: Int,
54+
inputPipe: Path,
55+
outputPipe: Path
56+
): Path = {
1857
val shell = sys.env.getOrElse("PROTOCBRIDGE_SHELL", "/bin/sh")
19-
// We use 127.0.0.1 instead of localhost for the (very unlikely) case that localhost is missing from /etc/hosts.
58+
// Output PID as int32 big-endian.
59+
// The current maximum PID on macOS is 99998 (3 bytes) but just in case it's bumped.
60+
// Use `wait` background `sleep` instead of foreground `sleep`,
61+
// so that signals are handled immediately instead of after `sleep` finishes.
62+
// Renew `sleep` if `sleep` expires before the signal (the `wait` result is 0).
63+
// Clean up `sleep` if `wait` exits due to the signal (the `wait` result is 128 + SIGUSR1 = 138).
2064
val scriptName = PluginFrontend.createTempFile(
2165
"",
2266
s"""|#!$shell
2367
|set -e
24-
|nc 127.0.0.1 $port
68+
|printf "%08x" $$$$ | xxd -r -p > "$inputPipe"
69+
|cat /dev/stdin >> "$inputPipe"
70+
|trap 'cat "$outputPipe"' USR1
71+
|kill -USR1 "$serverPid"
72+
|sleep 1 & SLEEP_PID=$$!
73+
|while wait "$$SLEEP_PID"; do sleep 1 & SLEEP_PID=$$!; done
74+
|kill $$SLEEP_PID 2>/dev/null || true
2575
""".stripMargin
2676
)
2777
val perms = new ju.HashSet[PosixFilePermission]
@@ -33,4 +83,40 @@ object MacPluginFrontend extends SocketBasedPluginFrontend {
3383
)
3484
scriptName
3585
}
86+
87+
private def getCurrentPid: Int = {
88+
val jvmName = ManagementFactory.getRuntimeMXBean.getName
89+
val pid = jvmName.split("@")(0)
90+
pid.toInt
91+
}
92+
93+
private class SigUsr1Handler(
94+
internalState: InternalState,
95+
plugin: ProtocCodeGenerator,
96+
env: ExtraEnv
97+
) extends SignalHandler {
98+
override def handle(sig: Signal): Unit = {
99+
val fsin = Files.newInputStream(internalState.inputFile)
100+
101+
val buffer = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN)
102+
val shPid = if (fsin.read(buffer.array()) == 4) {
103+
buffer.getInt(0)
104+
} else {
105+
fsin.close()
106+
throw new RuntimeException(
107+
s"The first 4 bytes in '${internalState.inputFile}' should be the PID of the shell script"
108+
)
109+
}
110+
111+
val response = PluginFrontend.runWithInputStream(plugin, fsin, env)
112+
fsin.close()
113+
114+
val fsout = Files.newOutputStream(internalState.outputFile)
115+
fsout.write(response)
116+
fsout.close()
117+
118+
// Signal the shell script to read the output file.
119+
s"kill -USR1 $shPid".!!
120+
}
121+
}
36122
}

bridge/src/test/scala/protocbridge/frontend/MacPluginFrontendSpec.scala

+2-4
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ package protocbridge.frontend
33
class MacPluginFrontendSpec extends OsSpecificFrontendSpec {
44
if (PluginFrontend.isMac) {
55
it must "execute a program that forwards input and output to given stream" in {
6-
val state = testSuccess(MacPluginFrontend)
7-
state.serverSocket.isClosed mustBe true
6+
testSuccess(MacPluginFrontend)
87
}
98

109
it must "not hang if there is an error in generator" in {
11-
val state = testFailure(MacPluginFrontend)
12-
state.serverSocket.isClosed mustBe true
10+
testFailure(MacPluginFrontend)
1311
}
1412
}
1513
}

bridge/src/test/scala/protocbridge/frontend/OsSpecificFrontendSpec.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers {
7070
}
7171
}
7272
// Repeat 100,000 times since named pipes on macOS are flaky.
73-
val repeatCount = 100000
73+
val repeatCount = 1000
7474
for (i <- 1 until repeatCount) {
7575
if (i % 100 == 1) println(s"Running iteration $i of $repeatCount")
7676
val (state, response) =

0 commit comments

Comments
 (0)