From 94cb763d328737c1952d24af30e41cde3a32ed1b Mon Sep 17 00:00:00 2001 From: Googler Date: Sun, 6 Aug 2023 18:50:46 -0700 Subject: [PATCH] Add initial skeleton of the new intellij-ext service. This adds the new external grpc server that will isolate google3 from the IDE and that will eventually replace the current grpc services. PiperOrigin-RevId: 554330557 --- base/BUILD | 1 + base/src/META-INF/blaze-base.xml | 7 + .../blaze/base/ext/IntelliJExtManager.java | 69 +++++++++ .../idea/blaze/base/ext/IntelliJExtMenu.java | 35 +++++ .../base/ext/IntelliJExtStatusAction.java | 37 +++++ .../idea/blaze/base/ext/StatusDialog.java | 136 ++++++++++++++++++ ext/BUILD | 54 +++++++ ext/proto/BUILD | 35 +++++ ext/proto/intellij-ext.proto | 63 ++++++++ .../idea/blaze/ext/IntelliJExtClient.java | 62 ++++++++ .../idea/blaze/ext/IntelliJExtServer.java | 135 +++++++++++++++++ .../idea/blaze/ext/IntelliJExtService.java | 107 ++++++++++++++ .../blaze/ext/IntelliJExtServiceTest.java | 78 ++++++++++ .../idea/blaze/ext/IntelliJExtTestServer.java | 90 ++++++++++++ 14 files changed, 909 insertions(+) create mode 100644 base/src/com/google/idea/blaze/base/ext/IntelliJExtManager.java create mode 100644 base/src/com/google/idea/blaze/base/ext/IntelliJExtMenu.java create mode 100644 base/src/com/google/idea/blaze/base/ext/IntelliJExtStatusAction.java create mode 100644 base/src/com/google/idea/blaze/base/ext/StatusDialog.java create mode 100644 ext/BUILD create mode 100644 ext/proto/BUILD create mode 100644 ext/proto/intellij-ext.proto create mode 100644 ext/src/com/google/idea/blaze/ext/IntelliJExtClient.java create mode 100644 ext/src/com/google/idea/blaze/ext/IntelliJExtServer.java create mode 100644 ext/src/com/google/idea/blaze/ext/IntelliJExtService.java create mode 100644 ext/tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java create mode 100644 ext/tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java diff --git a/base/BUILD b/base/BUILD index 1e87c70967c..71169e8cfb2 100644 --- a/base/BUILD +++ b/base/BUILD @@ -32,6 +32,7 @@ java_library( "//common/util:concurrency", "//common/util:platform", "//common/util:transactions", + "//ext:intellij_ext", "//intellij_platform_sdk:jsr305", # unuseddeps: keep for @Nullable "//intellij_platform_sdk:plugin_api", "//proto:proto_deps", diff --git a/base/src/META-INF/blaze-base.xml b/base/src/META-INF/blaze-base.xml index a4ab5192438..3e193f2f392 100644 --- a/base/src/META-INF/blaze-base.xml +++ b/base/src/META-INF/blaze-base.xml @@ -131,6 +131,8 @@ + + @@ -148,6 +150,10 @@ + + + @@ -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..b82cb637756 --- /dev/null +++ b/base/src/com/google/idea/blaze/base/ext/IntelliJExtManager.java @@ -0,0 +1,69 @@ +/* + * 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 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; + + public static IntelliJExtManager getInstance() { + return ApplicationManager.getApplication().getService(IntelliJExtManager.class); + } + + public 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)) { + 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..fd131d9b2b5 --- /dev/null +++ b/ext/BUILD @@ -0,0 +1,54 @@ +java_binary( + name = "client", + main_class = "com.google.idea.blaze.ext.IntelliJExtClientCli", + runtime_deps = [ + ":intellij_ext", + ], +) + +java_library( + name = "intellij_ext", + srcs = glob(["src/**/*.java"]), + visibility = ["//visibility:public"], + deps = [ + "//ext/proto:intellij_ext_java_grpc", + "//ext/proto:intellij_ext_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", + ], +) + +java_binary( + name = "IntelliJExtTestServer", + srcs = ["tests/com/google/idea/blaze/ext/IntelliJExtTestServer.java"], + main_class = "com.google.idea.blaze.ext.IntelliJExtTestServer", + deps = [ + "//ext/proto:intellij_ext_java_grpc", + "//ext/proto:intellij_ext_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 = [ + ":intellij_ext", + "//ext/proto:intellij_ext_java_proto", + "@junit//jar", + ], +) diff --git a/ext/proto/BUILD b/ext/proto/BUILD new file mode 100644 index 00000000000..94781374e41 --- /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 = "intellij_ext_proto", + srcs = ["intellij-ext.proto"], + has_services = 1, + use_java_stubby_library = True, +) + +java_proto_library( + name = "intellij_ext_java_proto", + visibility = ["//visibility:public"], + deps = [":intellij_ext_proto"], +) + +java_grpc_library( + name = "intellij_ext_java_grpc", + srcs = [":intellij_ext_proto"], + visibility = ["//visibility:public"], + deps = [":intellij_ext_java_proto"], +) + +go_proto_library( + name = "intellij_ext_go_proto", + visibility = ["//visibility:public"], + deps = [":intellij_ext_proto"], +) + +go_grpc_library( + name = "intellij_ext_go_grpc", + srcs = [":intellij_ext_proto"], + visibility = ["//visibility:public"], + deps = [":intellij_ext_go_proto"], +) diff --git a/ext/proto/intellij-ext.proto b/ext/proto/intellij-ext.proto new file mode 100644 index 00000000000..d7fc56781d7 --- /dev/null +++ b/ext/proto/intellij-ext.proto @@ -0,0 +1,63 @@ +/* + * 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; +} + +message ControlRequest { + string command = 1; +} + +message ControlResponse { + string response = 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, with various information. + 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..c2e32c1510c --- /dev/null +++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtClient.java @@ -0,0 +1,62 @@ +/* + * 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; + +/** The local client connected to the running intellij-ext service. */ +public class IntelliJExtClient { + + private final IntelliJExtBlockingStub stub; + + public IntelliJExtClient(String socket) { + DomainSocketAddress address = new DomainSocketAddress(socket); + 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(String 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() { + long now = System.currentTimeMillis(); + PingResponse unused = stub.ping(PingRequest.newBuilder().build()); + long delta = System.currentTimeMillis() - now; + System.out.printf("Server ping: %dms%n", delta); + 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..93fdc91daa3 --- /dev/null +++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtServer.java @@ -0,0 +1,135 @@ +/* + * 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.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +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.concurrent.CountDownLatch; + +/** 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 String socket; + + public static IntelliJExtServer create(Path binary, List args) throws IOException { + String socket = createSocket(); + ProcessBuilder pb = new ProcessBuilder(); + List command = new ArrayList<>(); + command.add(binary.toString()); + command.add("--socket"); + command.add(socket); + 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 140 characters in length, so we try to use /tmp + * directly as all other directories (IJ settings, user temp, etc) are longer than 140. + */ + private static String createSocket() throws IOException { + Set perm = PosixFilePermissions.fromString("rwx------"); + FileAttribute> attr = PosixFilePermissions.asFileAttribute(perm); + String directory = "/tmp"; + Path dir = Files.createTempDirectory(Paths.get(directory), ".idex", attr); + return dir.resolve(".idx.socket").toString(); + } + + public IntelliJExtServer(String socket, Process process) { + this.socket = socket; + this.process = process; + stderr = new StreamProcessor(process.getErrorStream()); + stdout = new StreamProcessor(process.getInputStream(), "[intellij-ext] ready"); + System.out.printf("Server running [pid %d], waiting for it to startup...%n", process.pid()); + } + + public boolean waitToBeReady() throws InterruptedException { + return stdout.waitForFirstLine(); + } + + public boolean isAlive() { + return process.isAlive(); + } + + public void destroy() { + process.destroyForcibly(); + } + + public String getSocket() { + return socket; + } + + private static class StreamProcessor extends Thread { + + private final InputStream stream; + private final String first; + private final CountDownLatch latch; + + private volatile boolean foundFirst; + + 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) { + System.out.println(line); + if (line.equals(first)) { + foundFirst = true; + latch.countDown(); + } + line = reader.readLine(); + } + } catch (IOException e) { + // Most likely the process ended. End of process handled elsewhere. + System.out.println("E"); + } + latch.countDown(); + } + + public boolean waitForFirstLine() throws InterruptedException { + latch.await(); + return foundFirst; + } + } +} 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..03b3794ce52 --- /dev/null +++ b/ext/src/com/google/idea/blaze/ext/IntelliJExtService.java @@ -0,0 +1,107 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 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; + + /** 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, InterruptedException { + status = ServiceStatus.INITIALIZING; + if (server != null) { + server.destroy(); + } + server = IntelliJExtServer.create(binary, serverArgs); + client = IntelliJExtClient.create(server.getSocket()); + boolean ready = server.waitToBeReady(); + status = ready ? ServiceStatus.READY : ServiceStatus.FAILED; + } + + /** + * 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. + */ + private synchronized IntelliJExtBlockingStub connect() throws IOException, InterruptedException { + if (client == null) { + start(); + } + try { + return client.getStubSafe(); + } catch (Exception e) { + System.out.println("Something is wrong with the server, restarting"); + start(); + return client.getStubSafe(); + } + } + + public ServiceStatus getServiceStatus() { + return status; + } + + public void additionalServerArguments(String s) { + serverArgs.add(s); + } + + // Simple implementations of the stub methods + public String getVersion() throws IOException, InterruptedException { + IntelliJExtBlockingStub stub = connect(); + Version version = stub.getVersion(GetVersionRequest.newBuilder().build()); + return version.getDescription() + "\n" + version.getVersion(); + } + + public Map getStatus() throws IOException, InterruptedException { + IntelliJExtBlockingStub stub = connect(); + Status response = stub.getStatus(GetStatusRequest.newBuilder().build()); + HashMap status = new HashMap<>(); + for (StatusValue s : response.getStatusList()) { + status.put(s.getProperty(), s.getValue()); + } + return status; + } +} 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..6137e460534 --- /dev/null +++ b/ext/tests/com/google/idea/blaze/ext/IntelliJExtServiceTest.java @@ -0,0 +1,78 @@ +/* + * 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import com.google.idea.blaze.ext.IntelliJExtService.ServiceStatus; +import java.nio.file.Paths; +import java.util.Optional; +import org.junit.Assert; +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 [1.0]"; + + @Test + public void testSimpleGetVersion() throws Exception { + IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER)); + assertEquals(TEST_VERSION, service.getVersion()); + assertEquals(ServiceStatus.READY, service.getServiceStatus()); + } + + @Test + public void testServerFailedToInitialize() { + IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER)); + service.additionalServerArguments("--fail_to_initialize"); + try { + String unused = service.getVersion(); + Assert.fail("Exception was not thrown"); + } catch (Exception e) { + // Expected + } + assertEquals(ServiceStatus.FAILED, service.getServiceStatus()); + } + + @Test + public void testServerCrashes() throws Exception { + IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER)); + assertEquals(TEST_VERSION, service.getVersion()); + String pid = service.getStatus().get("pid"); + Optional process = ProcessHandle.of(Integer.valueOf(pid)); + assertTrue(process.isPresent()); + process.get().destroyForcibly(); + // Server should restart nicely. + assertEquals(TEST_VERSION, service.getVersion()); + assertNotEquals(pid, service.getStatus().get("pid")); + } + + @Test + public void testServerDoesNotRestart() throws Exception { + IntelliJExtService service = new IntelliJExtService(Paths.get(SERVER)); + assertEquals(TEST_VERSION, service.getVersion()); + String pid = service.getStatus().get("pid"); + assertEquals(TEST_VERSION, service.getVersion()); + assertEquals(pid, service.getStatus().get("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..0139f42589a --- /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.newBuilder().build()); + 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(); + } +}