Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/kotlinc/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
38 changes: 38 additions & 0 deletions e2e/abi_avoidance/README.md
Original file line number Diff line number Diff line change
@@ -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 <jar>` 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).
119 changes: 119 additions & 0 deletions e2e/abi_avoidance/run.sh
Original file line number Diff line number Diff line change
@@ -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 <jar>` 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 <srcfile> -> 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 <name> <orig-src> <edited-src> <same|diff> <why>
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 <name>_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 <jar> produces a jar of ABI classes"
else
echo " FAIL jar-output -d <jar> 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"
87 changes: 77 additions & 10 deletions elide/private/compile_common.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -360,7 +411,7 @@ def run_kotlinc(ctx, output_jar):
)

if single_kt_only:
return
return abi_jar

class_jars = []
if has_kt:
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
12 changes: 6 additions & 6 deletions elide/rules/kotlin.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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])),
]
Expand Down Expand Up @@ -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])),
]
Expand All @@ -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])),
]
Expand Down
Loading