@@ -226,6 +232,7 @@
+
diff --git a/base/src/com/google/idea/blaze/base/ext/IntelliJExtManager.java b/base/src/com/google/idea/blaze/base/ext/IntelliJExtManager.java
new file mode 100644
index 00000000000..4bab327b498
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/ext/IntelliJExtManager.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.base.ext;
+
+import com.google.idea.blaze.ext.IntelliJExtService;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * An application level manager that creates the IntelliJExtService.
+ *
+ * To enable this service the system property `intellij.ext.binary` must be set to be pointing to
+ * the executable that provides the extended services. This executable must implement the
+ * intellij-ext grpc interface for the IDE to communicate with it.
+ */
+public class IntelliJExtManager {
+
+ public static final String INTELLIJ_EXT_BINARY = "intellij.ext.binary";
+
+ private IntelliJExtService service;
+
+ private static final Logger logger = Logger.getInstance(IntelliJExtManager.class);
+
+ public static IntelliJExtManager getInstance() {
+ return ApplicationManager.getApplication().getService(IntelliJExtManager.class);
+ }
+
+ public synchronized IntelliJExtService getService() {
+ if (service == null) {
+ Path path = getBinaryPath();
+ if (path == null) {
+ throw new IllegalStateException("No intellij-ext binary found");
+ }
+ service = new IntelliJExtService(path);
+ }
+ return service;
+ }
+
+ @Nullable
+ private Path getBinaryPath() {
+ String binary = System.getProperty(INTELLIJ_EXT_BINARY);
+ if (binary == null) {
+ return null;
+ }
+ Path path = Paths.get(binary);
+ if (!Files.exists(path)) {
+ logger.warn(String.format("intellij-ext binary path %s does not exist", path));
+ return null;
+ }
+ return path;
+ }
+
+ boolean isEnabled() {
+ return getBinaryPath() != null;
+ }
+}
diff --git a/base/src/com/google/idea/blaze/base/ext/IntelliJExtMenu.java b/base/src/com/google/idea/blaze/base/ext/IntelliJExtMenu.java
new file mode 100644
index 00000000000..bd1997730bc
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/ext/IntelliJExtMenu.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.base.ext;
+
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.actionSystem.DefaultActionGroup;
+import org.jetbrains.annotations.NotNull;
+
+/** The menu for all intellij-ext actions */
+public class IntelliJExtMenu extends DefaultActionGroup {
+
+ @Override
+ public void update(@NotNull AnActionEvent e) {
+ boolean enabled = IntelliJExtManager.getInstance().isEnabled();
+ e.getPresentation().setEnabledAndVisible(enabled);
+ }
+
+ @Override
+ public boolean isDumbAware() {
+ return true;
+ }
+}
diff --git a/base/src/com/google/idea/blaze/base/ext/IntelliJExtStatusAction.java b/base/src/com/google/idea/blaze/base/ext/IntelliJExtStatusAction.java
new file mode 100644
index 00000000000..2aa936f8383
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/ext/IntelliJExtStatusAction.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.base.ext;
+
+import com.google.idea.blaze.ext.IntelliJExtService;
+import com.intellij.openapi.actionSystem.AnActionEvent;
+import com.intellij.openapi.project.DumbAwareAction;
+import org.jetbrains.annotations.NotNull;
+
+/** An action to show the connection status against the intellij-ext server. */
+public class IntelliJExtStatusAction extends DumbAwareAction {
+
+ @Override
+ public void actionPerformed(@NotNull AnActionEvent e) {
+ IntelliJExtService service = IntelliJExtManager.getInstance().getService();
+ new StatusDialog(e.getProject(), service).show();
+ }
+
+ @Override
+ public void update(@NotNull AnActionEvent e) {
+ boolean enabled = IntelliJExtManager.getInstance().isEnabled();
+ e.getPresentation().setEnabledAndVisible(enabled);
+ }
+}
diff --git a/base/src/com/google/idea/blaze/base/ext/StatusDialog.java b/base/src/com/google/idea/blaze/base/ext/StatusDialog.java
new file mode 100644
index 00000000000..0a8d0ac5532
--- /dev/null
+++ b/base/src/com/google/idea/blaze/base/ext/StatusDialog.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.base.ext;
+
+import com.google.idea.blaze.ext.IntelliJExtService;
+import com.intellij.openapi.ide.CopyPasteManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.ui.DialogWrapper;
+import com.intellij.ui.AppUIUtil;
+import com.intellij.ui.components.JBLabel;
+import com.intellij.ui.scale.ScaleContext;
+import com.intellij.util.ui.JBFont;
+import com.intellij.util.ui.JBUI;
+import com.intellij.util.ui.UIUtil;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.util.Map;
+import java.util.Map.Entry;
+import javax.swing.Action;
+import javax.swing.Box;
+import javax.swing.Icon;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.SwingConstants;
+import org.jetbrains.annotations.Nullable;
+
+/** The status dialog for the connection to the intellij-ext service. */
+public class StatusDialog extends DialogWrapper {
+
+ private final Box box;
+
+ private String content;
+
+ public StatusDialog(@Nullable Project project, IntelliJExtService service) {
+ super(project, false);
+ this.box = Box.createVerticalBox();
+ setResizable(false);
+ setTitle("Extended IntelliJ Services");
+ init();
+ setContent("Loading...", null);
+ new Thread(
+ () -> {
+ String message;
+ Map status = null;
+ try {
+ status = service.getStatus();
+ message = service.getVersion();
+ } catch (Exception e) {
+ message = "Error\nCannot fetch status, see logs.";
+ }
+ String finalMessage = message;
+ Map finalStatus = status;
+ UIUtil.invokeLaterIfNeeded(() -> setContent(finalMessage, finalStatus));
+ })
+ .start();
+ }
+
+ @Override
+ protected @Nullable JComponent createCenterPanel() {
+ Icon appIcon = AppUIUtil.loadApplicationIcon(ScaleContext.create(), 60);
+ JLabel icon = new JLabel(appIcon);
+ icon.setVerticalAlignment(SwingConstants.TOP);
+ icon.setBorder(JBUI.Borders.empty(20, 12, 0, 24));
+ box.setBorder(JBUI.Borders.empty(20, 0, 0, 20));
+
+ return JBUI.Panels.simplePanel().addToLeft(icon).addToCenter(box);
+ }
+
+ @Override
+ protected void createDefaultActions() {
+ super.createDefaultActions();
+ myOKAction =
+ new OkAction() {
+ @Override
+ protected void doAction(ActionEvent e) {
+ copyAboutInfoToClipboard();
+ close(OK_EXIT_CODE);
+ }
+ };
+ myOKAction.putValue(Action.NAME, "Copy and Close");
+ myCancelAction.putValue(Action.NAME, "Close");
+ }
+
+ private void setContent(String message, Map status) {
+ String content = message;
+ box.removeAll();
+ String[] lines = message.split("\n");
+ box.add(label(lines[0], JBFont.regular().asBold()));
+ for (int i = 1; i < lines.length; i++) {
+ box.add(label(lines[i], JBFont.small()));
+ }
+ box.add(Box.createVerticalStrut(10));
+ if (status != null) {
+ Box line = Box.createHorizontalBox();
+ line.setAlignmentX(0.0f);
+ for (Entry e : status.entrySet()) {
+ line.add(label(e.getKey(), JBFont.regular().asBold()));
+ line.add(Box.createHorizontalStrut(5));
+ line.add(label(e.getValue(), JBFont.small()));
+ box.add(line);
+ content += "\n" + e.getKey() + ": " + e.getValue();
+ }
+ }
+ this.content = content;
+ this.getContentPane().revalidate();
+ }
+
+ private static JLabel label(String text, JBFont font) {
+ return new JBLabel(text).withFont(font);
+ }
+
+ private String getPlainText() {
+ return content;
+ }
+
+ private void copyAboutInfoToClipboard() {
+ try {
+ CopyPasteManager.getInstance().setContents(new StringSelection(getPlainText()));
+ } catch (Exception ignore) {
+ // Ignore
+ }
+ }
+}
diff --git a/ext/BUILD b/ext/BUILD
new file mode 100644
index 00000000000..10a2991639e
--- /dev/null
+++ b/ext/BUILD
@@ -0,0 +1,57 @@
+java_binary(
+ name = "client",
+ main_class = "com.google.idea.blaze.ext.IntelliJExtClientCli",
+ runtime_deps = [
+ ":intellijext",
+ ],
+)
+
+java_library(
+ name = "intellijext",
+ srcs = glob(["src/**/*.java"]),
+ visibility = ["//visibility:public"],
+ deps = [
+ "//ext/proto:intellijext_java_grpc",
+ "//ext/proto:intellijext_java_proto",
+ "//third_party/java/grpc:core",
+ "//third_party/java/grpc:netty",
+ "//third_party/java/netty4:common",
+ "//third_party/java/netty4:transport",
+ "//third_party/java/netty4:transport_native_epoll",
+ "//third_party/java/netty4:transport_native_unix_common",
+ "@com_google_guava_guava//jar",
+ ],
+)
+
+java_binary(
+ name = "IntelliJExtTestServer",
+ srcs = ["tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java"],
+ main_class = "com.google.idea.blaze.ext.IntelliJExtTestServer",
+ deps = [
+ "//ext/proto:intellijext_java_grpc",
+ "//ext/proto:intellijext_java_proto",
+ "//third_party/java/grpc:core",
+ "//third_party/java/grpc:netty",
+ "//third_party/java/grpc:stub",
+ "//third_party/java/netty4:common",
+ "//third_party/java/netty4:transport",
+ "//third_party/java/netty4:transport_native_epoll",
+ "//third_party/java/netty4:transport_native_unix_common",
+ ],
+)
+
+java_test(
+ name = "IntelliJExtServiceTest",
+ srcs = ["tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java"],
+ data = [
+ "//ext:IntelliJExtTestServer_deploy.jar",
+ ],
+ test_class = "com.google.idea.blaze.ext.IntelliJExtServiceTest",
+ deps = [
+ ":intellijext",
+ "//ext/proto:intellijext_java_proto",
+ "//third_party/java/grpc:core",
+ "@junit//jar",
+ "@truth//jar",
+ ],
+)
diff --git a/ext/proto/BUILD b/ext/proto/BUILD
new file mode 100644
index 00000000000..4f53b727069
--- /dev/null
+++ b/ext/proto/BUILD
@@ -0,0 +1,35 @@
+load("//third_party/java/grpc:build_defs.bzl", "java_grpc_library")
+load("//net/grpc/go/build_defs:go_grpc_library.bzl", "go_grpc_library")
+
+proto_library(
+ name = "intellijext_proto",
+ srcs = ["intellijext.proto"],
+ has_services = 1,
+ use_java_stubby_library = True,
+)
+
+java_proto_library(
+ name = "intellijext_java_proto",
+ visibility = ["//visibility:public"],
+ deps = [":intellijext_proto"],
+)
+
+java_grpc_library(
+ name = "intellijext_java_grpc",
+ srcs = [":intellijext_proto"],
+ visibility = ["//visibility:public"],
+ deps = [":intellijext_java_proto"],
+)
+
+go_proto_library(
+ name = "intellijext_go_proto",
+ visibility = ["//visibility:public"],
+ deps = [":intellijext_proto"],
+)
+
+go_grpc_library(
+ name = "intellijext_go_grpc",
+ srcs = [":intellijext_proto"],
+ visibility = ["//visibility:public"],
+ deps = [":intellijext_go_proto"],
+)
diff --git a/ext/proto/intellijext.proto b/ext/proto/intellijext.proto
new file mode 100644
index 00000000000..05554a36f0b
--- /dev/null
+++ b/ext/proto/intellijext.proto
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+syntax = "proto3";
+
+package com.google.idea.blaze.ext;
+
+option java_package = "com.google.idea.blaze.ext";
+option java_multiple_files = true;
+option java_outer_classname = "IntelliJExtProto";
+option use_java_stubby_library = true;
+
+message GetVersionRequest {}
+
+message Version {
+ string description = 1;
+ string version = 2;
+}
+
+message PingRequest {}
+
+message PingResponse {}
+
+message GetStatusRequest {}
+
+message StatusValue {
+ string property = 1;
+ string value = 2;
+}
+message Status {
+ repeated StatusValue status = 1;
+}
+
+service IntelliJExt {
+ // Returns the version of this server
+ rpc GetVersion(GetVersionRequest) returns (Version) {}
+
+ // A simple no-op request to test server responsiveness and availability.
+ rpc Ping(PingRequest) returns (PingResponse) {}
+
+ // Returns a list of property-value pairs, to show the status
+ // of the running server. The property names are descriptive only
+ // and not to be depended on.
+ rpc GetStatus(GetStatusRequest) returns (Status) {}
+}
diff --git a/ext/src/com/google/idea/blaze/ext/IntelliJExtClient.java b/ext/src/com/google/idea/blaze/ext/IntelliJExtClient.java
new file mode 100644
index 00000000000..dac45523abc
--- /dev/null
+++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtClient.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.ext;
+
+import com.google.idea.blaze.ext.IntelliJExtGrpc.IntelliJExtBlockingStub;
+import io.grpc.ManagedChannel;
+import io.grpc.netty.NettyChannelBuilder;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.epoll.EpollDomainSocketChannel;
+import io.netty.channel.epoll.EpollEventLoopGroup;
+import io.netty.channel.unix.DomainSocketAddress;
+import io.netty.util.concurrent.DefaultThreadFactory;
+import java.nio.file.Path;
+
+/** The local client connected to the running intellij-ext service. */
+public class IntelliJExtClient {
+
+ private final IntelliJExtBlockingStub stub;
+
+ public IntelliJExtClient(Path socket) {
+ DomainSocketAddress address = new DomainSocketAddress(socket.toFile());
+ EpollEventLoopGroup group =
+ new EpollEventLoopGroup(new DefaultThreadFactory(EventLoopGroup.class, true));
+ ManagedChannel channel =
+ NettyChannelBuilder.forAddress(address)
+ .eventLoopGroup(group)
+ .channelType(EpollDomainSocketChannel.class)
+ .withOption(ChannelOption.SO_KEEPALIVE, false)
+ .usePlaintext()
+ .build();
+ stub = IntelliJExtGrpc.newBlockingStub(channel);
+ }
+
+ public static IntelliJExtClient create(Path socket) {
+ return new IntelliJExtClient(socket);
+ }
+
+ /**
+ * Gets the client stub after performing a ping to the server. If it returns a stub, it means
+ * there is an active connection to the server. If not an exception is thrown.
+ */
+ public IntelliJExtBlockingStub getStubSafe() {
+ PingResponse unused = stub.ping(PingRequest.getDefaultInstance());
+ return stub;
+ }
+}
diff --git a/ext/src/com/google/idea/blaze/ext/IntelliJExtServer.java b/ext/src/com/google/idea/blaze/ext/IntelliJExtServer.java
new file mode 100644
index 00000000000..e7484b5b729
--- /dev/null
+++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtServer.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.ext;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Logger;
+
+/** The running server that implements intellij-ext that the IDE is connected to. */
+public class IntelliJExtServer {
+
+ private final StreamProcessor stderr;
+ private final StreamProcessor stdout;
+ private final Process process;
+ private final Path socket;
+
+ private static final Logger logger = Logger.getLogger(IntelliJExtServer.class.getName());
+
+ public static IntelliJExtServer create(Path binary, List args) throws IOException {
+ Path socket = createSocket();
+ ProcessBuilder pb = new ProcessBuilder();
+ List command = new ArrayList<>();
+ command.add(binary.toString());
+ command.add("--socket");
+ command.add(socket.toString());
+ command.addAll(args);
+ Process process = pb.command(command).start();
+ return new IntelliJExtServer(socket, process);
+ }
+
+ /**
+ * Creates a directory only readable to the current user that can hold the socket file created by
+ * the server. The socket path cannot be more than 104 characters in length, so we try to use /tmp
+ * directly as all other directories (IJ settings, user temp, etc) are longer. (eg.
+ * https://man.freebsd.org/cgi/man.cgi?query=unix&sektion=4)
+ */
+ private static Path createSocket() throws IOException {
+ Set perm = PosixFilePermissions.fromString("rwx------");
+ FileAttribute> attr = PosixFilePermissions.asFileAttribute(perm);
+ Path dir = Files.createTempDirectory(Paths.get("/tmp"), ".idex", attr);
+ return dir.resolve(".idx.socket");
+ }
+
+ public IntelliJExtServer(Path socket, Process process) {
+ this.socket = socket;
+ this.process = process;
+ stderr = new StreamProcessor(process.getErrorStream());
+ stdout = new StreamProcessor(process.getInputStream(), "[intellij-ext] ready");
+ logger.info(
+ String.format("Server running [pid %d], waiting for it to startup...", process.pid()));
+ }
+
+ public boolean waitToBeReady() {
+ return stdout.waitForFirstLine();
+ }
+
+ public void destroy() {
+ process.destroyForcibly();
+ }
+
+ public Path getSocket() {
+ return socket;
+ }
+}
diff --git a/ext/src/com/google/idea/blaze/ext/IntelliJExtService.java b/ext/src/com/google/idea/blaze/ext/IntelliJExtService.java
new file mode 100644
index 00000000000..2ed69631a48
--- /dev/null
+++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtService.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.ext;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.idea.blaze.ext.IntelliJExtGrpc.IntelliJExtBlockingStub;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * A service that maintains the connection to an external extension service. The external service is
+ * passed in as a grpc server binary. This class ensures there is an active connection to the
+ * server, and restarts the server and client if such a connection is lost.
+ */
+public final class IntelliJExtService {
+
+ private final Path binary;
+ private final List serverArgs;
+
+ private IntelliJExtServer server;
+ private IntelliJExtClient client;
+ private volatile ServiceStatus status;
+
+ private static final Logger logger = Logger.getLogger(IntelliJExtService.class.getName());
+
+ /** The status of the connection to the server */
+ public enum ServiceStatus {
+ INITIALIZING,
+ READY,
+ FAILED,
+ }
+
+ public IntelliJExtService(Path binary) {
+ this.binary = binary;
+ this.serverArgs = new ArrayList<>();
+ this.status = ServiceStatus.INITIALIZING;
+ }
+
+ private synchronized void start() throws IOException {
+ status = ServiceStatus.INITIALIZING;
+ if (server != null) {
+ // This would be a rare situation where the server process still exists
+ // but we are in the situation that we couldn't connect to it. This
+ // would mean that the process is alive and the grpc server is down or
+ // not responsive, we kill it before starting a new one.
+ server.destroy();
+ }
+ try {
+ server = IntelliJExtServer.create(binary, serverArgs);
+ client = IntelliJExtClient.create(server.getSocket());
+ boolean ready = server.waitToBeReady();
+ status = ready ? ServiceStatus.READY : ServiceStatus.FAILED;
+ } catch (IOException e) {
+ status = ServiceStatus.FAILED;
+ throw e;
+ }
+ }
+
+ /**
+ * A lazy connection to the server. If there is no client the server is started, and a client
+ * connected to it. Every time this is called a ping is issued to the server to ensure it's
+ * running, if it has died for some reason, the server is restarted and a new client is connected
+ * to it.
+ *
+ * Note this does not implement a retry loop intentionally. There should not be a case where we
+ * "try again", just to see if next time works. This connection should be stable. There are
+ * legitimate reasons why the server is gone, crashed or simply killed itself after a period of
+ * inactivity. This method will start it up again, and resume execution.
+ */
+ private synchronized IntelliJExtBlockingStub connect() throws IOException {
+ if (client == null) {
+ start();
+ }
+ try {
+ return client.getStubSafe();
+ } catch (RuntimeException e) {
+ logger.log(Level.SEVERE, "Error running the intellij-ext server, restarting", e);
+ start();
+ return client.getStubSafe();
+ }
+ }
+
+ public ServiceStatus getServiceStatus() {
+ return status;
+ }
+
+ @VisibleForTesting
+ public void additionalServerArguments(String s) {
+ serverArgs.add(s);
+ }
+
+ // Simple implementations of the stub methods
+ public String getVersion() throws IOException {
+ IntelliJExtBlockingStub stub = connect();
+ Version version = stub.getVersion(GetVersionRequest.getDefaultInstance());
+ return version.getDescription() + "\n" + version.getVersion();
+ }
+
+ /**
+ * A set of property, value pairs that describe the current status of the server, property names
+ * are descriptive only and not to be depended on.
+ */
+ public Map getStatus() throws IOException {
+ IntelliJExtBlockingStub stub = connect();
+ Status response = stub.getStatus(GetStatusRequest.getDefaultInstance());
+ HashMap status = new HashMap<>();
+ for (StatusValue s : response.getStatusList()) {
+ status.put(s.getProperty(), s.getValue());
+ }
+ return status;
+ }
+}
diff --git a/ext/src/com/google/idea/blaze/ext/StreamProcessor.java b/ext/src/com/google/idea/blaze/ext/StreamProcessor.java
new file mode 100644
index 00000000000..dabbe3cdf23
--- /dev/null
+++ b/ext/src/com/google/idea/blaze/ext/StreamProcessor.java
@@ -0,0 +1,57 @@
+package com.google.idea.blaze.ext;
+
+import com.google.common.util.concurrent.Uninterruptibles;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.concurrent.CountDownLatch;
+import java.util.logging.Logger;
+
+class StreamProcessor extends Thread {
+
+ private final InputStream stream;
+ private final String first;
+ private final CountDownLatch latch;
+
+ private volatile boolean foundFirst;
+
+ private static final Logger logger = Logger.getLogger(StreamProcessor.class.getName());
+
+ public StreamProcessor(InputStream stream) {
+ this(stream, null);
+ }
+
+ public StreamProcessor(InputStream stream, String first) {
+ setDaemon(true);
+ this.stream = stream;
+ this.first = first;
+ this.latch = new CountDownLatch(first == null ? 0 : 1);
+ this.foundFirst = false;
+ this.start();
+ }
+
+ @Override
+ public void run() {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) {
+ String line = reader.readLine();
+ while (line != null) {
+ logger.info(line);
+ if (line.equals(first)) {
+ foundFirst = true;
+ latch.countDown();
+ }
+ line = reader.readLine();
+ }
+ } catch (IOException e) {
+ // Ignore, process errors and exceptions are handled elsewhere.
+ } finally {
+ latch.countDown();
+ }
+ }
+
+ public boolean waitForFirstLine() {
+ Uninterruptibles.awaitUninterruptibly(latch);
+ return foundFirst;
+ }
+}
diff --git a/ext/tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java b/ext/tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java
new file mode 100644
index 00000000000..781d02f159d
--- /dev/null
+++ b/ext/tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.ext;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.idea.blaze.ext.IntelliJExtService.ServiceStatus;
+import io.grpc.StatusRuntimeException;
+import java.nio.file.Paths;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class IntelliJExtServiceTest {
+
+ private static final String SERVER =
+ "ext/IntelliJExtTestServer_deploy.jar";
+ public static final String TEST_VERSION = "test server\n1.0";
+
+ @Test
+ public void testSimpleGetVersion() throws Exception {
+ IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER));
+ assertThat(service.getVersion()).isEqualTo(TEST_VERSION);
+ assertThat(service.getServiceStatus()).isEqualTo(ServiceStatus.READY);
+ }
+
+ @Test
+ public void testServerFailedToInitialize() {
+ IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER));
+ service.additionalServerArguments("--fail_to_initialize");
+ assertThrows(StatusRuntimeException.class, service::getVersion);
+ assertThat(service.getServiceStatus()).isEqualTo(ServiceStatus.FAILED);
+ }
+
+ @Test
+ public void testServerCrashes() throws Exception {
+ IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER));
+ assertThat(service.getVersion()).isEqualTo(TEST_VERSION);
+ String pid = service.getStatus().get("pid");
+ Optional process = ProcessHandle.of(Integer.parseInt(pid));
+ assertThat(process).isPresent();
+ process.get().destroyForcibly();
+ // Server should restart nicely.
+ assertThat(service.getVersion()).isEqualTo(TEST_VERSION);
+ assertThat(service.getStatus().get("pid")).isNotEqualTo(pid);
+ }
+
+ @Test
+ public void testServerDoesNotRestart() throws Exception {
+ IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER));
+ assertThat(service.getVersion()).isEqualTo(TEST_VERSION);
+ String pid = service.getStatus().get("pid");
+ assertThat(service.getVersion()).isEqualTo(TEST_VERSION);
+ assertThat(service.getStatus().get("pid")).isEqualTo(pid);
+ }
+}
diff --git a/ext/tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java b/ext/tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java
new file mode 100644
index 00000000000..1dcd1c1d301
--- /dev/null
+++ b/ext/tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.idea.blaze.ext;
+
+import com.google.idea.blaze.ext.IntelliJExtGrpc.IntelliJExtImplBase;
+import io.grpc.Server;
+import io.grpc.netty.NettyServerBuilder;
+import io.grpc.stub.StreamObserver;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.epoll.EpollEventLoopGroup;
+import io.netty.channel.epoll.EpollServerDomainSocketChannel;
+import io.netty.channel.unix.DomainSocketAddress;
+import io.netty.util.concurrent.DefaultThreadFactory;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Iterator;
+
+public final class IntelliJExtTestServer extends IntelliJExtImplBase {
+
+ @Override
+ public void getVersion(GetVersionRequest request, StreamObserver responseObserver) {
+ Version v = Version.newBuilder().setDescription("test server").setVersion("1.0").build();
+ responseObserver.onNext(v);
+ responseObserver.onCompleted();
+ }
+
+ @Override
+ public void ping(PingRequest request, StreamObserver responseObserver) {
+ responseObserver.onNext(PingResponse.getDefaultInstance());
+ responseObserver.onCompleted();
+ }
+
+ @Override
+ public void getStatus(GetStatusRequest request, StreamObserver responseObserver) {
+ String pid = String.valueOf(ProcessHandle.current().pid());
+
+ Status status =
+ Status.newBuilder()
+ .addStatus(StatusValue.newBuilder().setProperty("pid").setValue(pid))
+ .build();
+ responseObserver.onNext(status);
+ responseObserver.onCompleted();
+ }
+
+ public static void main(String[] args) throws Exception {
+
+ String socket = null;
+ boolean failToInitialize = false;
+ Iterator it = Arrays.stream(args).iterator();
+ while (it.hasNext()) {
+ String arg = it.next();
+ if (arg.equals("--socket") && it.hasNext()) {
+ socket = it.next();
+ } else if (arg.equals("--fail_to_initialize")) {
+ failToInitialize = true;
+ }
+ }
+
+ Path path = Paths.get(socket);
+ EpollEventLoopGroup group =
+ new EpollEventLoopGroup(new DefaultThreadFactory(EventLoopGroup.class, true));
+ NettyServerBuilder sb =
+ NettyServerBuilder.forAddress(new DomainSocketAddress(path.toString()))
+ .channelType(EpollServerDomainSocketChannel.class)
+ .bossEventLoopGroup(group)
+ .addService(new IntelliJExtTestServer())
+ .workerEventLoopGroup(group);
+
+ Server server = sb.build().start();
+ if (failToInitialize) {
+ System.exit(1);
+ }
+ System.out.println("[intellij-ext] ready");
+ server.awaitTermination();
+ }
+}