Skip to content

Commit 4fa690d

Browse files
feat(v26.3-Alpha.2): bytecode pre-optimization engine with disk cache
aprism.agent.optimize package in aprismate-agent: - AsmClassOptimizer: two ASM core-API passes (void-call elision via stack-safe argument pops; method-entry probe injection to ProbeSink) - PreOptimizationTransformer: ClassFileTransformer, fail-open (any Throwable -> null = original bytes), JDK-internal packages excluded - BytecodeCache: SHA-256 keyed, integrity-framed [hash][payload] entries, atomic writes, corrupt->miss degradation - OptimizerConfig: simple line-format rules file parsed at premain; opt-in via -Daprismate.optimizer.rules=<file>; absent = no-op - embedJar now bundles ASM from runtimeClasspath (~214 KB total) Design: engine in agent module (not api) because ASM must NOT enter the jdk.aprismate fork module; agent jar is plain classpath artifact. Bugs found by tests and fixed: 1. slash/dot mismatch between ASM internal names and rule keys (all comparison points normalize) 2. elision gate was class-scoped but call targets live in any class (replaced with cheap pre-scan visitor sweep) 3. cache had no integrity check (corrupt files returned as-is) 4. Properties.load backslash escaping ate Windows test paths 33 new tests including real define-and-execute of transformed bytes via ByteArrayClassLoader. Full suite: 704 tests, 0 failures.
1 parent 2b12bc1 commit 4fa690d

12 files changed

Lines changed: 873 additions & 1 deletion

File tree

aprismate-agent/build.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ tasks.register('embedJar', Jar) {
4646
}) {
4747
exclude 'module-info.class'
4848
}
49+
// bundle ASM: the optimizer needs it at runtime on any host JDK
50+
configurations.runtimeClasspath
51+
.filter { it.name.startsWith('asm') }
52+
.each { dep ->
53+
from({ zipTree(dep) }) {
54+
exclude 'module-info.class'
55+
exclude 'META-INF/*'
56+
}
57+
}
4958
}
5059

5160
shadowJar {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package aprism.agent.optimize;
2+
3+
import org.objectweb.asm.ClassReader;
4+
import org.objectweb.asm.ClassVisitor;
5+
import org.objectweb.asm.ClassWriter;
6+
import org.objectweb.asm.MethodVisitor;
7+
import org.objectweb.asm.Opcodes;
8+
import org.objectweb.asm.Type;
9+
10+
import java.util.Set;
11+
12+
/**
13+
* ASM core-API optimizer. Two passes over the original bytes:
14+
*
15+
* <ol>
16+
* <li><b>elision</b> — void method invocations whose owner+name match
17+
* the rule set are replaced by argument pops (stack-safe for void
18+
* descriptors; non-void targets are never matched).</li>
19+
* <li><b>probe-enter</b> — a static
20+
* {@code ProbeSink.methodEnter(Ljava/lang/String;)V} call is
21+
* injected at the entry of methods matching the rule set, with
22+
* the tag {@code "internal.Owner.methodName"}.</li>
23+
* </ol>
24+
*
25+
* COMPUTE_MAXS is sufficient (we only pop what we pushed / remove
26+
* balanced calls); frames are recomputed via COMPUTE_FRAMES for safety
27+
* on class-file version 50+.
28+
*/
29+
public final class AsmClassOptimizer {
30+
31+
private final OptimizerConfig config;
32+
33+
public AsmClassOptimizer(OptimizerConfig config) {
34+
this.config = config;
35+
}
36+
37+
/**
38+
* Transforms bytes; returns null when nothing matched (caller passes
39+
* original through unchanged).
40+
*/
41+
public byte[] transform(String className, byte[] input) {
42+
if (config.isEmpty()) {
43+
return null;
44+
}
45+
ClassReader cr = new ClassReader(input);
46+
String internal = cr.getClassName();
47+
48+
// Cheap pre-scan: does THIS class contain anything we would touch?
49+
// (elision targets live anywhere; probes target this class)
50+
boolean[] hit = { false };
51+
String dottedInternal = internal.replace('/', '.');
52+
cr.accept(new ClassVisitor(Opcodes.ASM9) {
53+
@Override
54+
public MethodVisitor visitMethod(int access, String name, String desc,
55+
String signature, String[] exceptions) {
56+
if (!hit[0] && config.probes().stream()
57+
.anyMatch(k -> k.equals(OptimizerConfig.key(dottedInternal, name)))) {
58+
hit[0] = true;
59+
}
60+
return new MethodVisitor(Opcodes.ASM9) {
61+
@Override
62+
public void visitMethodInsn(int opcode, String owner, String mname,
63+
String mdesc, boolean iface) {
64+
if ("V".equals(Type.getReturnType(mdesc).getDescriptor())
65+
&& config.elisions().contains(OptimizerConfig.key(
66+
owner.replace('/', '.'), mname))) {
67+
hit[0] = true;
68+
}
69+
}
70+
};
71+
}
72+
}, 0);
73+
if (!hit[0]) {
74+
return null;
75+
}
76+
77+
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
78+
ClassVisitor chain = cw;
79+
if (matchesAny(internal, config.probes())) {
80+
chain = new ProbeVisitor(chain, internal, config);
81+
}
82+
if (!config.elisions().isEmpty()) {
83+
chain = new ElisionVisitor(chain, config);
84+
}
85+
cr.accept(chain, 0);
86+
return cw.toByteArray();
87+
}
88+
89+
static boolean matchesAny(String internalName, Set<String> ruleKeys) {
90+
// rules use dotted owner names; convert our internal name once
91+
String dotted = internalName.replace('/', '.');
92+
for (String key : ruleKeys) {
93+
int lastDot = key.lastIndexOf('.');
94+
if (lastDot <= 0) {
95+
continue;
96+
}
97+
String owner = key.substring(0, lastDot);
98+
if (dotted.equals(owner) || dotted.startsWith(owner + "$")) {
99+
return true;
100+
}
101+
}
102+
return false;
103+
}
104+
105+
static boolean elides(String ownerInternal, String name, OptimizerConfig cfg) {
106+
return cfg.elisions().contains(OptimizerConfig.key(ownerInternal, name));
107+
}
108+
109+
static String probeTag(String internalOwner, String name) {
110+
return internalOwner.replace('/', '.') + "." + name;
111+
}
112+
113+
static final class ElisionVisitor extends ClassVisitor {
114+
private final OptimizerConfig cfg;
115+
116+
ElisionVisitor(ClassVisitor next, OptimizerConfig cfg) {
117+
super(Opcodes.ASM9, next);
118+
this.cfg = cfg;
119+
}
120+
121+
@Override
122+
public MethodVisitor visitMethod(int access, String name, String desc,
123+
String signature, String[] exceptions) {
124+
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
125+
return new MethodVisitor(Opcodes.ASM9, mv) {
126+
@Override
127+
public void visitMethodInsn(int opcode, String owner, String mname,
128+
String mdesc, boolean isInterface) {
129+
if (!"V".equals(Type.getReturnType(mdesc).getDescriptor())
130+
|| !cfg.elisions().contains(OptimizerConfig.key(
131+
owner.replace('/', '.'), mname))) {
132+
super.visitMethodInsn(opcode, owner, mname, mdesc, isInterface);
133+
return;
134+
}
135+
// emit argument pops (receiver already on stack for non-static)
136+
Type[] args = Type.getArgumentTypes(mdesc);
137+
if (opcode != Opcodes.INVOKESTATIC) {
138+
super.visitInsn(Opcodes.POP); // receiver
139+
}
140+
for (int i = args.length - 1; i >= 0; i--) {
141+
super.visitInsn(args[i].getSize() == 2
142+
? Opcodes.POP2 : Opcodes.POP);
143+
}
144+
}
145+
};
146+
}
147+
}
148+
149+
static final class ProbeVisitor extends ClassVisitor {
150+
private final String dottedOwner;
151+
private final OptimizerConfig cfg;
152+
153+
ProbeVisitor(ClassVisitor next, String ownerInternal, OptimizerConfig cfg) {
154+
super(Opcodes.ASM9, next);
155+
this.dottedOwner = ownerInternal.replace('/', '.');
156+
this.cfg = cfg;
157+
}
158+
159+
@Override
160+
public MethodVisitor visitMethod(int access, String name, String desc,
161+
String signature, String[] exceptions) {
162+
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
163+
boolean probed = cfg.probes().stream()
164+
.anyMatch(k -> k.equals(OptimizerConfig.key(dottedOwner, name)));
165+
if (!probed || (access & Opcodes.ACC_ABSTRACT) != 0
166+
|| "<init>".equals(name) || "<clinit>".equals(name)) {
167+
return mv;
168+
}
169+
return new MethodVisitor(Opcodes.ASM9, mv) {
170+
@Override
171+
public void visitCode() {
172+
super.visitCode();
173+
super.visitLdcInsn(probeTag(dottedOwner, name));
174+
super.visitMethodInsn(Opcodes.INVOKESTATIC,
175+
"aprism/agent/optimize/ProbeSink",
176+
"methodEnter", "(Ljava/lang/String;)V", false);
177+
}
178+
};
179+
}
180+
}
181+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package aprism.agent.optimize;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.nio.file.StandardCopyOption;
7+
import java.security.MessageDigest;
8+
import java.security.NoSuchAlgorithmException;
9+
import java.util.Arrays;
10+
import java.util.HexFormat;
11+
12+
/**
13+
* Disk cache for transformed bytecode: ~/.aprismate/bytecode-cache by
14+
* default, keyed by sha256(className + configFingerprint + inputBytes).
15+
* Atomic writes (temp + move) keep concurrent readers safe; any read
16+
* failure degrades to a miss.
17+
*/
18+
public final class BytecodeCache {
19+
20+
private final Path dir;
21+
22+
public BytecodeCache(Path dir) {
23+
this.dir = dir;
24+
}
25+
26+
public byte[] get(String key) {
27+
Path f = fileFor(key);
28+
byte[] blob;
29+
try {
30+
blob = Files.readAllBytes(f);
31+
} catch (IOException e) {
32+
return null;
33+
}
34+
if (blob.length < HASH_LEN) {
35+
return null;
36+
}
37+
byte[] storedHash = Arrays.copyOfRange(blob, 0, HASH_LEN);
38+
byte[] payload = Arrays.copyOfRange(blob, HASH_LEN, blob.length);
39+
if (!MessageDigest.isEqual(storedHash, sha256Of(payload))) {
40+
return null;
41+
}
42+
return payload;
43+
}
44+
45+
public void put(String key, byte[] bytes) {
46+
try {
47+
Files.createDirectories(dir);
48+
byte[] out = new byte[HASH_LEN + bytes.length];
49+
System.arraycopy(sha256Of(bytes), 0, out, 0, HASH_LEN);
50+
System.arraycopy(bytes, 0, out, HASH_LEN, bytes.length);
51+
Path tmp = Files.createTempFile(dir, "t-", ".cls");
52+
Files.write(tmp, out);
53+
Files.move(tmp, fileFor(key), StandardCopyOption.REPLACE_EXISTING,
54+
StandardCopyOption.ATOMIC_MOVE);
55+
} catch (IOException ignored) {
56+
// cache is best-effort
57+
}
58+
}
59+
60+
private static final int HASH_LEN = 32;
61+
62+
private static byte[] sha256Of(byte[] data) {
63+
var md = digest();
64+
md.update(data);
65+
return md.digest();
66+
}
67+
68+
private Path fileFor(String key) {
69+
return dir.resolve(sha256(key) + ".cls");
70+
}
71+
72+
static String sha256(String s) {
73+
try {
74+
MessageDigest md = MessageDigest.getInstance("SHA-256");
75+
return HexFormat.of().formatHex(md.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
76+
} catch (NoSuchAlgorithmException e) {
77+
throw new IllegalStateException(e);
78+
}
79+
}
80+
81+
static String cacheKey(String className, String fingerprint, byte[] input) {
82+
String base = className + '\0' + fingerprint + '\0' + input.length + '\0';
83+
var md = digest();
84+
md.update(base.getBytes(java.nio.charset.StandardCharsets.UTF_8));
85+
md.update(input);
86+
return HexFormat.of().formatHex(md.digest());
87+
}
88+
89+
private static MessageDigest digest() {
90+
try {
91+
return MessageDigest.getInstance("SHA-256");
92+
} catch (NoSuchAlgorithmException e) {
93+
throw new IllegalStateException(e);
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)