diff --git a/config/kotlinc/BUILD.bazel b/config/kotlinc/BUILD.bazel index 171b533..1bc4748 100644 --- a/config/kotlinc/BUILD.bazel +++ b/config/kotlinc/BUILD.bazel @@ -25,3 +25,18 @@ config_setting( name = "incremental_enabled", flag_values = {":incremental": "True"}, ) + +# Opt-in (default off): Karbine ABI compile-avoidance (WHIPLASH#1111, rules +# issue #11). When true, a Kotlin-only target's `JavaInfo.compile_jar` is a +# header jar produced by a dedicated `elide kotlinc --abi-only` action instead +# of the `java_common.run_ijar`-derived jar. A method-*body* edit yields a +# byte-identical header (same digest), so Bazel prunes every dependent rebuild; +# dependents also key on the fast codegen-free header action rather than the +# full compile. `const`/`inline`/signature changes still change the header, so +# pruning stays sound. Scoped to kt-only targets: `elide kotlinc --abi-only` +# emits Kotlin ABI only, so mixed kt+java targets fall back to run_ijar. Verify +# soundness with //e2e/abi_avoidance. Experimental. +bool_flag( + name = "abi_compile_avoidance", + build_setting_default = False, +) diff --git a/e2e/abi_avoidance/README.md b/e2e/abi_avoidance/README.md new file mode 100644 index 0000000..f3c9c3e --- /dev/null +++ b/e2e/abi_avoidance/README.md @@ -0,0 +1,38 @@ +# ABI compile-avoidance soundness harness + +Empirical verification for Karbine ABI compile-avoidance (`elide kotlinc +--abi-only`), the capability behind **rules issue #11** and the +`//config/kotlinc:abi_compile_avoidance` build setting. + +When that setting is on, `elide_kotlin_*` derives a kt-only target's +`JavaInfo.compile_jar` from a dedicated `elide kotlinc --abi-only` **header** +action instead of `java_common.run_ijar`. Bazel keys dependents on that header's +digest, so the behavior is only correct if the header digest is a faithful +function of the *public ABI*. `run.sh` asserts both directions: + +| Edit | Header must | Why | +| --- | --- | --- | +| method **body** | stay identical | callers don't observe it → prune dependents (the win) | +| `const val` value | **change** | constant is inlined into callers → they must rebuild | +| `inline fun` body | **change** | inlined into callers → they must rebuild | +| signature | **change** | ABI change → callers must rebuild | +| default-arg value | stay identical | lives in the `$default` bridge, not callers → body-level | + +It also pins the two `--abi-only` behaviors the rule wiring depends on: it +accepts a `-d ` output, and on **mixed kt+java** it emits Kotlin ABI **only** +(which is why the rule scopes avoidance to kt-only targets). + +## Run + +```sh +ELIDE=/abs/path/to/elide ./run.sh # or: ./run.sh /abs/path/to/elide +``` + +Exits non-zero if any assertion fails. Like `../ic_correctness`, this needs a +real `elide` binary, so it runs locally / on main-push CI rather than on PRs. + +## Upstream + +Mixed kt+java ABI avoidance is blocked on `elide kotlinc --abi-only` emitting +Java ABI too (tracked upstream in WHIPLASH). Until then, mixed targets keep the +`run_ijar` compile jar (correct, just not pruned on Kotlin body edits). diff --git a/e2e/abi_avoidance/run.sh b/e2e/abi_avoidance/run.sh new file mode 100755 index 0000000..cc8567f --- /dev/null +++ b/e2e/abi_avoidance/run.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Soundness harness for Karbine ABI compile-avoidance (`elide kotlinc --abi-only`), +# the capability behind rules issue #11 / //config/kotlinc:abi_compile_avoidance. +# +# The rule uses the `--abi-only` header jar as `JavaInfo.compile_jar`. That is +# only correct if the header's digest is a faithful function of the public ABI: +# +# * a change that callers DON'T observe (a method body) must leave the header +# byte-identical -> Bazel prunes dependents (the win), and +# * a change that callers DO observe (const value, inline body, signature) +# must change the header -> dependents rebuild (sound). +# +# A false "stable" in the second class is a silent-miscompile: a dependent keeps +# stale `const`/`inline` bytes. This harness makes both directions explicit. +# +# It also pins the two implementation-critical `--abi-only` behaviors the rule +# wiring depends on: it accepts a `-d ` output, and on mixed kt+java it +# emits Kotlin ABI ONLY (hence the rule scopes avoidance to kt-only targets; +# mixed-source support is filed upstream). +# +# Usage: ELIDE=/path/to/elide ./run.sh (or: ./run.sh /path/to/elide) +# Exits non-zero if any assertion fails. +set -uo pipefail + +ELIDE="${ELIDE:-${1:-elide}}" +command -v "$ELIDE" >/dev/null 2>&1 || { echo "elide not found (set ELIDE=...): $ELIDE" >&2; exit 2; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/abi-avoid.XXXXXX")"; trap 'rm -rf "$WORK"' EXIT +rc=0 +echo "elide: $("$ELIDE" --version 2>/dev/null | head -1)" + +# abi_digest -> stable digest of the emitted ABI .class bytes. +# Compiles to a dir (class bytes are deterministic; jar wrappers carry mtimes). +abi_digest() { + local src="$1" out; out="$(mktemp -d "$WORK/o.XXXXXX")" + "$ELIDE" kotlinc --abi-only -- -d "$out" -cp . "$src" >/dev/null 2>&1 || { echo "COMPILE-FAIL"; return; } + (cd "$out" && find . -name '*.class' | LC_ALL=C sort | xargs cat 2>/dev/null | sha256sum | cut -d' ' -f1) +} + +# assert_abi +assert_abi() { + local name="$1" orig="$2" edit="$3" want="$4" why="$5" + local f="$WORK/$name.kt" + printf '%s\n' "$orig" > "$f"; local a; a="$(abi_digest "$f")" + printf '%s\n' "$edit" > "$f"; local b; b="$(abi_digest "$f")" + local got; [ "$a" = "$b" ] && got="same" || got="diff" + if [ "$got" = "$want" ]; then + printf ' ok %-22s abi %-4s (%s)\n' "$name" "$got" "$why" + else + printf ' FAIL %-22s abi %-4s, wanted %s (%s)\n' "$name" "$got" "$want" "$why" >&2 + rc=1 + fi +} + +echo "=== prune-soundness: header digest tracks ABI, not bodies ===" +assert_abi body-edit \ + 'package s +class C { fun f(x: Int): Int { return x + 1 } }' \ + 'package s +class C { fun f(x: Int): Int { return x + 99 } }' \ + same "plain body — callers unaffected, must prune" + +assert_abi const-edit \ + 'package s +object C { const val V: Int = 1 }' \ + 'package s +object C { const val V: Int = 2 }' \ + diff "const is inlined into callers — must rebuild" + +assert_abi inline-edit \ + 'package s +object C { inline fun f(x: Int): Int = x + 1 }' \ + 'package s +object C { inline fun f(x: Int): Int = x + 99 }' \ + diff "inline body is inlined into callers — must rebuild" + +assert_abi signature-edit \ + 'package s +class C { fun f(x: Int): Int = x }' \ + 'package s +class C { fun f(x: Long): Long = x }' \ + diff "signature change — must rebuild" + +assert_abi default-arg-edit \ + 'package s +class C { fun g(x: Int = 1): Int = x }' \ + 'package s +class C { fun g(x: Int = 2): Int = x }' \ + same "default value lives in the \$default bridge, not callers — body-level" + +echo "=== implementation-critical --abi-only behaviors the rule relies on ===" +# (1) jar output: the rule passes `-d _abi.jar`. +printf 'package s\nclass J { fun f(): Int = 1 }\n' > "$WORK/J.kt" +"$ELIDE" kotlinc --abi-only -- -d "$WORK/J_abi.jar" -cp . "$WORK/J.kt" >/dev/null 2>&1 +if [ -f "$WORK/J_abi.jar" ] && unzip -l "$WORK/J_abi.jar" 2>/dev/null | grep -q 's/J.class'; then + echo " ok jar-output -d produces a jar of ABI classes" +else + echo " FAIL jar-output -d did not produce a class jar" >&2; rc=1 +fi + +# (2) mixed kt+java: --abi-only emits Kotlin ABI only (no Java class). This is +# WHY the rule scopes avoidance to kt-only; mixed support is upstream. +printf 'package s\nclass K { fun f(): Int = 1 }\n' > "$WORK/K.kt" +printf 'package s;\npublic final class L { public static int g() { return 2; } }\n' > "$WORK/L.java" +mkdir -p "$WORK/mx" +"$ELIDE" kotlinc --abi-only -- -d "$WORK/mx" -cp . "$WORK/K.kt" "$WORK/L.java" >/dev/null 2>&1 +has_k=$([ -f "$WORK/mx/s/K.class" ] && echo 1 || echo 0) +has_l=$([ -f "$WORK/mx/s/L.class" ] && echo 1 || echo 0) +if [ "$has_k" = 1 ] && [ "$has_l" = 0 ]; then + echo " ok mixed-kotlin-only --abi-only emits Kotlin ABI only (Java dropped)" +else + echo " FAIL mixed-kotlin-only expected K.class only (K=$has_k L=$has_l)" >&2; rc=1 +fi + +echo +[ "$rc" -eq 0 ] && echo "ABI avoidance: all assertions passed." || echo "ABI avoidance: FAILURES above." >&2 +exit "$rc" diff --git a/elide/private/compile_common.bzl b/elide/private/compile_common.bzl index c94addf..d9ae88b 100644 --- a/elide/private/compile_common.bzl +++ b/elide/private/compile_common.bzl @@ -258,6 +258,11 @@ def run_kotlinc(ctx, output_jar): ctx: rule context. Must expose srcs/deps/kotlinc_opts/javac_opts/module_name/ plugins/associates. output_jar: File. Declared output JAR. + + Returns: + File or None. The `--abi-only` header jar when ABI compile-avoidance is + enabled for a kt-only target (pass to make_java_info as + compile_jar_override); otherwise None. """ elide = ctx.toolchains[TOOLCHAIN_TYPE].elide_info classpath = compile_classpath(ctx.attr.deps) @@ -279,6 +284,52 @@ def run_kotlinc(ctx, output_jar): # per-target, undeclared cache dir, then pack the dir into `kt_jar` below. incremental = ctx.attr._incremental[BuildSettingInfo].value and has_kt + # Karbine ABI compile-avoidance (opt-in via //config/kotlinc:abi_compile_avoidance, + # rules issue #11). A dedicated codegen-free `elide kotlinc --abi-only` pass + # emits a header jar used as JavaInfo.compile_jar (see make_java_info): a + # body-only edit yields a byte-identical header (same digest) so Bazel prunes + # every dependent rebuild, and dependents key on this fast action rather than + # the full compile. const/inline/signature changes still change the header, + # keeping pruning sound (verified by //e2e/abi_avoidance). `--abi-only` emits + # Kotlin ABI only, so this is scoped to kt-only targets; mixed kt+java keeps + # the run_ijar compile jar. + abi_avoidance = ctx.attr._abi_compile_avoidance[BuildSettingInfo].value and has_kt and not has_java + + abi_jar = None + if abi_avoidance: + abi_jar = ctx.actions.declare_file(ctx.label.name + "_abi.jar") + abi_args = _tool_args(ctx) + abi_builtin_plugins = getattr(ctx.attr, "builtin_plugins", []) + if abi_builtin_plugins: + abi_args.add_joined(abi_builtin_plugins, join_with = ",", format_joined = "--plugins=%s") + abi_args.add("--abi-only") + abi_args.add("--") + abi_args.add("-d", abi_jar) + abi_cp = depset(transitive = [classpath, plugin_cp, friend_jars]) + abi_args.add_joined("-classpath", abi_cp, join_with = sep) + if ctx.attr.module_name: + abi_args.add("-module-name", ctx.attr.module_name) + if friend_path_strs: + abi_args.add("-Xfriend-paths=" + ",".join(friend_path_strs)) + for plugin in ctx.attr.plugins: + for f in plugin[JavaInfo].transitive_runtime_jars.to_list(): + abi_args.add("-Xplugin=" + f.path) + for o in ctx.attr.kotlinc_opts: + abi_args.add(o) + abi_args.add_all(kt_srcs) + _run_elide_compile( + ctx, + mnemonic = "ElideKotlincAbi", + subcommand = "kotlinc", + tool_args = abi_args, + inputs = depset( + direct = kt_srcs, + transitive = [classpath, plugin_cp, friend_jars, elide.compile_tool_files], + ), + outputs = [abi_jar], + progress_message = "Generating %{label} ABI header (elide kotlinc --abi-only)", + ) + kt_jar = None if has_kt: kt_jar = output_jar if single_kt_only else ctx.actions.declare_file(ctx.label.name + "_kotlin_classes.jar") @@ -360,7 +411,7 @@ def run_kotlinc(ctx, output_jar): ) if single_kt_only: - return + return abi_jar class_jars = [] if has_kt: @@ -369,6 +420,7 @@ def run_kotlinc(ctx, output_jar): class_jars.append(_compile_java_aux(ctx, java_srcs, kt_jar)) _merge_resources(ctx, class_jars, output_jar) + return abi_jar def _compile_java_aux(ctx, java_srcs, kt_jar): if _has_exported_processors(ctx.attr.deps): @@ -428,24 +480,32 @@ def _compile_java_processed(ctx, java_srcs, kt_jar): ) return java_jar -def make_java_info(ctx, output_jar, source_jar = None): +def make_java_info(ctx, output_jar, source_jar = None, compile_jar_override = None): """Builds the JavaInfo emitted by elide compile rules. Args: ctx: rule context. output_jar: File. Compiled classes jar. source_jar: File or None. Optional sources jar (sets JavaInfo.source_jar). + compile_jar_override: File or None. When set (the Karbine `--abi-only` + header jar from run_kotlinc, under ABI compile-avoidance), it is used + verbatim as `compile_jar` instead of deriving one with run_ijar. The + header is body-stable, so dependents prune on body-only edits. Returns: - JavaInfo carrying output_jar + ijar-derived compile jar + merged deps. + JavaInfo carrying output_jar + compile jar (ijar-derived, or the + body-stable ABI header when overridden) + merged deps. """ - java_toolchain = ctx.toolchains["@bazel_tools//tools/jdk:toolchain_type"].java - compile_jar = java_common.run_ijar( - actions = ctx.actions, - jar = output_jar, - target_label = ctx.label, - java_toolchain = java_toolchain, - ) + if compile_jar_override != None: + compile_jar = compile_jar_override + else: + java_toolchain = ctx.toolchains["@bazel_tools//tools/jdk:toolchain_type"].java + compile_jar = java_common.run_ijar( + actions = ctx.actions, + jar = output_jar, + target_label = ctx.label, + java_toolchain = java_toolchain, + ) return JavaInfo( output_jar = output_jar, compile_jar = compile_jar, @@ -749,6 +809,13 @@ COMMON_LIBRARY_ATTRS = { doc = "Runtime-only dependencies (excluded from compile classpath).", providers = [[JavaInfo]], ), + "_abi_compile_avoidance": attr.label( + default = Label("//config/kotlinc:abi_compile_avoidance"), + providers = [BuildSettingInfo], + doc = "Build setting: derive a kt-only target's compile_jar from a " + + "dedicated `elide kotlinc --abi-only` header action (body-stable " + + "ABI) instead of run_ijar. See //config/kotlinc:abi_compile_avoidance.", + ), "_classpath_cache": attr.label( default = Label("//config/javac:classpath_cache"), providers = [BuildSettingInfo], diff --git a/elide/rules/kotlin.bzl b/elide/rules/kotlin.bzl index 5935d2b..340fdf7 100644 --- a/elide/rules/kotlin.bzl +++ b/elide/rules/kotlin.bzl @@ -33,10 +33,10 @@ _KOTLIN_TOOLCHAINS = [TOOLCHAIN_TYPE, "@bazel_tools//tools/jdk:toolchain_type"] def _elide_kotlin_library_impl(ctx): output_jar = ctx.actions.declare_file(ctx.label.name + ".jar") - run_kotlinc(ctx, output_jar) + abi_jar = run_kotlinc(ctx, output_jar) source_jar = pack_source_jar(ctx) return [ - make_java_info(ctx, output_jar, source_jar = source_jar), + make_java_info(ctx, output_jar, source_jar = source_jar, compile_jar_override = abi_jar), make_elide_info(ctx), DefaultInfo(files = depset([output_jar])), ] @@ -74,11 +74,11 @@ elide_kotlin_library = rule( def _elide_kotlin_binary_impl(ctx): output_jar = ctx.actions.declare_file(ctx.label.name + ".jar") - run_kotlinc(ctx, output_jar) + abi_jar = run_kotlinc(ctx, output_jar) source_jar = pack_source_jar(ctx) launcher, runfiles = build_launcher(ctx, output_jar) return [ - make_java_info(ctx, output_jar, source_jar = source_jar), + make_java_info(ctx, output_jar, source_jar = source_jar, compile_jar_override = abi_jar), make_elide_info(ctx), DefaultInfo(executable = launcher, runfiles = runfiles, files = depset([launcher])), ] @@ -97,11 +97,11 @@ elide_kotlin_binary = rule( def _elide_kotlin_test_impl(ctx): output_jar = ctx.actions.declare_file(ctx.label.name + ".jar") - run_kotlinc(ctx, output_jar) + abi_jar = run_kotlinc(ctx, output_jar) source_jar = pack_source_jar(ctx) launcher, runfiles = build_test_launcher(ctx, output_jar) return [ - make_java_info(ctx, output_jar, source_jar = source_jar), + make_java_info(ctx, output_jar, source_jar = source_jar, compile_jar_override = abi_jar), make_elide_info(ctx), DefaultInfo(executable = launcher, runfiles = runfiles, files = depset([launcher])), ] diff --git a/tests/kotlin_rule_test.bzl b/tests/kotlin_rule_test.bzl index 394512f..89c7f92 100644 --- a/tests/kotlin_rule_test.bzl +++ b/tests/kotlin_rule_test.bzl @@ -60,6 +60,69 @@ def _library_action_test_impl(ctx): _library_action_test = analysistest.make(_library_action_test_impl) +def _abi_avoidance_test_impl(ctx): + # With //config/kotlinc:abi_compile_avoidance=True, a kt-only target emits a + # dedicated `elide kotlinc --abi-only` header action, and its JavaInfo + # compile_jar is that header jar (not the run_ijar-derived jar) — so a + # body-only edit yields a byte-identical header and dependents prune. + env = analysistest.begin(ctx) + actions = analysistest.target_actions(env) + abis = [a for a in actions if a.mnemonic == "ElideKotlincAbi"] + asserts.equals(env, 1, len(abis), "expected one ElideKotlincAbi (--abi-only) action") + argv = abis[0].argv + asserts.true(env, "--abi-only" in argv, "abi action must pass `--abi-only`") + asserts.true( + env, + argv.index("--abi-only") < argv.index("--"), + "`--abi-only` is an elide option; must precede the `--` separator", + ) + abi_out = abis[0].outputs.to_list() + asserts.true( + env, + len(abi_out) == 1 and abi_out[0].basename.endswith("_abi.jar"), + "abi action must output a single `_abi.jar`", + ) + + # compile_jar is wired to the header jar, not an ijar. + compile_jars = analysistest.target_under_test(env)[JavaInfo].compile_jars.to_list() + asserts.true( + env, + any([j.basename.endswith("_abi.jar") for j in compile_jars]), + "compile_jar must be the `--abi-only` header jar when avoidance is on", + ) + return analysistest.end(env) + +_abi_avoidance_test = analysistest.make( + _abi_avoidance_test_impl, + # See _library_action_no_worker_test re: the @@ canonical root ref. + config_settings = {"@@//config/kotlinc:abi_compile_avoidance": True}, # buildifier: disable=canonical-repository +) + +def _abi_avoidance_mixed_fallback_test_impl(ctx): + # `elide kotlinc --abi-only` emits Kotlin ABI only, so mixed kt+java targets + # must NOT take the abi path (a Kotlin-only header would drop the Java ABI + # and break dependents). They fall back to the run_ijar compile jar. + env = analysistest.begin(ctx) + actions = analysistest.target_actions(env) + asserts.equals( + env, + 0, + len([a for a in actions if a.mnemonic == "ElideKotlincAbi"]), + "mixed kt+java must not emit an --abi-only action (Kotlin-only ABI)", + ) + compile_jars = analysistest.target_under_test(env)[JavaInfo].compile_jars.to_list() + asserts.true( + env, + not any([j.basename.endswith("_abi.jar") for j in compile_jars]), + "mixed target compile_jar must be the run_ijar jar, not an `_abi.jar`", + ) + return analysistest.end(env) + +_abi_avoidance_mixed_fallback_test = analysistest.make( + _abi_avoidance_mixed_fallback_test_impl, + config_settings = {"@@//config/kotlinc:abi_compile_avoidance": True}, # buildifier: disable=canonical-repository +) + def _builtin_plugins_test_impl(ctx): env = analysistest.begin(ctx) actions = analysistest.target_actions(env) @@ -410,6 +473,14 @@ def kotlin_rule_test_suite(name): name = "kt_test_rule_executable_test", target_under_test = ":_kt_test_fixture", ) + _abi_avoidance_test( + name = "kt_abi_avoidance_test", + target_under_test = ":_kt_lib_fixture", + ) + _abi_avoidance_mixed_fallback_test( + name = "kt_abi_avoidance_mixed_fallback_test", + target_under_test = ":_kt_mixed_fixture", + ) native.test_suite( name = name, tests = [ @@ -423,5 +494,7 @@ def kotlin_rule_test_suite(name): ":kt_annotation_processor_test", ":kt_binary_executable_test", ":kt_test_rule_executable_test", + ":kt_abi_avoidance_test", + ":kt_abi_avoidance_mixed_fallback_test", ], )