diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..08c39f6 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,125 @@ +name: JetBrains plugin pull request checks + +on: + pull_request: + paths: + - "jetbrains_plugin/**" + - ".github/workflows/pr.yml" + +permissions: + contents: read + +concurrency: + group: jetbrains-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + name: Connector tests, build, and 2026.3 compatibility gates + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out the JetBrains plugin + uses: actions/checkout@v5 + with: + persist-credentials: false + + # One source of truth: the build number and the pinned archive digest + # live in jetbrains_plugin/gradle.properties, next to each other. + - name: Read the centralized verification IDE values + shell: bash + run: | + build="$(sed -n 's/^idea263Build=//p' jetbrains_plugin/gradle.properties)" + digest="$(sed -n 's/^idea263Sha256=//p' jetbrains_plugin/gradle.properties)" + if [ -z "$build" ] || [ -z "$digest" ]; then + echo "idea263Build or idea263Sha256 is missing from jetbrains_plugin/gradle.properties." + exit 1 + fi + echo "IDEA_263_BUILD=$build" >> "$GITHUB_ENV" + echo "IDEA_263_SHA256=$digest" >> "$GITHUB_ENV" + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Restore the cached 2026.3 verification IDE + id: idea263-cache + uses: actions/cache@v4 + with: + path: jetbrains_plugin/build/ideaIC-* + key: ideaIC-263-${{ env.IDEA_263_SHA256 }} + + # The same pinned download as release.yml: the exact IC- EAP + # archive is fetched from the IntelliJ snapshots repository, checked + # against the SHA-256 from gradle.properties, and unpacked once. + - name: Download and unpack the 2026.3 verification IDE + working-directory: jetbrains_plugin + shell: bash + run: | + ide_zip="build/ideaIC-${IDEA_263_BUILD}.zip" + ide_dir="build/ideaIC-${IDEA_263_BUILD}" + # build/ is gitignored, so a fresh checkout has no build directory. + # Create it before curl writes the archive. + mkdir -p build + if [ -d "$ide_dir/bin" ]; then + echo "Using cached $ide_dir" + exit 0 + fi + curl --fail --silent --show-error --retry 3 --location -o "$ide_zip" \ + "https://www.jetbrains.com/intellij-repository/snapshots/com/jetbrains/intellij/idea/ideaIC/${IDEA_263_BUILD}-EAP-SNAPSHOT/ideaIC-${IDEA_263_BUILD}-EAP-SNAPSHOT.zip" + echo "${IDEA_263_SHA256} ${ide_zip}" | sha256sum --check --status || { + echo "Downloaded ideaIC-${IDEA_263_BUILD}.zip does not match the pinned SHA-256 ${IDEA_263_SHA256}." + exit 1 + } + unzip -q "$ide_zip" -d "$ide_dir" + + # prepareSandbox copies the extension-host payload into the sandbox. The + # pull-request gates verify the plugin bytecode and do not need that + # payload, so the two required input paths are created empty. + - name: Create the sandbox input paths the Gradle build needs + working-directory: jetbrains_plugin + shell: bash + run: | + mkdir -p plugins/zoo-code/extension + touch plugins/zoo-code/extension/.keep prodDep.txt + + # The connector tests are pure JVM tests and the focus of these gates. + # The platform-fixture tests need a display and the extension payload, + # which a pull-request build does not assemble. + - name: Run the connector tests + working-directory: jetbrains_plugin + shell: bash + run: > + ./gradlew test + --tests "org.zoocode.jetbrains.terminal.RawOutputTtyConnectorTest" + --no-configuration-cache + + - name: Build the plugin + working-directory: jetbrains_plugin + shell: bash + run: ./gradlew buildPlugin --no-configuration-cache + + - name: Reject packaged references to the 2026.3-removed terminal API + working-directory: jetbrains_plugin + shell: bash + run: ./gradlew verifyPackagedBytecode --no-configuration-cache + + - name: Verify plugin compatibility with the exact IDEA 2026.3 build + working-directory: jetbrains_plugin + shell: bash + run: > + ./gradlew runPluginVerifier + -PverifierLocalIde="build/ideaIC-${IDEA_263_BUILD}" + --no-configuration-cache + + - name: Smoke check the 2026.3 createTtyConnector dispatch + working-directory: jetbrains_plugin + shell: bash + run: > + ./gradlew smoke263Dispatch + -PsmokeLocalIde="build/ideaIC-${IDEA_263_BUILD}" + --no-configuration-cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7becca..4883b31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,19 @@ jobs: with: lfs: true submodules: recursive + persist-credentials: false + + # The pinned digest lives in jetbrains_plugin/gradle.properties next to + # idea263Build, so one value serves this workflow and pr.yml. + - name: Read the pinned verification IDE digest + shell: bash + run: | + digest="$(sed -n 's/^idea263Sha256=//p' jetbrains_plugin/gradle.properties)" + if [ -z "$digest" ]; then + echo "idea263Sha256 is missing from jetbrains_plugin/gradle.properties." + exit 1 + fi + echo "IDEA_263_SHA256=$digest" >> "$GITHUB_ENV" - name: Set up Java 17 uses: actions/setup-java@v5 @@ -174,6 +187,81 @@ jobs: ZOO_EXTENSION_VERSION: ${{ needs.prepare.outputs.version }} run: ./scripts/release.sh --mode release --clean --verbose + # Every verification step runs with the same -P flags as the publish step so + # Gradle reuses one built artifact across the gates and the upload. Verifying a + # differently configured build would say nothing about the shipped zip. + - name: Reject packaged references to the 2026.3-removed terminal API + working-directory: jetbrains_plugin + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: > + ./gradlew verifyPackagedBytecode + -PdebugMode=release + -PvscodePlugin=zoo-code + -PpluginVersion="$VERSION" + --no-configuration-cache + + # gradle-intellij 1.x resolves verification targets from release feeds only, so the + # 2026.3 EAP build is fetched from the IntelliJ snapshots repository, checked + # against the pinned SHA-256, cached, and handed to runPluginVerifier through + # -PverifierLocalIde. The build number lives in jetbrains_plugin/gradle.properties. + - name: Restore the cached 2026.3 verification IDE + id: idea263-cache + uses: actions/cache@v4 + with: + path: jetbrains_plugin/build/ideaIC-* + key: ideaIC-263-${{ env.IDEA_263_SHA256 }} + + - name: Download and unpack the 2026.3 verification IDE + working-directory: jetbrains_plugin + shell: bash + run: | + verifier_build="$(sed -n 's/^idea263Build=//p' gradle.properties)" + ide_zip="build/ideaIC-${verifier_build}.zip" + ide_dir="build/ideaIC-${verifier_build}" + if [ -d "$ide_dir/bin" ]; then + echo "Using cached $ide_dir" + exit 0 + fi + curl --fail --silent --show-error --retry 3 --location -o "$ide_zip" \ + "https://www.jetbrains.com/intellij-repository/snapshots/com/jetbrains/intellij/idea/ideaIC/${verifier_build}-EAP-SNAPSHOT/ideaIC-${verifier_build}-EAP-SNAPSHOT.zip" + echo "${IDEA_263_SHA256} ${ide_zip}" | sha256sum --check --status || { + echo "Downloaded ideaIC-${verifier_build}.zip does not match the pinned SHA-256 ${IDEA_263_SHA256}." + exit 1 + } + unzip -q "$ide_zip" -d "$ide_dir" + + - name: Verify plugin compatibility with IDEA 2026.3 + working-directory: jetbrains_plugin + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + verifier_build="$(sed -n 's/^idea263Build=//p' gradle.properties)" + ./gradlew runPluginVerifier \ + -PdebugMode=release \ + -PvscodePlugin=zoo-code \ + -PpluginVersion="$VERSION" \ + -PverifierLocalIde="build/ideaIC-${verifier_build}" \ + --no-configuration-cache + + # The verifier cannot see a silently non-overriding createTtyConnector + # signature; this step proves the dispatch binds on the exact downloaded build. + - name: Smoke check the 2026.3 createTtyConnector dispatch + working-directory: jetbrains_plugin + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + verifier_build="$(sed -n 's/^idea263Build=//p' gradle.properties)" + ./gradlew smoke263Dispatch \ + -PdebugMode=release \ + -PvscodePlugin=zoo-code \ + -PpluginVersion="$VERSION" \ + -PsmokeLocalIde="build/ideaIC-${verifier_build}" \ + --no-configuration-cache + - name: Publish to JetBrains Marketplace if: needs.prepare.outputs.marketplace-published != 'true' env: diff --git a/jetbrains_plugin/build.gradle.kts b/jetbrains_plugin/build.gradle.kts index 64183da..809ff92 100644 --- a/jetbrains_plugin/build.gradle.kts +++ b/jetbrains_plugin/build.gradle.kts @@ -4,6 +4,8 @@ // SPDX-License-Identifier: APACHE2.0 // SPDX-License-Identifier: Apache-2.0 +import org.jetbrains.intellij.tasks.RunPluginVerifierTask + // Convenient for reading variables from gradle.properties fun properties(key: String) = providers.gradleProperty(key) @@ -28,6 +30,11 @@ kotlin { jvmToolchain(17) } +// Toolchain launcher used by verification tasks that start a plain JVM. +val verificationJavaLauncher = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(17)) +} + // ------------------------------------------------------------ // The 'debugMode' setting controls how plugin resources are prepared during the build process. // It supports the following three modes: @@ -293,6 +300,165 @@ tasks { untilBuild.set("") } + // One binary must keep 233 support and stay compatible with the 2026.3 terminal + // changes. Verify against the newest supported platform; failures are limited to + // hard compatibility problems so deprecated-API notices do not hide them. + // + // The gradle-intellij 1.x task resolves ideVersions from release feeds only, and + // IC-263.5701.42 (idea263Build property) is an EAP build that no feed lists. CI + // (release.yml) downloads the exact build and passes it as + // -PverifierLocalIde=. Local runs have two clear paths: + // ./gradlew runPluginVerifier -PverifierLocalIde= + // ./gradlew runPluginVerifier -PverifierIdeVersion=IC- + runPluginVerifier { + // Verifier releases: https://repo1.maven.org/maven2/org/jetbrains/intellij/plugins/verifier-cli/ + verifierVersion.set("1.410") + val idea263Build = providers.gradleProperty("idea263Build").orElse("263.5701.42") + val explicitVersions = providers.gradleProperty("verifierIdeVersion").orNull + ideVersions.set(listOf(explicitVersions ?: "IC-${idea263Build.get()}")) + failureLevel.set( + listOf( + RunPluginVerifierTask.FailureLevel.COMPATIBILITY_PROBLEMS, + RunPluginVerifierTask.FailureLevel.INVALID_PLUGIN + ) + ) + val localIde = providers.gradleProperty("verifierLocalIde").orNull + if (localIde != null) { + ideVersions.set(emptyList()) + localPaths.set(listOf(file(localIde))) + } else if (explicitVersions == null) { + doFirst { + throw GradleException( + "runPluginVerifier needs an IDE target. IC-${idea263Build.get()} is an EAP build that " + + "gradle-intellij cannot resolve from release feeds. Download " + + "ideaIC-${idea263Build.get()}-EAP-SNAPSHOT.zip, unpack it, and pass " + + "-PverifierLocalIde= (this is what release.yml does for CI), " + + "or verify a release feed build with -PverifierIdeVersion=IC-.", + ) + } + } + } + + // Regression gate: a packaged method reference to LocalTerminalDirectRunner + // .createProcess(ShellStartupOptions) breaks the same binary on IntelliJ 2026.3, + // where that method returns java.lang.Process instead of PtyProcess, so a 2023.3 + // call site does not resolve. The 2023.3 path and the 2026.3 path both start the + // process through TerminalInstance.startTerminalProcess. + // + // The packaged plugin jar inside the sandbox: instrumentation may rename it with + // an "instrumented-" prefix, and patchPluginXml writes the release descriptor into + // exactly this copy. + fun packagedPluginJar(): File { + val archiveName = jar.get().archiveFileName.get() + val libDir = File(File(prepareSandbox.get().destinationDir, intellij.pluginName.get()), "lib") + val candidates = libDir.listFiles { file -> file.name.endsWith(archiveName) } + ?: throw GradleException("Sandbox lib directory not found: $libDir") + return candidates.firstOrNull { it.name.startsWith("instrumented-") } + ?: candidates.firstOrNull() + ?: throw GradleException("No packaged plugin jar ($archiveName) found in $libDir") + } + + // The scan delegates to smoke/Smoke263DispatchCheck.java --scan-jar so the banned + // reference list and the constant-pool parsing live in one place. The checker is + // linked against the 233 platform jars already on the compile classpath; this task + // never needs the 2026.3 IDE. The scan target is the instrumented jar in the + // sandbox, the exact bytes that the Plugin Verifier checks and that the published + // zip packages. The marker output makes the task up-to-date when the sandbox and + // the checker are unchanged. + register("verifyPackagedBytecode") { + group = "verification" + description = "Reject packaged references to terminal APIs removed in IntelliJ 2026.3" + val checker = layout.projectDirectory.file("smoke/Smoke263DispatchCheck.java") + inputs.files(prepareSandbox.map { it.destinationDir }) + inputs.file(checker) + outputs.file(layout.buildDirectory.file("verification/packaged-bytecode.ok")) + dependsOn(prepareSandbox) + doLast { + val pluginJar = packagedPluginJar() + project.exec { + commandLine( + verificationJavaLauncher.get().executablePath.asFile.absolutePath, + "-cp", + configurations.getByName("compileClasspath").asPath, + checker.asFile.absolutePath, + "--scan-jar", + pluginJar.absolutePath, + ) + } + layout.buildDirectory.file("verification/packaged-bytecode.ok").get().asFile.writeText(pluginJar.path) + } + } + named("check") { + dependsOn("verifyPackagedBytecode") + } + + // Headless 2026.3 proof that the Plugin Verifier cannot give: the platform entry + // point createTtyConnector(ShellStartupOptions) exists and is overridable, the + // override in TerminalInstance.createCustomRunner has the identical descriptor on + // a direct LocalTerminalDirectRunner subclass so the virtual dispatch binds, no + // packaged class references the removed createProcess descriptor, and + // RawOutputTtyConnector decodes and forwards output when linked against this IDE + // build's jediterm and pty4j. + register("smoke263Dispatch") { + group = "verification" + description = "Prove the 2026.3 createTtyConnector dispatch against an unpacked ideaIC 263.5701.42" + val checker = layout.projectDirectory.file("smoke/Smoke263DispatchCheck.java") + val idea263Build = providers.gradleProperty("idea263Build").orElse("263.5701.42") + val ideDir = providers.gradleProperty("smokeLocalIde") + .orElse(layout.buildDirectory.dir(idea263Build.map { "ideaIC-$it" }).map { it.asFile.absolutePath }) + // The checker reads the first jar as bytes and loads the other five. Track only + // this jar set so unrelated files in the unpacked IDE cannot invalidate the task. + val ideJarPaths = listOf( + "plugins/terminal/lib/terminal.jar", + "lib/intellij.libraries.pty4j.jar", + "lib/intellij.libraries.jediterm.core.jar", + "lib/intellij.libraries.jediterm.ui.jar", + "lib/util-8.jar", + "lib/util_rt.jar", + ) + inputs.files(prepareSandbox.map { it.destinationDir }) + inputs.file(checker) + inputs.files(ideDir.map { ideDirectory -> ideJarPaths.map { File(ideDirectory, it) } }) + inputs.property("idea263Build", idea263Build) + outputs.file(layout.buildDirectory.file("verification/smoke263-dispatch.ok")) + dependsOn(prepareSandbox) + doLast { + val ideDirectory = file(ideDir.get()) + check(ideDirectory.isDirectory) { + "Unpacked 2026.3 IDE not found at $ideDirectory. Unpack " + + "ideaIC-${idea263Build.get()}-EAP-SNAPSHOT.zip there or pass " + + "-PsmokeLocalIde=." + } + val ideJars = ideJarPaths.map { ideDirectory.resolve(it) } + ideJars.forEach { + check(it.isFile) { + "Expected 2026.3 IDE jar missing: $it. The ideaIC layout changed and the " + + "smoke checker needs updated jar locations." + } + } + // The checker classpath holds only jars that the connector links against and + // that ship as Java 8 or Java 11 bytecode. The 2026.3 platform classes are + // Java 25 bytecode; the checker reads them as bytes and must never load them. + // util-8 carries the platform Logger, AppExecutorUtil and the Kotlin runtime + // that the connector needs at class-init time. + val terminalJar = ideJars.first() + val pluginJar = packagedPluginJar() + val classpath = ideJars.drop(1) + pluginJar + project.exec { + commandLine( + verificationJavaLauncher.get().executablePath.asFile.absolutePath, + "-cp", + classpath.joinToString(File.pathSeparator), + checker.asFile.absolutePath, + pluginJar.absolutePath, + terminalJar.absolutePath, + ) + } + val smokeMarker = layout.buildDirectory.file("verification/smoke263-dispatch.ok").get().asFile + smokeMarker.writeText("${idea263Build.get()} ${pluginJar.absolutePath}") + } + } + signPlugin { certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) privateKey.set(System.getenv("PRIVATE_KEY")) diff --git a/jetbrains_plugin/gradle.properties b/jetbrains_plugin/gradle.properties index aeb0f48..eda0347 100644 --- a/jetbrains_plugin/gradle.properties +++ b/jetbrains_plugin/gradle.properties @@ -7,5 +7,14 @@ platformVersion=2023.3 platformType=IC pluginSinceBuild=233 +# The 2026.3 EAP build used by runPluginVerifier and smoke263Dispatch. The +# GitHub Actions workflows download the archive once, check it against the +# pinned digest below, and cache the unpacked IDE. +idea263Build=263.5701.42 + +# SHA-256 of the ideaIC--EAP-SNAPSHOT.zip archive. release.yml and +# pr.yml both read this value, so it is pinned in one place. +idea263Sha256=e8c679ef071297714151d1f182e5e729e8d1afee216c06ece47fad89a6316846 + # Other project configurations can be added here vscodeVersion=1.100.0 diff --git a/jetbrains_plugin/smoke/Smoke263DispatchCheck.java b/jetbrains_plugin/smoke/Smoke263DispatchCheck.java new file mode 100644 index 0000000..1a59340 --- /dev/null +++ b/jetbrains_plugin/smoke/Smoke263DispatchCheck.java @@ -0,0 +1,426 @@ +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Headless proof for the 2026.3 terminal compatibility, run by the + * smoke263Dispatch Gradle task against an unpacked ideaIC 263.5701.42. + * It checks the facts that the Plugin Verifier cannot see: + *
    + *
  • the platform entry point createTtyConnector(ShellStartupOptions) exists + * on LocalTerminalDirectRunner and is overridable there (public, not + * static, not private, not final),
  • + *
  • the custom runner is a direct LocalTerminalDirectRunner subclass whose + * createTtyConnector with the identical descriptor can override it. The + * JVM dispatch rule that matters here is: same name and descriptor, the + * subclass method is public or protected and not static, the platform + * method is not final, static or private. A final subclass method is a + * valid override; it only cannot itself be overridden, which nothing + * needs to do,
  • + *
  • no packaged class references LocalTerminalDirectRunner#createProcess, + * whose 2023.3 descriptor does not resolve on 2026.3,
  • + *
  • RawOutputTtyConnector decodes and forwards output when linked against + * this IDE build's jediterm and pty4j binaries.
  • + *
+ * + * The 2026.3 platform classes are Java 25 bytecode, so they are parsed as + * bytes and never loaded. The execution part uses only the IDE libraries + * that ship as Java 8 or Java 11 bytecode: jediterm, pty4j and util-8, which + * carries the platform Logger, AppExecutorUtil and the Kotlin runtime. + */ +public class Smoke263DispatchCheck { + + private static final String RUNNER_CLASS = "org/jetbrains/plugins/terminal/LocalTerminalDirectRunner"; + private static final String DISPATCH_DESCRIPTOR = + "(Lorg/jetbrains/plugins/terminal/ShellStartupOptions;)Lcom/jediterm/terminal/TtyConnector;"; + private static final String BANNED_REF_PREFIX = + "org/jetbrains/plugins/terminal/LocalTerminalDirectRunner#createProcess#"; + private static final String MULTIBYTE = "naive — 你好, τξζ, 🚀 smoke"; + + private static final int ACC_PUBLIC = 0x0001; + private static final int ACC_FINAL = 0x0010; + private static final int ACC_SUPER = 0x0020; + private static final int ACC_ABSTRACT = 0x0400; + private static final int ACC_INTERFACE = 0x0200; + + public static void main(String[] args) throws Exception { + if (args.length == 2 && "--scan-jar".equals(args[0])) { + checkNoBannedRefs(Paths.get(args[1])); + System.out.println("[smoke-263] scan OK: no packaged reference to " + BANNED_REF_PREFIX + "*"); + return; + } + Path pluginJar = Paths.get(args[0]); + Path terminalJar = Paths.get(args[1]); + for (Path path : new Path[]{pluginJar, terminalJar}) { + if (!Files.isRegularFile(path)) { + throw new AssertionError("Required jar not found: " + path); + } + } + + checkPlatformEntryPoint(terminalJar); + checkDispatchBinds(pluginJar); + checkNoBannedRefs(pluginJar); + checkConnectorForwards(); + + System.out.println("[smoke-263] OK: dispatch binds and output forwarding works against the 2026.3 IDE jars"); + } + + private static void checkPlatformEntryPoint(Path terminalJar) throws Exception { + byte[] runnerBytes = readClass(terminalJar, RUNNER_CLASS + ".class"); + ClassFile runner = ClassFile.parse(runnerBytes); + if ((runner.access & ACC_INTERFACE) != 0) { + throw new AssertionError(RUNNER_CLASS + " became an interface"); + } + + String[] entryPoint = runner.findMethod("createTtyConnector", DISPATCH_DESCRIPTOR); + if (entryPoint == null) { + throw new AssertionError( + RUNNER_CLASS + " does not declare createTtyConnector" + DISPATCH_DESCRIPTOR + + "; the 2026.3 entry point moved elsewhere"); + } + checkOverridable(RUNNER_CLASS, entryPoint); + + // The compatibility premise: the 2023.3 signature returns PtyProcess and the + // 2026.3 signature returns java.lang.Process. Both facts must hold, otherwise + // this smoke check no longer describes the platforms it claims to compare. + if (runner.findMethod("createProcess", + "(Lorg/jetbrains/plugins/terminal/ShellStartupOptions;)Ljava/lang/Process;") == null) { + throw new AssertionError( + RUNNER_CLASS + " no longer declares createProcess(ShellStartupOptions) returning " + + "java.lang.Process; the premise of the compatibility patch changed"); + } + if (runner.findMethod("createProcess", + "(Lorg/jetbrains/plugins/terminal/ShellStartupOptions;)Lcom/pty4j/PtyProcess;") != null) { + throw new AssertionError( + "createProcess(ShellStartupOptions) still returns PtyProcess on this IDE; " + + "the premise of the compatibility patch changed"); + } + System.out.println("[smoke-263] platform entry point: createTtyConnector" + DISPATCH_DESCRIPTOR + + " on " + RUNNER_CLASS + " is public, non-static and non-final"); + } + + /** + * An entry point the subclass must replace has to be callable dispatch + * around: public, not static, not private and not final. A private or + * static platform method would never dispatch, and a final one would send + * every 2026.3 session to the platform implementation. + */ + private static void checkOverridable(String owner, String[] method) { + int access = Integer.parseInt(method[2]); + List blockers = new ArrayList<>(); + if ((access & ACC_PUBLIC) == 0) blockers.add("not public"); + if ((access & 0x0008) != 0) blockers.add("static"); + if ((access & 0x0002) != 0) blockers.add("private"); + if ((access & ACC_FINAL) != 0) blockers.add("final"); + if (!blockers.isEmpty()) { + throw new AssertionError(owner + ".createTtyConnector" + DISPATCH_DESCRIPTOR + + " cannot be overridden: " + String.join(", ", blockers)); + } + } + + private static void checkDispatchBinds(Path pluginJar) throws Exception { + String customRunner = null; + try (JarFile jar = new JarFile(pluginJar.toFile())) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (!name.endsWith(".class")) { + continue; + } + ClassFile parsed = ClassFile.parse(jar.getInputStream(entry).readAllBytes()); + if (!RUNNER_CLASS.equals(parsed.superName) || parsed.className.equals(RUNNER_CLASS)) { + continue; + } + if ((parsed.access & ACC_ABSTRACT) != 0 || (parsed.access & ACC_INTERFACE) != 0) { + throw new AssertionError(parsed.className + + " extends " + RUNNER_CLASS + " but is abstract or an interface;" + + " no object of it can receive the dispatch"); + } + String[] override = parsed.findMethod("createTtyConnector", DISPATCH_DESCRIPTOR); + if (override != null) { + customRunner = parsed.className; + int access = Integer.parseInt(override[2]); + List blockers = new ArrayList<>(); + if ((access & ACC_PUBLIC) == 0) blockers.add("not public"); + if ((access & 0x0008) != 0) blockers.add("static"); + if ((access & 0x0002) != 0) blockers.add("private"); + if (!blockers.isEmpty()) { + throw new AssertionError(parsed.className + ".createTtyConnector" + + DISPATCH_DESCRIPTOR + " cannot override: " + String.join(", ", blockers)); + } + // A final subclass method is a valid override; it only cannot + // itself be overridden, which the platform never does. + boolean finalMethod = (access & ACC_FINAL) != 0; + System.out.println("[smoke-263] dispatch binds to override: " + + parsed.className.replace('/', '.') + ".createTtyConnector" + + DISPATCH_DESCRIPTOR + (finalMethod ? " (final method, valid override)" : "")); + } + } + } + if (customRunner == null) { + throw new AssertionError( + "No LocalTerminalDirectRunner subclass declares createTtyConnector" + DISPATCH_DESCRIPTOR + + "; the platform default connector would run on 2026.3"); + } + } + + private static void checkNoBannedRefs(Path pluginJar) throws Exception { + List violations = new ArrayList<>(); + try (JarFile jar = new JarFile(pluginJar.toFile())) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".class")) { + continue; + } + ClassFile parsed = ClassFile.parse(jar.getInputStream(entry).readAllBytes()); + for (String ref : parsed.memberRefs) { + if (ref.startsWith(BANNED_REF_PREFIX)) { + violations.add(entry.getName() + ": " + ref); + } + } + } + } + if (!violations.isEmpty()) { + throw new AssertionError( + "Packaged bytecode references LocalTerminalDirectRunner.createProcess. IntelliJ " + + "2026.3 changed that method to return java.lang.Process, so a 2023.3 call " + + "site does not resolve:\n" + String.join("\n", violations) + + "\nStart the process through TerminalInstance.startTerminalProcess instead."); + } + } + + private static void checkConnectorForwards() throws Exception { + Class connectorClass = Class.forName("org.zoocode.jetbrains.terminal.RawOutputTtyConnector"); + Class callbackClass = Class.forName("org.zoocode.jetbrains.terminal.TerminalRawDataCallback"); + Class processClass = Class.forName("com.pty4j.PtyProcess"); + Class charsetClass = Charset.class; + + Constructor constructor = connectorClass.getConstructor(processClass, charsetClass, callbackClass); + if (!java.lang.reflect.Modifier.isPublic(constructor.getModifiers())) { + throw new AssertionError("RawOutputTtyConnector constructor is not public"); + } + + StringBuilder forwarded = new StringBuilder(); + Object callback = Proxy.newProxyInstance( + callbackClass.getClassLoader(), + new Class[]{callbackClass}, + (proxy, method, methodArgs) -> { + if ("onRawData".equals(method.getName())) { + forwarded.append((String) methodArgs[0]); + } + return null; + }); + + FakePtyProcess process = new FakePtyProcess( + new java.io.ByteArrayInputStream(MULTIBYTE.getBytes(StandardCharsets.UTF_8)), + new java.io.ByteArrayOutputStream()); + Object connector = constructor.newInstance(process, StandardCharsets.UTF_8, callback); + + Method read = connectorClass.getMethod("read", char[].class, int.class, int.class); + char[] buffer = new char[4]; + StringBuilder decoded = new StringBuilder(); + while (true) { + int count = (Integer) read.invoke(connector, buffer, 0, buffer.length); + if (count < 0) { + break; + } + decoded.append(buffer, 0, count); + } + + if (!MULTIBYTE.contentEquals(decoded)) { + throw new AssertionError("Connector read() returned " + decoded + " instead of " + MULTIBYTE); + } + if (!MULTIBYTE.contentEquals(forwarded)) { + throw new AssertionError("Connector forwarded " + forwarded + " instead of " + MULTIBYTE); + } + System.out.println("[smoke-263] RawOutputTtyConnector decoded and forwarded " + + decoded.length() + " chars on the 2026.3 jediterm and pty4j binaries"); + } + + private static byte[] readClass(Path jarPath, String entryName) throws Exception { + try (JarFile jar = new JarFile(jarPath.toFile())) { + JarEntry entry = jar.getJarEntry(entryName); + if (entry == null) { + throw new AssertionError(entryName + " not found in " + jarPath); + } + return jar.getInputStream(entry).readAllBytes(); + } + } + + public static class FakePtyProcess extends com.pty4j.PtyProcess { + private final java.io.InputStream input; + private final java.io.OutputStream output; + private boolean destroyed; + + FakePtyProcess(java.io.InputStream input, java.io.OutputStream output) { + this.input = input; + this.output = output; + } + + @Override public java.io.InputStream getInputStream() { return input; } + @Override public java.io.InputStream getErrorStream() { return new java.io.ByteArrayInputStream(new byte[0]); } + @Override public java.io.OutputStream getOutputStream() { return output; } + @Override public boolean isAlive() { return !destroyed; } + @Override public long pid() { return 1; } + @Override public int exitValue() { return 0; } + @Override public int waitFor() { return 0; } + @Override public boolean waitFor(long timeout, TimeUnit unit) { return true; } + @Override public void destroy() { destroyed = true; } + @Override public Process destroyForcibly() { return this; } + @Override public ProcessHandle.Info info() { return ProcessHandle.current().info(); } + @Override public java.util.stream.Stream children() { return ProcessHandle.current().children(); } + @Override public java.util.stream.Stream descendants() { return ProcessHandle.current().descendants(); } + @Override public ProcessHandle toHandle() { return ProcessHandle.current(); } + @Override public java.util.concurrent.CompletableFuture onExit() { + return java.util.concurrent.CompletableFuture.completedFuture(this); + } + @Override public void setWinSize(com.pty4j.WinSize winSize) { } + @Override public com.pty4j.WinSize getWinSize() { return new com.pty4j.WinSize(80, 24); } + @Override public boolean isConsoleMode() { return false; } + } + + /** + * Minimal class-file reader for the class and method access flags, the + * superclass, method descriptors and member references. + */ + static final class ClassFile { + int access; + String className; + String superName; + List methods = new ArrayList<>(); + List memberRefs = new ArrayList<>(); + + String[] findMethod(String name, String descriptor) { + for (String[] method : methods) { + if (method[0].equals(name) && method[1].equals(descriptor)) { + return method; + } + } + return null; + } + + static ClassFile parse(byte[] d) { + Cur c = new Cur(d); + c.p = 8; + int count = c.u2(); + String[] utf8 = new String[count]; + int[] classNameIndex = new int[count]; + int[] memberClassIndex = new int[count]; + int[] memberNameAndTypeIndex = new int[count]; + int[] nameAndTypeNameIndex = new int[count]; + int[] nameAndTypeDescriptorIndex = new int[count]; + + int slot = 1; + while (slot < count) { + int tag = c.u1(); + switch (tag) { + case 1 -> { + int length = c.u2(); + utf8[slot] = new String(d, c.p, length, StandardCharsets.UTF_8); + c.p += length; + } + case 3, 4 -> c.p += 4; + case 5, 6 -> { + c.p += 8; + slot++; + } + case 7 -> classNameIndex[slot] = c.u2(); + case 8, 16, 19, 20 -> c.p += 2; + case 9 -> c.p += 4; + case 10, 11 -> { + memberClassIndex[slot] = c.u2(); + memberNameAndTypeIndex[slot] = c.u2(); + } + case 12 -> { + nameAndTypeNameIndex[slot] = c.u2(); + nameAndTypeDescriptorIndex[slot] = c.u2(); + } + case 15 -> c.p += 3; + case 17, 18 -> c.p += 4; + default -> throw new AssertionError("Unknown constant pool tag " + tag + " at byte " + (c.p - 1)); + } + slot++; + } + + ClassFile result = new ClassFile(); + result.access = c.u2(); + int thisIndex = c.u2(); + int superIndex = c.u2(); + result.className = utf8[classNameIndex[thisIndex]]; + result.superName = superIndex == 0 ? null : utf8[classNameIndex[superIndex]]; + + int interfaceCount = c.u2(); + c.p += 2 * interfaceCount; + + int fieldCount = c.u2(); + for (int i = 0; i < fieldCount; i++) { + c.p += 6; + skipAttributes(c); + } + + int methodCount = c.u2(); + for (int i = 0; i < methodCount; i++) { + int methodAccess = c.u2(); + int nameIndex = c.u2(); + int descriptorIndex = c.u2(); + result.methods.add(new String[]{ + utf8[nameIndex], utf8[descriptorIndex], Integer.toString(methodAccess)}); + skipAttributes(c); + } + + for (int entry = 1; entry < count; entry++) { + int classSlot = memberClassIndex[entry]; + int nameAndTypeSlot = memberNameAndTypeIndex[entry]; + if (classSlot == 0 || nameAndTypeSlot == 0) { + continue; + } + String owner = utf8[classNameIndex[classSlot]]; + String name = utf8[nameAndTypeNameIndex[nameAndTypeSlot]]; + String descriptor = utf8[nameAndTypeDescriptorIndex[nameAndTypeSlot]]; + result.memberRefs.add(owner + "#" + name + "#" + descriptor); + } + return result; + } + + private static void skipAttributes(Cur c) { + int n = c.u2(); + for (int i = 0; i < n; i++) { + c.u2(); + int length = (c.u2() << 16) | c.u2(); + c.p += length; + } + } + } + + static final class Cur { + final byte[] d; + int p; + + Cur(byte[] d) { + this.d = d; + } + + int u1() { + return d[p++] & 0xFF; + } + + int u2() { + int v = ((d[p] & 0xFF) << 8) | (d[p + 1] & 0xFF); + p += 2; + return v; + } + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ProxyPtyProcess.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ProxyPtyProcess.kt deleted file mode 100644 index 31f1c00..0000000 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ProxyPtyProcess.kt +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Weibo, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - -package org.zoocode.jetbrains.terminal - -import com.pty4j.PtyProcess -import com.intellij.openapi.diagnostic.Logger -import com.pty4j.WinSize - -/** - * ProxyPtyProcess callback interface - * Simplified version, only provides raw data callback - */ -interface ProxyPtyProcessCallback { - /** - * Raw data callback - * @param data Raw string data - * @param streamType Stream type (STDOUT/STDERR) - */ - fun onRawData(data: String, streamType: String) -} - -/** - * ProxyPtyProcess implementation - * Intercepts input/output stream operations and provides raw data callback - */ -class ProxyPtyProcess( - private val originalProcess: PtyProcess, - private val callback: ProxyPtyProcessCallback? = null -) : PtyProcess() { - - // Create proxy input stream (process standard output) - private val proxyInputStream: ProxyInputStream = ProxyInputStream( - originalProcess.inputStream, - "STDOUT", - callback - ) - - // Create proxy error stream (process error output) - private val proxyErrorStream: ProxyInputStream = ProxyInputStream( - originalProcess.errorStream, - "STDERR", - callback - ) - - // Override methods that require special handling - override fun getInputStream(): java.io.InputStream = proxyInputStream - override fun getErrorStream(): java.io.InputStream = proxyErrorStream - override fun getOutputStream(): java.io.OutputStream = originalProcess.outputStream - - // Delegate all other methods to the original process - override fun isAlive(): Boolean = originalProcess.isAlive() - override fun pid(): Long = originalProcess.pid() - override fun exitValue(): Int = originalProcess.exitValue() - override fun waitFor(): Int = originalProcess.waitFor() - override fun waitFor(timeout: Long, unit: java.util.concurrent.TimeUnit): Boolean = - originalProcess.waitFor(timeout, unit) - override fun destroy() = originalProcess.destroy() - override fun destroyForcibly(): Process = originalProcess.destroyForcibly() - override fun info(): ProcessHandle.Info = originalProcess.info() - override fun children(): java.util.stream.Stream = originalProcess.children() - override fun descendants(): java.util.stream.Stream = originalProcess.descendants() - override fun setWinSize(winSize: WinSize) = originalProcess.setWinSize(winSize) - override fun toHandle(): ProcessHandle = originalProcess.toHandle() - override fun onExit(): java.util.concurrent.CompletableFuture = originalProcess.onExit() - - // PtyProcess specific methods - override fun getWinSize(): WinSize = originalProcess.winSize - override fun isConsoleMode(): Boolean = originalProcess.isConsoleMode -} - -/** - * Proxy InputStream implementation - * Intercepts read operations and provides raw data callback - */ -class ProxyInputStream( - private val originalStream: java.io.InputStream, - private val streamType: String, - private val callback: ProxyPtyProcessCallback? -) : java.io.InputStream() { - - override fun read(): Int { - val result = originalStream.read() - if (result != -1 && callback != null) { - // Convert single byte to string and callback - val dataString = String(byteArrayOf(result.toByte()), Charsets.UTF_8) - callback.onRawData(dataString, streamType) - } - return result - } - - override fun read(b: ByteArray): Int { - val result = originalStream.read(b) - if (result > 0 && callback != null) { - // Convert to string and callback - val dataString = String(b, 0, result, Charsets.UTF_8) - callback.onRawData(dataString, streamType) - } - return result - } - - override fun read(b: ByteArray, off: Int, len: Int): Int { - val result = originalStream.read(b, off, len) - if (result > 0 && callback != null) { - // Convert to string and callback - val dataString = String(b, off, result, Charsets.UTF_8) - callback.onRawData(dataString, streamType) - } - return result - } - - // Delegate other methods to the original stream - override fun available(): Int = originalStream.available() - override fun close() = originalStream.close() - override fun mark(readlimit: Int) = originalStream.mark(readlimit) - override fun reset() = originalStream.reset() - override fun markSupported(): Boolean = originalStream.markSupported() -} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnector.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnector.kt new file mode 100644 index 0000000..68cd8c4 --- /dev/null +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnector.kt @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.terminal + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.util.concurrency.AppExecutorUtil +import com.jediterm.core.util.TermSize +import com.jediterm.terminal.ProcessTtyConnector +import com.pty4j.PtyProcess +import com.pty4j.WinSize +import com.pty4j.unix.UnixPtyProcess +import com.pty4j.windows.conpty.WinConPtyProcess +import java.io.IOException +import java.nio.charset.Charset +import java.util.concurrent.TimeUnit + +/** + * Callback that receives terminal output as the terminal emulator reads it from the PTY. + */ +fun interface TerminalRawDataCallback { + fun onRawData(data: String) +} + +/** + * TtyConnector over a PtyProcess that forwards every chunk of decoded output to + * [callback] exactly once while returning it to the terminal emulator. + * + * The class extends jediterm ProcessTtyConnector. Its + * (java.lang.Process, java.nio.charset.Charset) constructor has the exact same + * descriptor in IntelliJ Platform builds 233 (lib/lib-client.jar) and 263 + * (lib/intellij.libraries.jediterm.core.jar), so one compiled super call + * resolves on both. ready, isConnected, waitFor, both write overloads, and + * getProcess are inherited unchanged. The platform + * com.intellij.terminal.pty.PtyProcessTtyConnector cannot be the superclass: + * on 233 its constructor takes com.pty4j.PtyProcess and on 2026.3 it takes + * java.lang.Process, so no single compiled super call resolves on both. The + * session teardown handles any TtyConnector: it only asks + * LocalTerminalTtyConnector for closeSafely and falls back to plain close + * otherwise (TerminalUtilKt.closeAndWaitFor). + * + * Overrides keep the behavior the platform connectors provide: + * - read forwards each decoded chunk to [callback] before returning it. + * - resize skips a dead process, matching PtyProcessTtyConnector on both builds. + * - close sends SIGHUP first on Unix and destroys after one second; on Windows + * ConPTY it writes the ETX interrupt byte to the process stdin before + * destroy; every other process is destroyed immediately. super.close is not + * called: it destroys immediately, which would remove the one-second SIGHUP + * grace window. It closes the same process streams that the inherited reader + * wraps. + */ +class RawOutputTtyConnector( + process: PtyProcess, + charset: Charset, + private val callback: TerminalRawDataCallback?, +) : ProcessTtyConnector(process, charset) { + + private val logger = Logger.getInstance(RawOutputTtyConnector::class.java) + + override fun getName(): String = "Local" + + override fun read(buf: CharArray, offset: Int, length: Int): Int { + val count = super.read(buf, offset, length) + if (count > 0 && callback != null) { + callback.onRawData(String(buf, offset, count)) + } + return count + } + + override fun resize(termSize: TermSize) { + if (isConnected) { + (process as PtyProcess).winSize = WinSize(termSize.columns, termSize.rows) + } + } + + override fun close() { + closeProcess() + try { + process.inputStream.close() + } catch (ignored: IOException) { + } + } + + /** + * Mirrors the close behavior of the JetBrains local terminal connector: + * Unix processes receive SIGHUP first and get destroyed one second later if + * they survived it; Windows ConPTY processes receive the ETX interrupt byte + * on stdin before destroy; every other process is destroyed immediately. + */ + private fun closeProcess() { + val proc = process + if (proc is UnixPtyProcess) { + proc.hangup() + AppExecutorUtil.getAppScheduledExecutorService().schedule({ + if (proc.isAlive()) { + logger.info("Terminal process survived SIGHUP, performing default termination") + proc.destroy() + } + }, 1, TimeUnit.SECONDS) + } else if (proc is WinConPtyProcess) { + sendInterrupt(proc) + proc.destroy() + } else { + proc.destroy() + } + } + + /** + * Matches LocalTerminalTtyConnector.sendInterruptToWinConPtyProcess: the + * interrupt byte reaches the console only while the process is alive, and a + * closed stream must not fail the close path. + */ + private fun sendInterrupt(ptyProcess: Process) { + if (!ptyProcess.isAlive) return + try { + ptyProcess.outputStream.write(0x03) + ptyProcess.outputStream.flush() + } catch (ignored: IOException) { + logger.info("Failed to send the interrupt byte to the Windows ConPTY process during close") + } + } +} diff --git a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt index fd663a4..8ff20ad 100644 --- a/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt +++ b/jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/TerminalInstance.kt @@ -4,6 +4,8 @@ package org.zoocode.jetbrains.terminal +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.LocalPtyOptions import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger @@ -13,7 +15,9 @@ import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowManager import com.intellij.terminal.JBTerminalWidget import com.intellij.terminal.ui.TerminalWidget +import com.jediterm.terminal.TtyConnector import com.pty4j.PtyProcess +import com.pty4j.PtyProcessBuilder import org.zoocode.jetbrains.core.ServiceProxyRegistry import org.zoocode.jetbrains.ipc.proxy.IRPCProtocol import org.zoocode.jetbrains.ipc.proxy.interfaces.ExtHostTerminalShellIntegrationProxy @@ -25,6 +29,8 @@ import kotlinx.coroutines.cancel import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner import org.jetbrains.plugins.terminal.ShellStartupOptions import org.jetbrains.plugins.terminal.ShellTerminalWidget +import java.io.IOException +import java.nio.charset.StandardCharsets /** * Terminal instance class @@ -150,7 +156,7 @@ class TerminalInstance( // 🎯 Add terminalWidget to Terminal tool window addToTerminalToolWindow() - + notifyTerminalOpened() notifyShellIntegrationChange() handleInitialText() @@ -195,18 +201,51 @@ class TerminalInstance( } /** - * Create custom runner + * Create custom runner. + * + * IntelliJ 2023.3 runs the session through [createProcess] returning PtyProcess + * and then through [createTtyConnector] with a PtyProcess argument. On 2026.3 + * (build 263.5701.42) that createProcess signature is gone: the method now returns + * java.lang.Process, and the session enters through createTtyConnector with a + * ShellStartupOptions argument. The second method is declared without `override` + * because it does not exist in 2023.3; at runtime its exact descriptor overrides + * the 2026.3 method and shadows the LocalTerminalDirectRunner implementation. No + * method calls super.createProcess or super.createTtyConnector, because the 2023.3 + * descriptors do not resolve on 2026.3. */ private fun createCustomRunner(): LocalTerminalDirectRunner { return object : LocalTerminalDirectRunner(project) { override fun createProcess(options: ShellStartupOptions): PtyProcess { logger.info("🔧 Custom createProcess method called...") - logger.info("Startup options: $options") - val originalProcess = super.createProcess(options) - logger.info("✅ Original Process created: ${originalProcess.javaClass.name}") + return startTerminalProcess(options) + } - return createProxyPtyProcess(originalProcess) + override fun createTtyConnector(process: PtyProcess): TtyConnector { + logger.info("🔧 Custom createTtyConnector(PtyProcess) method called...") + return createRawOutputConnector(process) + } + + /** + * The 2026.3 session entry point. On build 263 the platform + * dispatches createTtyConnector with ShellStartupOptions, because + * createProcess no longer exists there with the 2023.3 signature. + * The method is compiled without `override`, because 2023.3 + * declares no such method; at runtime the identical descriptor + * overrides the platform method. + * + * Support boundary: this bridge is local-only. It starts a + * PtyProcess on the local machine. WSL-internal terminals, + * dev-container terminals, and RemDev sessions are not supported. + * The 2026.3 WSL-internal and dev-container sessions carry + * non-local EEL descriptors, and this bridge implements none. The + * RemDev frontend receives the inherited platform guard and never + * reaches this runner. No EEL support is claimed. + */ + fun createTtyConnector(startupOptions: ShellStartupOptions): TtyConnector { + logger.info("🔧 Custom createTtyConnector(ShellStartupOptions) method called...") + val process = startTerminalProcess(startupOptions) + return createRawOutputConnector(process) } override fun createShellTerminalWidget( @@ -224,6 +263,66 @@ class TerminalInstance( } } + /** + * Start the PTY process for the already configured startup options. + * + * Replicates LocalTerminalDirectRunner.createProcess so that no packaged class + * references that method, which IntelliJ 2026.3 removed. + */ + private fun startTerminalProcess(options: ShellStartupOptions): PtyProcess { + val command = options.shellCommand + ?: throw ExecutionException("Shell command must not be null") + val workingDirectory = options.workingDirectory + ?: throw ExecutionException("Working directory must not be null") + val initialTermSize = options.initialTermSize + + logger.info("🔧 Starting PTY process: command=$command, cwd=$workingDirectory") + + try { + val builder = PtyProcessBuilder(command.toTypedArray()) + .setEnvironment(options.envVariables) + .setDirectory(workingDirectory) + .setInitialColumns(initialTermSize?.columns) + .setInitialRows(initialTermSize?.rows) + .setUseWinConPty(LocalPtyOptions.shouldUseWinConPty()) + val process = builder.start() + logger.info("✅ PTY process created: ${process.javaClass.name}") + return process + } catch (e: IOException) { + throw ExecutionException("Failed to start terminal process in $workingDirectory", e) + } + } + + /** + * Create TtyConnector that forwards process output to ExtHost. + * + * UTF-8 matches the platform connector on every supported build: + * LocalTerminalDirectRunner assigns myDefaultCharset = StandardCharsets.UTF_8 + * and passes it to PtyProcessTtyConnector (intellij-community branch 233) and + * to LocalTerminalTtyConnector (2026.3), so the raw bytes decode the same way + * the stock terminal would. + */ + private fun createRawOutputConnector(process: PtyProcess): TtyConnector { + logger.info("🔧 Creating TtyConnector with raw output forwarding...") + return RawOutputTtyConnector(process, StandardCharsets.UTF_8, createRawDataCallback()) + } + + /** + * Create raw data callback handler + */ + private fun createRawDataCallback(): TerminalRawDataCallback { + return TerminalRawDataCallback { data -> + logger.debug("📥 Raw data: ${data.length} chars") + + try { + sendRawDataToExtHost(data) + terminalShellIntegration.appendRawOutput(data) + } catch (e: Exception) { + logger.error("❌ Failed to process raw data (terminal: $extHostTerminalId)", e) + } + } + } + /** * Create startup options */ @@ -278,34 +377,6 @@ class TerminalInstance( } } - /** - * Create proxy PtyProcess to intercept input/output streams - */ - private fun createProxyPtyProcess(originalProcess: PtyProcess): PtyProcess { - logger.info("🔧 Creating proxy PtyProcess to intercept input/output streams...") - - val rawDataCallback = createRawDataCallback() - return ProxyPtyProcess(originalProcess, rawDataCallback) - } - - /** - * Create raw data callback handler - */ - private fun createRawDataCallback(): ProxyPtyProcessCallback { - return object : ProxyPtyProcessCallback { - override fun onRawData(data: String, streamType: String) { - logger.debug("📥 Raw data [$streamType]: ${data.length} chars") - - try { - sendRawDataToExtHost(data) - terminalShellIntegration.appendRawOutput(data) - } catch (e: Exception) { - logger.error("❌ Failed to process raw data (terminal: $extHostTerminalId)", e) - } - } - } - } - /** * Send raw data to ExtHost */ @@ -383,16 +454,16 @@ class TerminalInstance( try { val terminalToolWindowManager = org.jetbrains.plugins.terminal.TerminalToolWindowManager.getInstance(project) val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(TERMINAL_TOOL_WINDOW_ID) - + if (toolWindow == null) { logger.warn("Terminal tool window does not exist") return } - + // Use TerminalToolWindowManager's newTab method to create new Content val content = terminalToolWindowManager.newTab(toolWindow, terminalWidget!!) content.displayName = config.name ?: DEFAULT_TERMINAL_NAME - + logger.info("✅ Added terminalWidget to Terminal tool window: ${content.displayName}") } catch (e: Exception) { logger.error("❌ Failed to add terminalWidget to tool window", e) @@ -544,7 +615,7 @@ class TerminalInstance( try { // 🎯 Mark as disposed first to avoid repeated calls in callbacks state.markDisposed() - + callbackManager.clear() scope.cancel() @@ -686,4 +757,4 @@ private class TerminalCallbackManager { fun clear() { terminalCloseCallbacks.clear() } -} \ No newline at end of file +} diff --git a/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/extensions/ExtensionDecouplingTest.kt b/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/extensions/ExtensionDecouplingTest.kt index a4f8788..98acb93 100644 --- a/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/extensions/ExtensionDecouplingTest.kt +++ b/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/extensions/ExtensionDecouplingTest.kt @@ -7,7 +7,6 @@ package org.zoocode.jetbrains.extensions import com.intellij.testFramework.fixtures.BasePlatformTestCase import org.zoocode.jetbrains.extensions.core.ExtensionManager import org.zoocode.jetbrains.extensions.plugin.zoo.ZooExtensionProvider -import org.zoocode.jetbrains.extensions.plugin.kilo.KiloCodeExtensionProvider /** * Test class for extension decoupling functionality @@ -80,7 +79,6 @@ class ExtensionDecouplingTest : BasePlatformTestCase() { // Test that all providers implement the interface correctly val providers = listOf( ZooExtensionProvider(), - KiloCodeExtensionProvider(), ) providers.forEach { provider -> diff --git a/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnectorTest.kt b/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnectorTest.kt new file mode 100644 index 0000000..691fdd9 --- /dev/null +++ b/jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnectorTest.kt @@ -0,0 +1,479 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package org.zoocode.jetbrains.terminal + +import com.jediterm.core.util.TermSize +import com.jediterm.terminal.ProcessTtyConnector +import com.pty4j.PtyProcess +import com.pty4j.WinSize +import com.pty4j.unix.UnixPtyProcess +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class RawOutputTtyConnectorTest { + + private class StubPtyProcess( + private val input: InputStream, + private val output: OutputStream, + ) : PtyProcess() { + var destroyed = false + private set + + var lastWinSize: WinSize? = null + private set + + override fun getInputStream(): InputStream = input + + override fun getErrorStream(): InputStream = ByteArrayInputStream(ByteArray(0)) + + override fun getOutputStream(): OutputStream = output + + override fun isAlive(): Boolean = !destroyed + + override fun pid(): Long = 1 + + override fun exitValue(): Int = 0 + + override fun waitFor(): Int = 0 + + override fun waitFor(timeout: Long, unit: TimeUnit): Boolean = true + + override fun destroy() { + destroyed = true + } + + override fun destroyForcibly(): Process = this + + override fun info(): ProcessHandle.Info = ProcessHandle.current().info() + + override fun children(): java.util.stream.Stream = ProcessHandle.current().children() + + override fun descendants(): java.util.stream.Stream = ProcessHandle.current().descendants() + + override fun toHandle(): ProcessHandle = ProcessHandle.current() + + override fun onExit(): CompletableFuture = CompletableFuture.completedFuture(this) + + override fun setWinSize(winSize: WinSize) { + lastWinSize = winSize + } + + override fun getWinSize(): WinSize = WinSize(80, 24) + + override fun isConsoleMode(): Boolean = false + } + + /** + * Records every flush so write() can be checked against the platform + * connectors, which flush after each write. + */ + private class FlushRecordingOutputStream : OutputStream() { + var flushed = 0 + private set + + val written = ByteArrayOutputStream() + + override fun write(b: Int) = written.write(b) + + override fun write(b: ByteArray, off: Int, len: Int) = written.write(b, off, len) + + override fun flush() { + flushed++ + } + } + + /** + * Delivers the payload byte by byte, so a 2-, 3- or 4-byte UTF-8 sequence is + * always cut at a byte boundary inside the decoder. + */ + private class OneBytePerReadInputStream(private val data: ByteArray) : InputStream() { + private var position = 0 + + override fun read(): Int = if (position < data.size) data[position++].toInt() and 0xFF else -1 + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (position >= data.size) return -1 + b[off] = data[position++] + return 1 + } + } + + /** + * Delivers the payload in fixed-size chunks that do not align with UTF-8 + * sequence lengths. + */ + private class FixedChunkInputStream( + private val data: ByteArray, + private val chunkSize: Int, + ) : InputStream() { + private var position = 0 + + override fun read(): Int = if (position < data.size) data[position++].toInt() and 0xFF else -1 + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (position >= data.size) return -1 + val count = minOf(len, chunkSize, data.size - position) + System.arraycopy(data, position, b, off, count) + position += count + return count + } + } + + private fun newConnector( + data: ByteArray, + callback: TerminalRawDataCallback? = TerminalRawDataCallback { }, + ): Pair { + val process = StubPtyProcess( + ByteArrayInputStream(data), + ByteArrayOutputStream(), + ) + return RawOutputTtyConnector(process, StandardCharsets.UTF_8, callback) to process + } + + private fun readAll(connector: RawOutputTtyConnector): String { + val decoded = StringBuilder() + val buf = CharArray(4) + while (true) { + val count = connector.read(buf, 0, buf.size) + if (count < 0) break + decoded.append(buf, 0, count) + } + return decoded.toString() + } + + @Test + fun readForwardsExactlyTheReturnedChunkToTheCallback() { + val received = mutableListOf() + val (connector, _) = newConnector( + "hello world".toByteArray(StandardCharsets.UTF_8), + callback = TerminalRawDataCallback { received.add(it) }, + ) + + val buf = CharArray(16) + val count = connector.read(buf, 0, buf.size) + + assertEquals(11, count) + assertEquals(1, received.size) + assertEquals("hello world", received[0]) + assertEquals("hello world", String(buf, 0, count)) + } + + @Test + fun readRespectsOffsetAndDoesNotDuplicateData() { + val received = mutableListOf() + val (connector, _) = newConnector( + "chunk".toByteArray(StandardCharsets.UTF_8), + callback = TerminalRawDataCallback { received.add(it) }, + ) + + val buf = CharArray(16) + java.util.Arrays.fill(buf, 'x') + val count = connector.read(buf, 3, 10) + + assertEquals(5, count) + assertEquals(1, received.size) + assertEquals("chunk", received[0]) + assertEquals("chunk", String(buf, 3, count)) + assertEquals("xxx", String(buf, 0, 3)) + assertEquals('x', buf[8]) + } + + @Test + fun readAtEndOfStreamSendsNothingAndReturnsMinusOne() { + val received = mutableListOf() + val (connector, _) = newConnector( + ByteArray(0), + callback = TerminalRawDataCallback { received.add(it) }, + ) + + val buf = CharArray(8) + val count = connector.read(buf, 0, buf.size) + + assertEquals(-1, count) + assertTrue(received.isEmpty()) + } + + @Test + fun zeroLengthReadReturnsZeroAndCallsNoCallback() { + val received = mutableListOf() + val (connector, _) = newConnector( + "data".toByteArray(StandardCharsets.UTF_8), + callback = TerminalRawDataCallback { received.add(it) }, + ) + + assertEquals(0, connector.read(CharArray(8), 0, 0)) + assertTrue("zero-length read must not feed the callback", received.isEmpty()) + } + + @Test + fun callbackFailurePropagatesAndDoesNotDuplicateTheFailedChunk() { + val received = mutableListOf() + var failOnSecondChunk = false + val (connector, _) = newConnector( + "hello world".toByteArray(StandardCharsets.UTF_8), + callback = TerminalRawDataCallback { + if (received.size == 1 && failOnSecondChunk) error("callback boom") + received.add(it) + }, + ) + + val buf = CharArray(4) + assertEquals(4, connector.read(buf, 0, buf.size)) + assertEquals(listOf("hell"), received) + + failOnSecondChunk = true + try { + connector.read(buf, 0, buf.size) + throw AssertionError("callback failure must propagate to the reader") + } catch (expected: IllegalStateException) { + assertEquals("callback boom", expected.message) + } + failOnSecondChunk = false + + val count = connector.read(buf, 0, buf.size) + assertEquals(3, count) + assertEquals( + "the failed chunk is dropped, every delivered chunk is forwarded exactly once", + listOf("hell", "rld"), + received, + ) + } + + @Test + fun readDecodesMultibyteSequencesCutAtEveryByteBoundary() { + val received = mutableListOf() + val text = "naïve — 你好, τξζ, 🚀 done" + val process = StubPtyProcess( + OneBytePerReadInputStream(text.toByteArray(StandardCharsets.UTF_8)), + ByteArrayOutputStream(), + ) + val connector = RawOutputTtyConnector( + process, + StandardCharsets.UTF_8, + TerminalRawDataCallback { received.add(it) }, + ) + + val decoded = readAll(connector) + + assertEquals(text, decoded) + assertEquals(text, received.joinToString("")) + assertFalse( + "decoder must not emit replacement characters (U+FFFD)", + received.any { it.contains("\uFFFD") }, + ) + } + + @Test + fun readDecodesMultibyteSequencesWhenChunksDoNotAlignWithSequences() { + val received = mutableListOf() + val text = "naïve — 你好, τξζ, 🚀 done" + val bytes = text.toByteArray(StandardCharsets.UTF_8) + for (chunkSize in intArrayOf(2, 3, 5, 7)) { + received.clear() + val process = StubPtyProcess( + FixedChunkInputStream(bytes, chunkSize), + ByteArrayOutputStream(), + ) + val connector = RawOutputTtyConnector( + process, + StandardCharsets.UTF_8, + TerminalRawDataCallback { received.add(it) }, + ) + + val decoded = readAll(connector) + + assertEquals("chunk size $chunkSize", text, decoded) + assertEquals("chunk size $chunkSize", text, received.joinToString("")) + assertFalse( + "chunk size $chunkSize must not produce replacement characters (U+FFFD)", + received.any { it.contains("\uFFFD") }, + ) + } + } + + @Test + fun writeEncodesMultibyteTextWithTheConnectorCharset() { + val processOutput = ByteArrayOutputStream() + val process = StubPtyProcess( + ByteArrayInputStream(ByteArray(0)), + processOutput, + ) + val connector = RawOutputTtyConnector(process, StandardCharsets.UTF_8, null) + val text = "echo 你好 🚀\n" + + connector.write(text) + + assertTrue( + processOutput.toByteArray().contentEquals(text.toByteArray(StandardCharsets.UTF_8)), + ) + } + + @Test + fun writeFlushesTheProcessOutputStream() { + val processOutput = FlushRecordingOutputStream() + val process = StubPtyProcess( + ByteArrayInputStream(ByteArray(0)), + processOutput, + ) + val connector = RawOutputTtyConnector(process, StandardCharsets.UTF_8, null) + + connector.write("echo hi\n".toByteArray(StandardCharsets.UTF_8)) + connector.write("echo ho\n") + + assertEquals("echo hi\necho ho\n", processOutput.written.toString(StandardCharsets.UTF_8.name())) + assertEquals("every write must flush, like the platform connectors", 2, processOutput.flushed) + } + + @Test + fun closeHangsUpARealUnixPtyProcessAndForwardsItsOutput() { + assumeTrue("real PTY test runs on Linux only", System.getProperty("os.name").lowercase().contains("linux")) + + val process = com.pty4j.PtyProcessBuilder(arrayOf("/bin/sh")) + .setRedirectErrorStream(true) + .setEnvironment(mapOf("TERM" to "dumb", "PATH" to "/usr/bin:/bin")) + .start() + assertTrue("expected UnixPtyProcess on Linux", process is UnixPtyProcess) + + val received = StringBuilder() + val sawMarker = CompletableFuture() + val connector = RawOutputTtyConnector(process, StandardCharsets.UTF_8) { data -> + received.append(data) + if ("pty-roundtrip-mark" in received) sawMarker.complete(true) + } + val reader = thread(start = true, isDaemon = true) { + val buf = CharArray(256) + while (!sawMarker.isDone && process.isAlive()) { + val count = connector.read(buf, 0, buf.size) + if (count < 0) break + } + } + + connector.write("echo pty-roundtrip-mark\n") + try { + assertTrue("shell output was not forwarded within timeout", sawMarker.get(30, TimeUnit.SECONDS)) + assertFalse( + "forwarded output must decode cleanly (no U+FFFD)", + received.toString().contains("\uFFFD"), + ) + + val startNs = System.nanoTime() + connector.close() + val done = process.waitFor(15, TimeUnit.SECONDS) + val closeToExitMs = (System.nanoTime() - startNs) / 1_000_000 + assertTrue("close did not terminate the shell within 15s", done) + // The fallback destroy is scheduled one second after close, so only a + // death before it proves the SIGHUP path. dash reports exit code 0 for a + // SIGHUP death, so the exit code cannot discriminate here. + assertTrue( + "the shell died $closeToExitMs ms after close; the delayed destroy fires " + + "at 1000 ms, so a death before it can only come from SIGHUP", + closeToExitMs < 1000, + ) + } finally { + sawMarker.complete(false) + reader.join(1000) + if (process.isAlive()) process.destroyForcibly() + } + } + + @Test + fun writeSendsTextToTheProcessInputStream() { + val processOutput = ByteArrayOutputStream() + val process = StubPtyProcess( + ByteArrayInputStream(ByteArray(0)), + processOutput, + ) + val connector = RawOutputTtyConnector(process, StandardCharsets.UTF_8, null) + + connector.write("echo hi\n") + + assertEquals("echo hi\n", String(processOutput.toByteArray(), StandardCharsets.UTF_8)) + } + + @Test + fun closeDestroysNonUnixProcessLikeOnWindows() { + val (connector, process) = newConnector(ByteArray(0)) + + connector.close() + + assertTrue(process.destroyed) + } + + @Test + fun resizeAppliesTermSizeToTheProcess() { + val (connector, process) = newConnector(ByteArray(0)) + + connector.resize(TermSize(120, 40)) + + assertEquals(120, process.lastWinSize?.columns) + assertEquals(40, process.lastWinSize?.rows) + } + + @Test + fun resizeSkipsDeadProcessLikeThePlatformConnector() { + val (connector, process) = newConnector(ByteArray(0)) + process.destroy() + + connector.resize(TermSize(120, 40)) + + assertNull("resize must not touch a dead process", process.lastWinSize) + } + + @Test + fun nullCallbackStillReturnsData() { + val data = "ok".toByteArray(StandardCharsets.UTF_8) + val (connector, _) = newConnector(data, callback = null) + + val buf = CharArray(8) + val count = connector.read(buf, 0, buf.size) + + assertEquals(2, count) + assertEquals("ok", String(buf, 0, count)) + } + + @Test + fun connectorIsAProcessTtyConnectorOverTheSameProcess() { + val (connector, process) = newConnector(ByteArray(0)) + + assertTrue( + "the connector must extend jediterm ProcessTtyConnector, whose " + + "(Process, Charset) constructor descriptor is identical on 233 and 263", + connector is ProcessTtyConnector, + ) + assertSame( + "getProcess() must expose the exact process the connector was built with", + process, + connector.process, + ) + } + + @Test + fun inheritedLifecycleMethodsFollowTheProcess() { + val (connector, process) = newConnector("in".toByteArray(StandardCharsets.UTF_8)) + + assertEquals("Local", connector.name) + assertTrue(connector.isConnected) + assertTrue(connector.ready()) + assertEquals(0, connector.waitFor()) + assertSame(process, connector.process) + + process.destroy() + + assertFalse("isConnected must track process.isAlive", connector.isConnected) + } +}