Skip to content

Commit 5a14de2

Browse files
committed
Switch PosixPluginFrontend to sockets
1 parent 9ba41d9 commit 5a14de2

9 files changed

Lines changed: 167 additions & 115 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package protocbridge.frontend
2+
3+
import java.nio.file.attribute.PosixFilePermission
4+
import java.nio.file.{Files, Path}
5+
import java.{util => ju}
6+
7+
/** PluginFrontend for macOS.
8+
*
9+
* Creates a server socket and uses `nc` to communicate with the socket.
10+
* Named pipes are unreliable on macOS: https://github.com/scalapb/protoc-bridge/issues/366.
11+
* Since `nc` is widely available on macOS, this is the simplest and most reliable solution for macOS.
12+
*/
13+
object MacPluginFrontend extends SocketBasedPluginFrontend {
14+
15+
protected def createShellScript(port: Int): Path = {
16+
val shell = sys.env.getOrElse("PROTOCBRIDGE_SHELL", "/bin/sh")
17+
val scriptName = PluginFrontend.createTempFile(
18+
"",
19+
s"""|#!$shell
20+
|set -e
21+
|nc localhost $port
22+
""".stripMargin
23+
)
24+
val perms = new ju.HashSet[PosixFilePermission]
25+
perms.add(PosixFilePermission.OWNER_EXECUTE)
26+
perms.add(PosixFilePermission.OWNER_READ)
27+
Files.setPosixFilePermissions(
28+
scriptName,
29+
perms
30+
)
31+
scriptName
32+
}
33+
}

bridge/src/main/scala/protocbridge/frontend/PluginFrontend.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ package protocbridge.frontend
22

33
import java.io.{ByteArrayOutputStream, InputStream, PrintWriter, StringWriter}
44
import java.nio.file.{Files, Path}
5-
6-
import protocbridge.{ProtocCodeGenerator, ExtraEnv}
5+
import protocbridge.{ExtraEnv, ProtocCodeGenerator}
76

87
import scala.util.Try
98

@@ -133,8 +132,11 @@ object PluginFrontend {
133132

134133
def isWindows: Boolean = sys.props("os.name").startsWith("Windows")
135134

135+
def isMac: Boolean = sys.props("os.name").startsWith("Mac") || sys.props("os.name").startsWith("Darwin")
136+
136137
def newInstance: PluginFrontend = {
137138
if (isWindows) WindowsPluginFrontend
139+
else if (isMac) MacPluginFrontend
138140
else PosixPluginFrontend
139141
}
140142
}

bridge/src/main/scala/protocbridge/frontend/PosixPluginFrontend.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import scala.concurrent.ExecutionContext.Implicits.global
1212
import scala.sys.process._
1313
import java.{util => ju}
1414

15-
/** PluginFrontend for Unix-like systems (Linux, Mac, etc)
15+
/** PluginFrontend for Unix-like systems <b>except macOS</b> (Linux, FreeBSD, etc)
1616
*
1717
* Creates a pair of named pipes for input/output and a shell script that
1818
* communicates with them.
19+
* Compared with `SocketBasedPluginFrontend`, this frontend should be more performant
20+
* and doesn't rely on `nc` that might not be available in some distributions.
1921
*/
2022
object PosixPluginFrontend extends PluginFrontend {
2123
case class InternalState(
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package protocbridge.frontend
2+
3+
import protocbridge.{ExtraEnv, ProtocCodeGenerator}
4+
5+
import java.net.ServerSocket
6+
import java.nio.file.{Files, Path}
7+
import scala.concurrent.ExecutionContext.Implicits.global
8+
import scala.concurrent.{Future, blocking}
9+
10+
/** PluginFrontend for Windows and macOS where a server socket is used.
11+
*/
12+
abstract class SocketBasedPluginFrontend extends PluginFrontend {
13+
case class InternalState(serverSocket: ServerSocket, shellScript: Path)
14+
15+
override def prepare(
16+
plugin: ProtocCodeGenerator,
17+
env: ExtraEnv
18+
): (Path, InternalState) = {
19+
val ss = new ServerSocket(0) // Bind to any available port.
20+
val sh = createShellScript(ss.getLocalPort)
21+
22+
Future {
23+
blocking {
24+
// Accept a single client connection from the shell script.
25+
val client = ss.accept()
26+
27+
try {
28+
val cis = client.getInputStream
29+
val response = PluginFrontend.runWithInputStream(plugin, cis, env)
30+
31+
val cos = client.getOutputStream
32+
cos.write(response)
33+
} catch {
34+
case e: Throwable =>
35+
// Handles rare exceptions not already gracefully handled in `runWithBytes`.
36+
// Such exceptions aren't converted to `CodeGeneratorResponse`
37+
// because `cis` might not be fully consumed,
38+
// therefore the plugin shell script might hang on `nc` write,
39+
// and never get to `nc` read and consume `CodeGeneratorResponse`.
40+
//
41+
// Instead, we simply force close the client connection,
42+
// so that the plugin shell script can exit.
43+
System.err.println("Exception occurred in PluginFrontend outside runWithBytes")
44+
e.printStackTrace(System.err)
45+
} finally {
46+
client.close()
47+
}
48+
}
49+
}
50+
(sh, InternalState(ss, sh))
51+
}
52+
53+
override def cleanup(state: InternalState): Unit = {
54+
state.serverSocket.close()
55+
if (sys.props.get("protocbridge.debug") != Some("1")) {
56+
Files.delete(state.shellScript)
57+
}
58+
}
59+
60+
protected def createShellScript(port: Int): Path
61+
}
Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,15 @@
11
package protocbridge.frontend
22

3-
import java.net.ServerSocket
4-
import java.nio.file.{Files, Path, Paths}
5-
6-
import protocbridge.ExtraEnv
7-
import protocbridge.ProtocCodeGenerator
8-
9-
import scala.concurrent.blocking
10-
11-
import scala.concurrent.ExecutionContext.Implicits.global
12-
import scala.concurrent.Future
3+
import java.nio.file.{Path, Paths}
134

145
/** A PluginFrontend that binds a server socket to a local interface. The plugin
156
* is a batch script that invokes BridgeApp.main() method, in a new JVM with
167
* the same parameters as the currently running JVM. The plugin will
178
* communicate its stdin and stdout to this socket.
189
*/
19-
object WindowsPluginFrontend extends PluginFrontend {
20-
21-
case class InternalState(batFile: Path)
22-
23-
override def prepare(
24-
plugin: ProtocCodeGenerator,
25-
env: ExtraEnv
26-
): (Path, InternalState) = {
27-
val ss = new ServerSocket(0)
28-
val state = createWindowsScript(ss.getLocalPort)
29-
30-
Future {
31-
blocking {
32-
val client = ss.accept()
33-
val response =
34-
PluginFrontend.runWithInputStream(plugin, client.getInputStream, env)
35-
client.getOutputStream.write(response)
36-
client.close()
37-
ss.close()
38-
}
39-
}
40-
41-
(state.batFile, state)
42-
}
43-
44-
override def cleanup(state: InternalState): Unit = {
45-
if (sys.props.get("protocbridge.debug") != Some("1")) {
46-
Files.delete(state.batFile)
47-
}
48-
}
10+
object WindowsPluginFrontend extends SocketBasedPluginFrontend {
4911

50-
private def createWindowsScript(port: Int): InternalState = {
12+
protected def createShellScript(port: Int): Path = {
5113
val classPath =
5214
Paths.get(getClass.getProtectionDomain.getCodeSource.getLocation.toURI)
5315
val classPathBatchString = classPath.toString.replace("%", "%%")
@@ -62,6 +24,6 @@ object WindowsPluginFrontend extends PluginFrontend {
6224
].getName} $port
6325
""".stripMargin
6426
)
65-
InternalState(batchFile)
27+
batchFile
6628
}
6729
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package protocbridge.frontend
2+
3+
class MacPluginFrontendSpec extends OsSpecificFrontendSpec {
4+
if (PluginFrontend.isMac) {
5+
it must "execute a program that forwards input and output to given stream" in {
6+
val state = testPluginFrontend(MacPluginFrontend)
7+
state.serverSocket.isClosed mustBe true
8+
}
9+
}
10+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package protocbridge.frontend
2+
3+
import org.apache.commons.io.IOUtils
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.must.Matchers
6+
import protocbridge.{ExtraEnv, ProtocCodeGenerator}
7+
8+
import java.io.ByteArrayOutputStream
9+
import scala.sys.process.ProcessIO
10+
import scala.util.Random
11+
12+
class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers {
13+
14+
protected def testPluginFrontend(frontend: PluginFrontend): frontend.InternalState = {
15+
val random = new Random()
16+
val toSend = Array.fill(123)(random.nextInt(256).toByte)
17+
val toReceive = Array.fill(456)(random.nextInt(256).toByte)
18+
val env = new ExtraEnv(secondaryOutputDir = "tmp")
19+
20+
val fakeGenerator = new ProtocCodeGenerator {
21+
override def run(request: Array[Byte]): Array[Byte] = {
22+
request mustBe (toSend ++ env.toByteArrayAsField)
23+
toReceive
24+
}
25+
}
26+
val (path, state) = frontend.prepare(
27+
fakeGenerator,
28+
env
29+
)
30+
val actualOutput = new ByteArrayOutputStream()
31+
val process = sys.process
32+
.Process(path.toAbsolutePath.toString)
33+
.run(new ProcessIO(writeInput => {
34+
writeInput.write(toSend)
35+
writeInput.close()
36+
}, processOutput => {
37+
IOUtils.copy(processOutput, actualOutput)
38+
processOutput.close()
39+
}, _.close()))
40+
process.exitValue()
41+
actualOutput.toByteArray mustBe toReceive
42+
frontend.cleanup(state)
43+
44+
state
45+
}
46+
}
Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,9 @@
11
package protocbridge.frontend
22

3-
import org.apache.commons.io.IOUtils
4-
import org.scalatest.flatspec.AnyFlatSpec
5-
import org.scalatest.matchers.must.Matchers
6-
import protocbridge.{ExtraEnv, ProtocCodeGenerator}
7-
8-
import java.io.ByteArrayOutputStream
9-
import scala.sys.process.ProcessIO
10-
import scala.util.Random
11-
12-
class PosixPluginFrontendSpec extends AnyFlatSpec with Matchers {
13-
if (!PluginFrontend.isWindows) {
3+
class PosixPluginFrontendSpec extends OsSpecificFrontendSpec {
4+
if (!PluginFrontend.isWindows && !PluginFrontend.isMac) {
145
it must "execute a program that forwards input and output to given stream" in {
15-
val random = new Random()
16-
val toSend = Array.fill(123)(random.nextInt(256).toByte)
17-
val toReceive = Array.fill(456)(random.nextInt(256).toByte)
18-
val env = new ExtraEnv(secondaryOutputDir = "tmp")
19-
20-
val fakeGenerator = new ProtocCodeGenerator {
21-
override def run(request: Array[Byte]): Array[Byte] = {
22-
request mustBe (toSend ++ env.toByteArrayAsField)
23-
toReceive
24-
}
25-
}
26-
val (path, state) = PosixPluginFrontend.prepare(
27-
fakeGenerator,
28-
env
29-
)
30-
val actualOutput = new ByteArrayOutputStream()
31-
val process = sys.process
32-
.Process(path.toAbsolutePath.toString)
33-
.run(new ProcessIO(writeInput => {
34-
writeInput.write(toSend)
35-
writeInput.close()
36-
}, processOutput => {
37-
IOUtils.copy(processOutput, actualOutput)
38-
processOutput.close()
39-
}, _.close()))
40-
process.exitValue()
41-
actualOutput.toByteArray mustBe toReceive
42-
PosixPluginFrontend.cleanup(state)
6+
testPluginFrontend(PosixPluginFrontend)
437
}
448
}
459
}
Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,10 @@
11
package protocbridge.frontend
22

3-
import java.io.ByteArrayInputStream
4-
5-
import protocbridge.{ProtocCodeGenerator, ExtraEnv}
6-
7-
import scala.sys.process.ProcessLogger
8-
import org.scalatest.flatspec.AnyFlatSpec
9-
import org.scalatest.matchers.must.Matchers
10-
11-
class WindowsPluginFrontendSpec extends AnyFlatSpec with Matchers {
3+
class WindowsPluginFrontendSpec extends OsSpecificFrontendSpec {
124
if (PluginFrontend.isWindows) {
135
it must "execute a program that forwards input and output to given stream" in {
14-
val toSend = "ping"
15-
val toReceive = "pong"
16-
val env = new ExtraEnv(secondaryOutputDir = "tmp")
17-
18-
val fakeGenerator = new ProtocCodeGenerator {
19-
override def run(request: Array[Byte]): Array[Byte] = {
20-
request mustBe (toSend.getBytes ++ env.toByteArrayAsField)
21-
toReceive.getBytes
22-
}
23-
}
24-
val (path, state) = WindowsPluginFrontend.prepare(
25-
fakeGenerator,
26-
env
27-
)
28-
val actualOutput = scala.collection.mutable.Buffer.empty[String]
29-
val process = sys.process
30-
.Process(path.toAbsolutePath.toString)
31-
.#<(new ByteArrayInputStream(toSend.getBytes))
32-
.run(ProcessLogger(o => actualOutput.append(o)))
33-
process.exitValue()
34-
actualOutput.mkString mustBe toReceive
35-
WindowsPluginFrontend.cleanup(state)
6+
val state = testPluginFrontend(WindowsPluginFrontend)
7+
state.serverSocket.isClosed mustBe true
368
}
379
}
3810
}

0 commit comments

Comments
 (0)