From 6bdafd237b1e19c9f5c1f673ba9a62f789b1d4be Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 2 Oct 2026 00:05:42 +0000 Subject: [PATCH 1/2] fix(jetbrains): support IntelliJ 2026.3 terminal API Replace process-stream interception with a ProcessTtyConnector that supports the legacy and modern terminal startup paths. Preserve terminal output forwarding, command execution, Unix shutdown, and Windows ConPTY interrupt handling. Add connector tests, packaged bytecode checks, exact 2026.3 verifier coverage, and pull-request gates. Document that the compatibility bridge supports local terminals only. --- .github/workflows/pr.yml | 122 +++++ .github/workflows/release.yml | 88 ++++ jetbrains_plugin/build.gradle.kts | 166 ++++++ jetbrains_plugin/gradle.properties | 9 + .../smoke/Smoke263DispatchCheck.java | 426 ++++++++++++++++ .../jetbrains/terminal/ProxyPtyProcess.kt | 119 ----- .../terminal/RawOutputTtyConnector.kt | 123 +++++ .../jetbrains/terminal/TerminalInstance.kt | 149 ++++-- .../extensions/ExtensionDecouplingTest.kt | 2 - .../terminal/RawOutputTtyConnectorTest.kt | 479 ++++++++++++++++++ 10 files changed, 1523 insertions(+), 160 deletions(-) create mode 100644 .github/workflows/pr.yml create mode 100644 jetbrains_plugin/smoke/Smoke263DispatchCheck.java delete mode 100644 jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/ProxyPtyProcess.kt create mode 100644 jetbrains_plugin/src/main/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnector.kt create mode 100644 jetbrains_plugin/src/test/kotlin/org/zoocode/jetbrains/terminal/RawOutputTtyConnectorTest.kt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..b41aebf --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,122 @@ +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}" + 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) + } +} From 167254fa134a1c854d65edfe5c72330a4dbf11c7 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 2 Oct 2026 00:42:16 +0000 Subject: [PATCH 2/2] ci: create build dir before downloading the pinned verification IDE --- .github/workflows/pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b41aebf..08c39f6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,6 +62,9 @@ jobs: 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