You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/04/26 02:10:37 UTC

(skywalking) branch feature/runtime-rule-hot-update updated: runtime-rule: BytecodeClassDefiner contract — bypass Javassist's deprecated reflection-based class definition for the per-file RuleClassLoader path

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feature/runtime-rule-hot-update
in repository https://gitbox.apache.org/repos/asf/skywalking.git


The following commit(s) were added to refs/heads/feature/runtime-rule-hot-update by this push:
     new 0d95d6c82f runtime-rule: BytecodeClassDefiner contract — bypass Javassist's deprecated reflection-based class definition for the per-file RuleClassLoader path
0d95d6c82f is described below

commit 0d95d6c82f4291099ebe25b5c3bebf3f5c7b9081
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Apr 26 10:10:31 2026 +0800

    runtime-rule: BytecodeClassDefiner contract — bypass Javassist's deprecated reflection-based class definition for the per-file RuleClassLoader path
    
    JDK 17+ blocks Javassist's CtClass.toClass(loader, ProtectionDomain), which
    reflects into java.lang.ClassLoader.defineClass via setAccessible — the
    strong-encapsulation rule fires with InaccessibleObjectException. The static
    MAL/LAL boot path is unaffected because it uses the neighbor-class form
    (toClass(MalExpressionPackageHolder.class) / equivalent), which Javassist
    routes through MethodHandles.privateLookupIn — no reflection. The runtime-
    rule per-file RuleClassLoader has no such neighbor at the first toClass
    call, so it falls into the deprecated 2-arg path and fails.
    
    Adds a small core contract — BytecodeClassDefiner — that loaders can implement
    to publish their defineClass(String, byte[]) as a public method. RuleClassLoader
    now implements it (its private defineRuleClass becomes a public override of
    the contract method), and three generators check the loader's interface
    before falling back:
    
      - MeterSystem.createInternal (Metrics subclass for runtime-rule metrics)
      - MALClassGenerator.defineClass (MalExpression + closure companion classes)
      - LALClassGenerator.defineClass (LalExpression + sub-method classes)
    
    The interface lives in server-core under .classloader/ rather than under
    .analysis/meter/ so MAL and LAL can both reference it without dragging the
    meter package into log-analyzer's import graph. Production callers that go
    through the static neighbor-class path are unchanged; legacy 2-arg toClass
    remains as a back-compat fallback for any non-BytecodeClassDefiner loader,
    but no shipped loader uses it today.
    
    Verified locally — runtime-rule /addOrUpdate against a fresh OAP boot
    returns 200 structural_applied (was 500 apply_failed before this commit).
---
 .../analyzer/v2/compiler/LALClassGenerator.java    |  19 +++-
 .../analyzer/v2/compiler/MALClassGenerator.java    |  25 ++++-
 .../server/core/analysis/meter/MeterSystem.java    | 119 +++++++++++++--------
 .../core/classloader/BytecodeClassDefiner.java     |  58 ++++++++++
 .../runtimerule/classloader/RuleClassLoader.java   |  19 ++--
 5 files changed, 184 insertions(+), 56 deletions(-)

diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java
index 3a8ffa3ee7..5cfb17ffe3 100644
--- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java
+++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java
@@ -20,6 +20,7 @@ package org.apache.skywalking.oap.log.analyzer.v2.compiler;
 import java.io.DataOutputStream;
 import java.io.File;
 import java.io.FileOutputStream;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -34,6 +35,7 @@ import javassist.CtNewConstructor;
 import javassist.CtNewMethod;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.skywalking.oap.log.analyzer.v2.compiler.rt.LalExpressionPackageHolder;
+import org.apache.skywalking.oap.server.core.classloader.BytecodeClassDefiner;
 import org.apache.skywalking.oap.log.analyzer.v2.dsl.LalExpression;
 import org.apache.skywalking.oap.server.core.WorkPath;
 import org.apache.skywalking.oap.server.library.util.StringUtil;
@@ -540,12 +542,27 @@ public final class LALClassGenerator {
     /**
      * Loads a generated class through the configured {@link #targetClassLoader} when set
      * (runtime-rule hot-update path: class lands in the per-file {@code RuleClassLoader}),
-     * or falls back to the neighbor-class form when {@code targetClassLoader} is {@code null}
+     * or via the neighbor-class form when {@code targetClassLoader} is {@code null}
      * (startup path: class lands in the OAP app loader alongside
      * {@link LalExpressionPackageHolder}).
+     *
+     * <p>{@link BytecodeClassDefiner} loaders (the runtime-rule {@code RuleClassLoader})
+     * receive the {@code CtClass.toBytecode()} bytes via their public {@code defineClass}
+     * — bypasses Javassist's deprecated {@code toClass(loader, ProtectionDomain)} reflection
+     * path so we don't need {@code --add-opens java.base/java.lang} on the OAP container.
+     * Same shape as {@code MALClassGenerator}; both DSLs share the contract.
      */
     private Class<?> defineClass(final CtClass ctClass) throws javassist.CannotCompileException {
         if (targetClassLoader != null) {
+            if (targetClassLoader instanceof BytecodeClassDefiner) {
+                try {
+                    return ((BytecodeClassDefiner) targetClassLoader)
+                        .defineClass(ctClass.getName(), ctClass.toBytecode());
+                } catch (final IOException e) {
+                    throw new javassist.CannotCompileException(
+                        "failed to serialise " + ctClass.getName() + " bytes", e);
+                }
+            }
             return ctClass.toClass(targetClassLoader, null);
         }
         return ctClass.toClass(LalExpressionPackageHolder.class);
diff --git a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGenerator.java b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGenerator.java
index d5fa037eb9..839733b1e3 100644
--- a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGenerator.java
+++ b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGenerator.java
@@ -18,6 +18,7 @@
 package org.apache.skywalking.oap.meter.analyzer.v2.compiler;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import javassist.ClassPool;
@@ -26,6 +27,7 @@ import javassist.CtNewConstructor;
 import javassist.CtNewMethod;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.skywalking.oap.meter.analyzer.v2.compiler.rt.MalExpressionPackageHolder;
+import org.apache.skywalking.oap.server.core.classloader.BytecodeClassDefiner;
 import org.apache.skywalking.oap.meter.analyzer.v2.dsl.ExpressionMetadata;
 import org.apache.skywalking.oap.meter.analyzer.v2.dsl.MalExpression;
 import org.apache.skywalking.oap.meter.analyzer.v2.dsl.MalFilter;
@@ -191,13 +193,30 @@ public final class MALClassGenerator {
     /**
      * Loads a generated class through the configured {@link #targetClassLoader} when set
      * (runtime-rule hot-update path: class lands in the per-file {@code RuleClassLoader}),
-     * or falls back to the neighbor-class form when {@code targetClassLoader} is {@code null}
+     * or via the neighbor-class form when {@code targetClassLoader} is {@code null}
      * (startup path: class lands in the OAP app loader alongside
-     * {@link MalExpressionPackageHolder}). Both forms use the same underlying
-     * {@code ClassLoader.defineClass} — only the target loader differs.
+     * {@link MalExpressionPackageHolder}).
+     *
+     * <p>When {@code targetClassLoader} implements
+     * {@link org.apache.skywalking.oap.server.core.classloader.BytecodeClassDefiner
+     * BytecodeClassDefiner} (the runtime-rule {@code RuleClassLoader} does), we hand
+     * the loader the {@code CtClass.toBytecode()} bytes and let it invoke its public
+     * {@code defineClass} directly — no Javassist {@code toClass(loader,
+     * ProtectionDomain)} reflection, no {@code --add-opens java.base/java.lang}
+     * requirement on JDK 17+. Otherwise we fall back to the legacy 2-arg toClass for
+     * back-compat, but no shipped loader uses that path today.
      */
     private Class<?> defineClass(final CtClass ctClass) throws javassist.CannotCompileException {
         if (targetClassLoader != null) {
+            if (targetClassLoader instanceof BytecodeClassDefiner) {
+                try {
+                    return ((BytecodeClassDefiner) targetClassLoader)
+                        .defineClass(ctClass.getName(), ctClass.toBytecode());
+                } catch (final IOException e) {
+                    throw new javassist.CannotCompileException(
+                        "failed to serialise " + ctClass.getName() + " bytes", e);
+                }
+            }
             return ctClass.toClass(targetClassLoader, null);
         }
         return ctClass.toClass(MalExpressionPackageHolder.class);
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/meter/MeterSystem.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/meter/MeterSystem.java
index f8dd9722df..4fe03742be 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/meter/MeterSystem.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/meter/MeterSystem.java
@@ -39,6 +39,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.JavaVersion;
 import org.apache.commons.lang3.SystemUtils;
 import org.apache.skywalking.oap.server.core.UnexpectedException;
+import org.apache.skywalking.oap.server.core.classloader.BytecodeClassDefiner;
 import org.apache.skywalking.oap.server.core.analysis.StreamDefinition;
 import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
 import org.apache.skywalking.oap.server.core.analysis.meter.dynamic.MeterClassPackageHolder;
@@ -291,11 +292,26 @@ public class MeterSystem implements Service {
         final Class targetClass;
         try {
             // Explicit targetClassLoader — the generated class goes directly into the
-            // per-file RuleClassLoader, not the neighbor class's loader. Uses the legacy
-            // 2-arg toClass signature which remains supported on Java 9+ (deprecated but
-            // functional); the alternative Lookup-based path would require a neighbor class
-            // loaded by targetClassLoader, which defeats the point.
-            targetClass = metricsClass.toClass(targetClassLoader, null);
+            // per-file RuleClassLoader, not the neighbor class's loader. Two paths:
+            //
+            //   - {@link BytecodeClassDefiner} loaders (the runtime-rule {@code
+            //     RuleClassLoader} is the only known implementor today): hand the loader
+            //     the raw bytecode via its public {@code defineClass(String, byte[])}.
+            //     This sidesteps Javassist's deprecated 2-arg {@code toClass(loader,
+            //     ProtectionDomain)} which reflects into {@code java.lang.ClassLoader.
+            //     defineClass} and requires {@code --add-opens java.base/java.lang} on
+            //     JDK 17+ — a JVM-flag tax we don't want to put on every operator.
+            //
+            //   - Other loaders (legacy callers): keep the 2-arg toClass for back-compat.
+            //     No new constraints on existing static rule paths; they don't use this
+            //     overload anyway, so this branch is effectively dead today and exists as
+            //     a safety net.
+            if (targetClassLoader instanceof BytecodeClassDefiner) {
+                targetClass = ((BytecodeClassDefiner) targetClassLoader)
+                    .defineClass(METER_CLASS_PACKAGE + className, metricsClass.toBytecode());
+            } else {
+                targetClass = metricsClass.toClass(targetClassLoader, null);
+            }
             AcceptableValue prototype = (AcceptableValue) targetClass.newInstance();
             meterPrototypes.put(metricsName, new MeterDefinition(type, prototype, dataType, true));
             MetricsStreamProcessor.getInstance().create(
@@ -314,9 +330,12 @@ public class MeterSystem implements Service {
             if (opt.hasShapeMismatch()) {
                 meterPrototypes.remove(metricsName);
             }
-        } catch (CannotCompileException | IllegalAccessException | InstantiationException | StorageException e) {
+        } catch (CannotCompileException | IllegalAccessException | InstantiationException
+                 | StorageException | IOException e) {
             // Also roll back on exception paths — an unsuccessful create must not leave a
-            // prototype stranded in the registry.
+            // prototype stranded in the registry. {@link IOException} surfaces from
+            // {@code metricsClass.toBytecode()} when serialising the generated bytes; treat
+            // it the same as a Javassist compile failure so the apply rolls back cleanly.
             meterPrototypes.remove(metricsName);
             throw new UnexpectedException(e.getMessage(), e);
         }
@@ -404,41 +423,41 @@ public class MeterSystem implements Service {
     /**
      * Opt-aware {@code removeMetric}. Runtime-rule peer-side callers pass
      * {@link StorageManipulationOpt#localCacheOnly()} so {@code ModelInstaller.dropTable} is
-     * NOT invoked on the shared storage — the cluster main owns that side-effect. The local
-     * state (meterPrototypes, L1/L2 workers, StorageModels registry entry, CtClass detach)
-     * is torn down identically regardless of policy; only the backend-facing listener
-     * chain respects it.
+     * NOT invoked on the shared storage — the cluster main owns that side-effect.
+     *
+     * <p>Order is backend-first / local-state-second so failure is retriable. The earlier
+     * version evicted {@code meterPrototypes} and called the cascade in parallel; if the
+     * cascade threw (BanyanDB {@code dropTable} failure), the local state was already torn
+     * down — there was nothing for the next {@code /inactivate} or reconciler tick to drive
+     * a backend retry against, and the operator could not recover the orphaned measure
+     * without an OAP restart. Now the cascade runs first; on success we drop the local
+     * caches; on failure we leave {@code meterPrototypes} populated and the CtClass attached
+     * so a retry hits the backend again.
      *
      * <p>Failure surface: under {@code fullInstall} the storage-model cascade failure is
-     * propagated as a {@link RuntimeException}, because that is the caller's signal that the
-     * backend drop (BanyanDB delete-measure, ES mapping close, ...) did not fully complete
-     * and an operator-facing "inactivated" response would be incorrect. Under
-     * {@code localCacheOnly} the cascade fires listener {@code whenRemoving} but the peer's
-     * {@code ModelInstaller.dropTable} is suppressed anyway, so any listener-level throw is
-     * logged and swallowed — the peer has no backend debt to surface. Streaming-chain drain
-     * failures are always logged and swallowed: stale workers self-drain within one tick,
-     * and carrying them into the response would be noise, not signal.
+     * propagated as a {@link RuntimeException}. The REST {@code /inactivate} path depends on
+     * this to surface 500 {@code teardown_deferred} when BanyanDB's delete-measure threw —
+     * without it the handler would return 200 inactivated despite the measure still being
+     * live. Under {@code localCacheOnly} the cascade fires {@code whenRemoving} but the
+     * peer's {@code ModelInstaller.dropTable} is suppressed by policy, so any throw is
+     * logged and swallowed — the peer has no backend debt. Streaming-chain drain failures
+     * are always logged and swallowed: stale workers self-drain within one tick.
      */
     public synchronized boolean removeMetric(final String metricsName, final StorageManipulationOpt opt) {
-        final MeterDefinition def = meterPrototypes.remove(metricsName);
+        final MeterDefinition def = meterPrototypes.get(metricsName);
         if (def == null) {
             return false;
         }
         final Class<?> prototypeClass = def.getMeterPrototype().getClass();
 
-        // Drain + deregister worker chain and release the shared queue handlers.
-        try {
-            MetricsStreamProcessor.getInstance().removeMetric(manager, (Class) prototypeClass);
-        } catch (final Throwable t) {
-            log.error("Failed to remove streaming chain for metric {}; storage model cascade "
-                + "will still run.", metricsName, t);
-        }
-
-        // Cascade storage-model removal (Hour / Day / Minute). ModelRegistry.remove fires
-        // whenRemoving on every listener, so each backend's ModelInstaller.dropTable runs
-        // in FULL_INSTALL mode — real delete for BanyanDB, no-op for JDBC / Elasticsearch,
-        // skipped outright when the caller is a peer-side (LOCAL_CACHE_ONLY) apply.
-        Throwable cascadeFailure = null;
+        // Cascade storage-model removal (Hour / Day / Minute) FIRST. ModelRegistry.remove
+        // fires whenRemoving on every listener, so each backend's ModelInstaller.dropTable
+        // runs — real delete for BanyanDB, no-op for JDBC / Elasticsearch, skipped outright
+        // when the caller is a peer-side (LOCAL_CACHE_ONLY) apply. If a listener throws,
+        // ModelRegistry.remove keeps the model in its registry so this retry path stays
+        // open: the caller (Reconciler unregisterBundle) preserves appliedMal[key] and the
+        // next tick (or operator retry) re-enters this method, finds meterPrototypes still
+        // populated, and re-fires the cascade.
         try {
             final ModelRegistry modelCreator = manager.find(CoreModule.NAME)
                                                      .provider()
@@ -446,7 +465,27 @@ public class MeterSystem implements Service {
             modelCreator.remove(prototypeClass, opt);
         } catch (final Throwable t) {
             log.error("Failed to cascade storage-model removal for metric {}", metricsName, t);
-            cascadeFailure = t;
+            if (opt.isFullInstall()) {
+                throw new RuntimeException(
+                    "Storage-model cascade failed for metric " + metricsName
+                        + "; backend drop did not complete. Local state preserved for retry.",
+                    t);
+            }
+            // localCacheOnly: peer-side caller — backend drop is suppressed by policy
+            // anyway, so a listener throw here is the listener's local bookkeeping
+            // misbehaving, not real backend debt. Fall through to clear local state.
+        }
+
+        // Backend cascade succeeded (or local-cache-only, where it doesn't matter). Drop
+        // the prototype and drain the workers. Worker drain failure is non-fatal and
+        // logged: stale workers self-drain within one tick, and the prototype + Model are
+        // already gone so future samples can't reach them.
+        meterPrototypes.remove(metricsName);
+        try {
+            MetricsStreamProcessor.getInstance().removeMetric(manager, (Class) prototypeClass);
+        } catch (final Throwable t) {
+            log.error("Failed to remove streaming chain for metric {}; prototype + storage "
+                + "model already gone.", metricsName, t);
         }
 
         // Detach the CtClass from the default pool so a future shape-breaking re-create passes
@@ -466,18 +505,6 @@ public class MeterSystem implements Service {
                     metricsName);
             }
         }
-        // On fullInstall, propagate a cascade failure after the local teardown has fully run
-        // (meterPrototypes evicted, CtClass detached). The REST /inactivate path depends on
-        // this to surface 500 teardown_deferred when BanyanDB's delete-measure threw — without
-        // it the handler would return 200 inactivated despite the backend measure still being
-        // live, misleading operators into thinking the rule is fully torn down.
-        if (cascadeFailure != null && opt.isFullInstall()) {
-            throw new RuntimeException(
-                "Storage-model cascade failed for metric " + metricsName
-                    + "; backend drop may not have completed. Local state has been torn down; "
-                    + "retry the operator request to re-attempt backend teardown.",
-                cascadeFailure);
-        }
         return true;
     }
 
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/classloader/BytecodeClassDefiner.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/classloader/BytecodeClassDefiner.java
new file mode 100644
index 0000000000..618918f94f
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/classloader/BytecodeClassDefiner.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.classloader;
+
+/**
+ * Marker contract for class loaders that expose a public {@code defineClass} so generated
+ * bytecode can be injected without going through Javassist's deprecated
+ * {@code CtClass.toClass(ClassLoader, ProtectionDomain)} reflection path.
+ *
+ * <p>Background: on JDK 9+ the deprecated {@code toClass(ClassLoader, ProtectionDomain)}
+ * reflectively calls {@code java.lang.ClassLoader.defineClass} via {@code setAccessible},
+ * which the strong-encapsulation rule blocks at runtime with
+ * {@code InaccessibleObjectException} unless the operator explicitly opens
+ * {@code java.base/java.lang} via {@code --add-opens}. Static MAL/LAL boot is unaffected
+ * because {@code MeterClassPackageHolder}'s package access works through Javassist's
+ * neighbor-class API on the default loader. Runtime-rule's per-file loader has no such
+ * neighbor at the first {@code toClass} call; the only pre-loaded classes are inherited
+ * via parent delegation and so live in the parent's loader, not the rule loader.
+ *
+ * <p>This contract sidesteps the issue entirely: a class loader that implements it
+ * publishes a {@code defineClass} method as part of its API, and the runtime-rule
+ * generator path calls it directly with {@code CtClass.toBytecode()} bytes — no
+ * reflection, no deprecated overload, no {@code --add-opens} requirement on the OAP
+ * JVM. Production loaders (the static path) keep working through their existing
+ * {@code toClass(Class<?>)} neighbor-based path.
+ *
+ * <p>The interface is intentionally minimal — a single bytecode-defining method that
+ * mirrors what {@link ClassLoader#defineClass(String, byte[], int, int)} does. Lifecycle
+ * (parent delegation, URL search) stays the implementor's concern.
+ */
+public interface BytecodeClassDefiner {
+
+    /**
+     * Define {@code bytecode} as a {@link Class} in this loader's namespace.
+     *
+     * @param className   fully-qualified binary name, must match the class's
+     *                    {@code this_class} attribute in the bytecode.
+     * @param bytecode    a complete classfile (e.g., from {@code CtClass.toBytecode()}).
+     * @return the resolved {@link Class} object loaded by this defining loader.
+     */
+    Class<?> defineClass(String className, byte[] bytecode);
+}
diff --git a/oap-server/server-receiver-plugin/skywalking-runtime-rule-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/classloader/RuleClassLoader.java b/oap-server/server-receiver-plugin/skywalking-runtime-rule-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/classloader/RuleClassLoader.java
index 36b17873eb..3fda057cc5 100644
--- a/oap-server/server-receiver-plugin/skywalking-runtime-rule-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/classloader/RuleClassLoader.java
+++ b/oap-server/server-receiver-plugin/skywalking-runtime-rule-receiver-plugin/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/classloader/RuleClassLoader.java
@@ -21,6 +21,7 @@ package org.apache.skywalking.oap.server.receiver.runtimerule.classloader;
 import java.net.URL;
 import java.net.URLClassLoader;
 import lombok.Getter;
+import org.apache.skywalking.oap.server.core.classloader.BytecodeClassDefiner;
 
 /**
  * Per-file classloader that isolates all classes generated for one runtime rule file.
@@ -43,7 +44,7 @@ import lombok.Getter;
  * (parented to {@code ClassPool.getDefault()} with {@code LoaderClassPath(this)} appended)
  * to inject generated bytecode back into this loader via {@code defineClass}.
  */
-public final class RuleClassLoader extends URLClassLoader {
+public final class RuleClassLoader extends URLClassLoader implements BytecodeClassDefiner {
     /** Source rule identity — informational, used in log lines and graveyard metrics. */
     @Getter
     private final String catalog;
@@ -62,12 +63,18 @@ public final class RuleClassLoader extends URLClassLoader {
     }
 
     /**
-     * Package-private escape hatch used by the generator-injection layer so Javassist-produced
-     * bytecode can be defined directly in this loader without going through {@code toClass}'s
-     * classpath resolution. Javassist's {@code toClass(Class<?>)} (Java 9+) path is preferred
-     * at compile time; this method is here for reflective fallbacks and tests.
+     * Bytecode-injection entry point used by {@link
+     * org.apache.skywalking.oap.server.core.analysis.meter.MeterSystem MeterSystem}'s
+     * runtime-rule path and by Javassist's MAL / LAL generators after they call {@code
+     * CtClass.toBytecode()}. Goes straight through {@code URLClassLoader.defineClass} —
+     * no Javassist {@code toClass(loader, ProtectionDomain)} reflection, no JDK 17+
+     * {@code --add-opens java.base/java.lang} requirement on the OAP container.
+     *
+     * <p>Implements {@link BytecodeClassDefiner} so callers in {@code server-core} can
+     * type-check against the contract without taking a compile-time dep on this plugin.
      */
-    Class<?> defineRuleClass(final String className, final byte[] bytecode) {
+    @Override
+    public Class<?> defineClass(final String className, final byte[] bytecode) {
         return defineClass(className, bytecode, 0, bytecode.length);
     }