diff --git a/bridge/src/main/scala/protocbridge/frontend/MacPluginFrontend.scala b/bridge/src/main/scala/protocbridge/frontend/MacPluginFrontend.scala new file mode 100644 index 0000000..0ca4cad --- /dev/null +++ b/bridge/src/main/scala/protocbridge/frontend/MacPluginFrontend.scala @@ -0,0 +1,35 @@ +package protocbridge.frontend + +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.{Files, Path} +import java.{util => ju} + +/** PluginFrontend for macOS. + * + * Creates a server socket and uses `nc` to communicate with the socket. + * We use a server socket instead of named pipes because named pipes are unreliable on macOS: + * https://github.com/scalapb/protoc-bridge/issues/366. + * Since `nc` is widely available on macOS, this is the simplest and most reliable solution for macOS. + */ +object MacPluginFrontend extends SocketBasedPluginFrontend { + + protected def createShellScript(port: Int): Path = { + val shell = sys.env.getOrElse("PROTOCBRIDGE_SHELL", "/bin/sh") + // We use 127.0.0.1 instead of localhost for the (very unlikely) case that localhost is missing from /etc/hosts. + val scriptName = PluginFrontend.createTempFile( + "", + s"""|#!$shell + |set -e + |nc 127.0.0.1 $port + """.stripMargin + ) + val perms = new ju.HashSet[PosixFilePermission] + perms.add(PosixFilePermission.OWNER_EXECUTE) + perms.add(PosixFilePermission.OWNER_READ) + Files.setPosixFilePermissions( + scriptName, + perms + ) + scriptName + } +} diff --git a/bridge/src/main/scala/protocbridge/frontend/PluginFrontend.scala b/bridge/src/main/scala/protocbridge/frontend/PluginFrontend.scala index 88d8c76..e7dacc4 100644 --- a/bridge/src/main/scala/protocbridge/frontend/PluginFrontend.scala +++ b/bridge/src/main/scala/protocbridge/frontend/PluginFrontend.scala @@ -2,8 +2,7 @@ package protocbridge.frontend import java.io.{ByteArrayOutputStream, InputStream, PrintWriter, StringWriter} import java.nio.file.{Files, Path} - -import protocbridge.{ProtocCodeGenerator, ExtraEnv} +import protocbridge.{ExtraEnv, ProtocCodeGenerator} import scala.util.Try @@ -133,8 +132,11 @@ object PluginFrontend { def isWindows: Boolean = sys.props("os.name").startsWith("Windows") + def isMac: Boolean = sys.props("os.name").startsWith("Mac") || sys.props("os.name").startsWith("Darwin") + def newInstance: PluginFrontend = { if (isWindows) WindowsPluginFrontend + else if (isMac) MacPluginFrontend else PosixPluginFrontend } } diff --git a/bridge/src/main/scala/protocbridge/frontend/PosixPluginFrontend.scala b/bridge/src/main/scala/protocbridge/frontend/PosixPluginFrontend.scala index ef57687..2ab4347 100644 --- a/bridge/src/main/scala/protocbridge/frontend/PosixPluginFrontend.scala +++ b/bridge/src/main/scala/protocbridge/frontend/PosixPluginFrontend.scala @@ -12,10 +12,12 @@ import scala.concurrent.ExecutionContext.Implicits.global import scala.sys.process._ import java.{util => ju} -/** PluginFrontend for Unix-like systems (Linux, Mac, etc) +/** PluginFrontend for Unix-like systems except macOS (Linux, FreeBSD, etc) * * Creates a pair of named pipes for input/output and a shell script that * communicates with them. + * Compared with `SocketBasedPluginFrontend`, + * this frontend doesn't rely on `nc` that might not be available in some distributions. */ object PosixPluginFrontend extends PluginFrontend { case class InternalState( diff --git a/bridge/src/main/scala/protocbridge/frontend/SocketBasedPluginFrontend.scala b/bridge/src/main/scala/protocbridge/frontend/SocketBasedPluginFrontend.scala new file mode 100644 index 0000000..6fc0b95 --- /dev/null +++ b/bridge/src/main/scala/protocbridge/frontend/SocketBasedPluginFrontend.scala @@ -0,0 +1,47 @@ +package protocbridge.frontend + +import protocbridge.{ExtraEnv, ProtocCodeGenerator} + +import java.net.ServerSocket +import java.nio.file.{Files, Path} +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Future, blocking} + +/** PluginFrontend for Windows and macOS where a server socket is used. + */ +abstract class SocketBasedPluginFrontend extends PluginFrontend { + case class InternalState(serverSocket: ServerSocket, shellScript: Path) + + override def prepare( + plugin: ProtocCodeGenerator, + env: ExtraEnv + ): (Path, InternalState) = { + val ss = new ServerSocket(0) // Bind to any available port. + val sh = createShellScript(ss.getLocalPort) + + Future { + blocking { + // Accept a single client connection from the shell script. + val client = ss.accept() + try { + val response = + PluginFrontend.runWithInputStream(plugin, client.getInputStream, env) + client.getOutputStream.write(response) + } finally { + client.close() + } + } + } + + (sh, InternalState(ss, sh)) + } + + override def cleanup(state: InternalState): Unit = { + state.serverSocket.close() + if (sys.props.get("protocbridge.debug") != Some("1")) { + Files.delete(state.shellScript) + } + } + + protected def createShellScript(port: Int): Path +} diff --git a/bridge/src/main/scala/protocbridge/frontend/WindowsPluginFrontend.scala b/bridge/src/main/scala/protocbridge/frontend/WindowsPluginFrontend.scala index 490211d..adf9486 100644 --- a/bridge/src/main/scala/protocbridge/frontend/WindowsPluginFrontend.scala +++ b/bridge/src/main/scala/protocbridge/frontend/WindowsPluginFrontend.scala @@ -1,53 +1,15 @@ package protocbridge.frontend -import java.net.ServerSocket -import java.nio.file.{Files, Path, Paths} - -import protocbridge.ExtraEnv -import protocbridge.ProtocCodeGenerator - -import scala.concurrent.blocking - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.Future +import java.nio.file.{Path, Paths} /** A PluginFrontend that binds a server socket to a local interface. The plugin * is a batch script that invokes BridgeApp.main() method, in a new JVM with * the same parameters as the currently running JVM. The plugin will * communicate its stdin and stdout to this socket. */ -object WindowsPluginFrontend extends PluginFrontend { - - case class InternalState(batFile: Path) - - override def prepare( - plugin: ProtocCodeGenerator, - env: ExtraEnv - ): (Path, InternalState) = { - val ss = new ServerSocket(0) - val state = createWindowsScript(ss.getLocalPort) - - Future { - blocking { - val client = ss.accept() - val response = - PluginFrontend.runWithInputStream(plugin, client.getInputStream, env) - client.getOutputStream.write(response) - client.close() - ss.close() - } - } - - (state.batFile, state) - } - - override def cleanup(state: InternalState): Unit = { - if (sys.props.get("protocbridge.debug") != Some("1")) { - Files.delete(state.batFile) - } - } +object WindowsPluginFrontend extends SocketBasedPluginFrontend { - private def createWindowsScript(port: Int): InternalState = { + protected def createShellScript(port: Int): Path = { val classPath = Paths.get(getClass.getProtectionDomain.getCodeSource.getLocation.toURI) val classPathBatchString = classPath.toString.replace("%", "%%") @@ -62,6 +24,6 @@ object WindowsPluginFrontend extends PluginFrontend { ].getName} $port """.stripMargin ) - InternalState(batchFile) + batchFile } } diff --git a/bridge/src/test/scala/protocbridge/frontend/MacPluginFrontendSpec.scala b/bridge/src/test/scala/protocbridge/frontend/MacPluginFrontendSpec.scala new file mode 100644 index 0000000..6e8b972 --- /dev/null +++ b/bridge/src/test/scala/protocbridge/frontend/MacPluginFrontendSpec.scala @@ -0,0 +1,15 @@ +package protocbridge.frontend + +class MacPluginFrontendSpec extends OsSpecificFrontendSpec { + if (PluginFrontend.isMac) { + it must "execute a program that forwards input and output to given stream" in { + val state = testSuccess(MacPluginFrontend) + state.serverSocket.isClosed mustBe true + } + + it must "not hang if there is an error in generator" in { + val state = testFailure(MacPluginFrontend) + state.serverSocket.isClosed mustBe true + } + } +} diff --git a/bridge/src/test/scala/protocbridge/frontend/OsSpecificFrontendSpec.scala b/bridge/src/test/scala/protocbridge/frontend/OsSpecificFrontendSpec.scala index 6b81788..700bb27 100644 --- a/bridge/src/test/scala/protocbridge/frontend/OsSpecificFrontendSpec.scala +++ b/bridge/src/test/scala/protocbridge/frontend/OsSpecificFrontendSpec.scala @@ -16,7 +16,7 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers { generator: ProtocCodeGenerator, env: ExtraEnv, request: Array[Byte] - ): Array[Byte] = { + ): (frontend.InternalState, Array[Byte]) = { val (path, state) = frontend.prepare( generator, env @@ -40,10 +40,10 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers { }, _.close())) process.exitValue() frontend.cleanup(state) - actualOutput.toByteArray + (state, actualOutput.toByteArray) } - protected def testSuccess(frontend: PluginFrontend): Unit = { + protected def testSuccess(frontend: PluginFrontend): frontend.InternalState = { val random = new Random() val toSend = Array.fill(123)(random.nextInt(256).toByte) val toReceive = Array.fill(456)(random.nextInt(256).toByte) @@ -55,11 +55,12 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers { toReceive } } - val response = testPluginFrontend(frontend, fakeGenerator, env, toSend) + val (state, response) = testPluginFrontend(frontend, fakeGenerator, env, toSend) response mustBe toReceive + state } - protected def testFailure(frontend: PluginFrontend): Unit = { + protected def testFailure(frontend: PluginFrontend): frontend.InternalState = { val random = new Random() val toSend = Array.fill(123)(random.nextInt(256).toByte) val env = new ExtraEnv(secondaryOutputDir = "tmp") @@ -69,7 +70,8 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers { throw new OutOfMemoryError("test error") } } - val response = testPluginFrontend(frontend, fakeGenerator, env, toSend) + val (state, response) = testPluginFrontend(frontend, fakeGenerator, env, toSend) response.length must be > 0 + state } } diff --git a/bridge/src/test/scala/protocbridge/frontend/PosixPluginFrontendSpec.scala b/bridge/src/test/scala/protocbridge/frontend/PosixPluginFrontendSpec.scala index 2a3481c..4a6dd99 100644 --- a/bridge/src/test/scala/protocbridge/frontend/PosixPluginFrontendSpec.scala +++ b/bridge/src/test/scala/protocbridge/frontend/PosixPluginFrontendSpec.scala @@ -1,7 +1,7 @@ package protocbridge.frontend class PosixPluginFrontendSpec extends OsSpecificFrontendSpec { - if (!PluginFrontend.isWindows) { + if (!PluginFrontend.isWindows && !PluginFrontend.isMac) { it must "execute a program that forwards input and output to given stream" in { testSuccess(PosixPluginFrontend) } diff --git a/bridge/src/test/scala/protocbridge/frontend/WindowsPluginFrontendSpec.scala b/bridge/src/test/scala/protocbridge/frontend/WindowsPluginFrontendSpec.scala index 4bf39de..db0bc65 100644 --- a/bridge/src/test/scala/protocbridge/frontend/WindowsPluginFrontendSpec.scala +++ b/bridge/src/test/scala/protocbridge/frontend/WindowsPluginFrontendSpec.scala @@ -3,11 +3,13 @@ package protocbridge.frontend class WindowsPluginFrontendSpec extends OsSpecificFrontendSpec { if (PluginFrontend.isWindows) { it must "execute a program that forwards input and output to given stream" in { - testSuccess(WindowsPluginFrontend) + val state = testSuccess(WindowsPluginFrontend) + state.serverSocket.isClosed mustBe true } it must "not hang if there is an OOM in generator" in { - testFailure(WindowsPluginFrontend) + val state = testFailure(WindowsPluginFrontend) + state.serverSocket.isClosed mustBe true } } }