Skip to content

Commit

Permalink
Switch PluginFrontend to sockets on macOS
Browse files Browse the repository at this point in the history
  • Loading branch information
bell-db authored and thesamet committed Aug 27, 2024
1 parent 44b9062 commit c574d50
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,13 @@ 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ 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 <b>except macOS</b> (Linux, FreeBSD,
* etc)
*
* Creates a pair of named pipes for input/output and a shell script that
* communicates with them.
* 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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
}
Original file line number Diff line number Diff line change
@@ -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("%", "%%")
Expand All @@ -62,6 +24,6 @@ object WindowsPluginFrontend extends PluginFrontend {
].getName} $port
""".stripMargin
)
InternalState(batchFile)
batchFile
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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
Expand Down Expand Up @@ -45,10 +45,12 @@ class OsSpecificFrontendSpec extends AnyFlatSpec with Matchers {
)
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)
Expand All @@ -60,11 +62,15 @@ 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")
Expand All @@ -74,7 +80,9 @@ 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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

0 comments on commit c574d50

Please sign in to comment.