Everything learned building @live-codes/java-wasm: a Java-in-the-browser engine made from
Vsprocessing/teavm-javac (TeaVM 0.14.1 + a bundled
OpenJDK javac, compiled to WebAssembly), as a replacement for LiveCodes' current Java engine
(Doppio + BrowserFS, an interpreted JVM stuck at Java 8).
This file is the single technical record: what works, what does not and why, how to build it, and how to upgrade it later. Keep it current — every fix and every measurement below is a claim someone will otherwise have to re-derive.
Related documents, each with a narrower job:
| Document | Covers |
|---|---|
patches/README.md |
The three fork patches, their bugs, and how to reproduce the classlib archives |
poc/UPSTREAM.md |
Upstream TeaVM bugs, with minimal reproductions |
corpus/README.md |
The engine-comparison corpus and its current numbers |
poc/RESULTS.md |
Decision record for the engine choice (keeps the Doppio findings) |
Measured on corpus/ (145 programs, run in Chrome):
| Pass | 145 / 145 |
| Compile | 145 / 145 |
| "Works on Doppio, fails here" (regressions) | 0 |
| "Fails on both engines" | 0 |
Measured on a full run. Every program compiles and runs identically to the current engine — there is no regression left and no "broken on both engines" hiding behind one.
Every class-library addition below (String.lines(), Matcher.results(), the %e/%E and %n format
conversions, the DecimalFormatSymbols clone, the suppressed field, Character.UnicodeBlock,
Duration.toXxxPart(), the division helper, and the three file-system pieces — the temp directory, the
Java 10 Reader.transferTo and idempotent createDirectories) and all five compiler patches
(CompilerResourceSupplier, ClassMetadataRequirements, DivisionCheckTransformer,
WasmGCVirtualTableBuilder + the Compiler listener, BaseWasmGenerationVisitor) are separately
confirmed by running the affected programs and diffing the output against JDK 25 — regex-groups,
var-textblock, text-block-more, stringformat, parse-format, string-format-more, io-writers,
exceptions-suppressed, char-classes, instant-duration, reflection-members, reflection,
exceptions, enum-map-set, exceptions-stacktrace, exception-propagation, exceptions-finally
and file-io all pass.
The headline is the regression count, not the pass count, and it is now zero: every program in the corpus produces the current engine's output, and file I/O — creating, writing, renaming, reading, listing and deleting — works against an in-memory filesystem that lives inside the module (§4c).
On Doppio it is not close in the other direction. Doppio cannot run records,
switch-expression, String.repeat, or text blocks at all (JDK 8 javac), and mis-dispatches
interface super default methods. Steady-state generation here is ~1–5 s per program versus tens of
seconds for Doppio's interpreted JVM.
browser worker (src/worker-java.ts)
│
├── compiler.wasm 5.2 MB OpenJDK javac + TeaVM compiler + WasmGC backend
├── compile-classlib-teavm.bin 207 KB javac's platform classpath ("the SDK")
└── runtime-classlib-teavm.bin 2.2 MB TeaVM's class library (T-prefixed classes)
Three stages, all inside one wasm module:
- compile — the bundled javac compiles user sources against
compile-classlib-teavm.bin. - generate — TeaVM AOT-compiles the resulting class files plus the runtime class library into a WebAssembly module. This is the slow stage (51–392 s summed over the corpus, load-dependent; 1–5 s for a typical single-file program).
- run — the module is instantiated and
mainis called.
The two archives look similar and are not interchangeable:
compile-classlib-teavm.binis the converted class library:StdlibConverterrenames TeaVM'sorg.teavm.classlib.java.**.T*classes intojava.**. javac only ever sees this one.runtime-classlib-teavm.binis the unconverted class library, T-names intact, and is what TeaVM generates from. It also carries the fork's replacements for a handful of classes (TFileInputStream,TConsoleInputStream,TScanner, …).
A gap therefore has to be closed on both sides: a declaration in the SDK for javac to resolve, and an implementation in the runtime archive. Half a fix is worse than none — it converts a clean compile-time error into a runtime failure.
java.time is a special case worth knowing about. It is not TeaVM T-classes: it is the bundled
ThreeTen backport, org.threeten.bp.**, and StdlibConverter maps that package to java/time/
for the SDK (StdlibConverter line ~417). So java.time is real at runtime — LocalDate,
DateTimeFormatter, Duration, Instant all work — but it is ThreeTen's API level, which predates
the Java 9 additions (Duration.toXxxPart(), …). Fixing a gap there means vendoring the threeten
class, and the archive and SDK both pick the patched copy up (§5).
{ cmd: 'init', baseUrl? } → { type: 'initialized', initMs } | { type: 'init-error', error }
{ cmd: 'run', source, mainClass?, input? } → { type: 'result', ok, compileMs, generateMs, runMs,
stdout, stderr, exitCode, diagnostics }
baseUrl points at the directory holding compiler.wasm and the two archives; it defaults to the
worker's own directory. Public API (src/index.ts): createRuntime → run(source, {input, mainClass}), with on('progress') / onProgress reporting init → compile → generate → run.
The global is JavaWasm. This shape matches what LiveCodes' language module expects, so wiring is a
matter of vendors.ts + lang-java-script.ts, keeping livecodes.java.{input,output,error,exitCode, run,loaded} unchanged.
teavmConsole—readStdin/putcharStdout/putcharStderr. This is the fork's stdin support: upstreamSystem.inisTConsoleInputStream, which throwsTEOFException. The fork overrides it to call an importedreadStdin. stdin is supplied up front and read a byte at a time.teavmFile— programs touchingjava.io/java.nioimport this module, and without itWebAssembly.instantiatefails for the whole module — an innocuousnew File(...)used to break programs that merely mentioned it. The worker installs a bridge: an in-memory file layer, reads falling back tofetch, writes recorded, async opens completed throughteavm_file_open_complete. There is no directory model, so path-based NIO fails with a cleanDirectory does not existrather than a broken module.- The stack deobfuscator — not an import, a loader option the worker passes when it instantiates
the program.
Throwable.getStackTrace()is implemented by calling out to the runtime'stakeStackTrace, which maps the JS stack's addresses to Java frames through a deobfuscator; with no deobfuscator it returns TeaVM's stand-in frames (Throwable$FakeClass.fakeMethod). Every generated module carries its debug info embedded (the fork setsWasmDebugInfoLocation.EMBEDDEDinCompiler.java), and one generic 40 KBcompiler.wasm-deobfuscator.wasmreads it, so the worker enables it for the program too — see §5. Without that,getStackTrace()reports fake frames; with it enabled but no deobfuscator module to load, frames come back with null names and crash user code that reads them.
optimizationLevel: 'SIMPLE'.ADVANCEDproduces a module whose completion callback andteavm_stopped()flag never fire: the program hangs. Documented in the code and inpoc/RESULTS.md.- Two exit conditions. Programs compile to an async entry point (
main(args, onFinish)backed byorg.teavm.runtime.TeaVMJavacMain/EventQueue), so the worker waits on the callback andteavm_stopped(). ARUN_TIMEOUT_MScap prevents a runaway program from wedging the worker. - Error plumbing. A worker
errormust be propagated intoerror/stderr; otherwise thrown failures surface as blank output and every unsupported-API failure looks like a silent hang.
- Language level: whatever the bundled javac accepts — records, text blocks, switch expressions, sealed types, pattern matching, lambdas, streams, generics, varargs, annotations.
- stdin (the fork's addition — upstream cannot read
System.inat all). - Progress reporting per stage, and accurate
compileMs/generateMs/runMs. - Diagnostics with file/line from javac, surfaced through
onDiagnostic. - Generational detail:
String.lines()and the rest of the classlib surface we have patched. - Speed: seconds per program, against tens of seconds to minutes for Doppio.
Grouped by where the fault lives, because that determines whether it is fixable here.
All six are fixed in patches/StdlibConverter.java; see patches/README.md. Symptoms were
cannot find symbol, cyclic inheritance, ambiguous method, does not override abstract method,
and — for the last one — a raw TypeError with no diagnostic at all.
| Bug | Symptom |
|---|---|
@org.teavm.interop.Rename not applied |
getClass(), getMessage(), clone(), notify() do not resolve — the SDK contains getClass0 |
InnerClasses not renamed |
Map.Entry does not resolve though Map$Entry.class exists |
| Members emitted twice | sb.append(anyObject) is ambiguous |
JDK default methods emitted abstract |
implements CharSequence / Iterator demand extra methods |
Annotation emitted extending itself |
every @interface fails with cyclic inheritance |
| Annotations emitted with no elements | any use of @SuppressWarnings crashed the compiler |
All of these are now fixed or worked around — three in the class library, four by compiler patches, none left open.
try { return x; } finally { return y; }emitted invalid wasm:expected 1 elements on the stack for fallthru, found 2. Fixed (§5): the try/finally is folded away (correctly — the finally'sreturnalways wins) but the AST kept two returns, and the statement emitters walk a list verbatim, so the second return's value landed on the wasm stack:The wasm backend now stops at a return or throw when emitting a statement sequence — and only a direct sibling one, because dropping statements after a block that returns broke(func $Main::overrideReturn (result i32) (call_ref $16 (global $Main@initializer)) (i32.const 2) (i32.const 2))
CharsetEncoder.encode(... found 0): the generator's block typing expects the walk to reach the end of every list. (AtADVANCEDthis shape was always valid; the package needsSIMPLEfor its fiber runtime, which is how the fault was first narrowed.)ArithmeticExceptionon integer division by zero is not raised — the wasmi32.div_straps instead, socatch (ArithmeticException)never runs.MIN_VALUE / -1traps too, where Java returnsMIN_VALUE. Worked around here:DivisionCheckTransformerrewrites int/longDIVIDE/MODULOinto calls toorg.teavm.classlib.impl.math.Division, which checks the divisor and special-cases the overflow (registered forWasmGCTargetonly — the JS backend already throws). Verified with a 52-case differential probe against the JDK. Seepatches/README.md.Object.clone()returns an object with no vtable, so the first virtual call on the copy traps withdereferencing a null pointer.clone()compiles to TeaVM'scloneLowLevel, which allocates through the low-levelAllocator/StructureAPI rather than the WasmGCstruct.newpath; the copy gets the fields (a field read or a devirtualised call on it still works, andgetClass()returns the right class) but not the vtable slot a virtual call dispatches through. Measured, not read off the code: an instrumentedTDecimalFormatprinted asymbolsthat was non-null yet whosegetClass().getName()wasnull, and the trap landed inDecimalFormat.forDigitonsymbols.getZeroDigit().super.clone()is the broken shape — a plain virtualclone()call on an object whose class does not override it works. Array clone traps outright (int[] b = a.clone()). This is one bug behind four regressions (§4c below), and it is worth suspecting for any other "elements are null" report (Throwable.getStackTrace()also clones).- A virtual call on an object whose class's vtable was never filled traps with
dereferencing a null pointer— this is one bug behindenum-map-setand bothStackTraceElementprograms. Disassembled rather than guessed:wasm-dis(the standalone Binaryen tool works on TeaVM's output, where the npmbinaryenbinding does not) showsAbstractStringBuilder::insert(int, Object)callingobj.toString()asand the object's vt — a(call_ref $14 (local.get $obj) (struct.get $VT<java.lang.Object> $toString (ref.cast (ref $VT<java.lang.Object>) (struct.get $java.lang.Object $vt (local.get $obj)))))
$Main$Level@vtglobal, created asstruct.new_default— never has that slot written. TeaVM fills a vtable in the program initializer (fillVirtualTableMethods): it calls a runtimeteavm@objectVirtualTable(vt, class)once, thenstruct.sets the slots the class itself provides.WasmGCVirtualTableBuildermarks a table "used" only when a call site's declaring class is that class (groupMethodsFromCallSitesgroups bymethodRef.getClassName()), so a class that only inherits the method called on it — an enum without its owntoString, a class whosetoStringcomes fromObject— can be left unused, andfillVirtualTableMethodsreturns early (usedVt == null) without writing anything. The call site still reads the slot, becauseWasmGCVirtualCallGeneratortakes the structure from the receiver's static type. It is shape-dependent, which is why it moves when unrelated code is added or removed: a nearby statically-typed use of the class can mark the table used and make the same call work. Both halves are fixed (§5):getClass().getName()returningnullby theClassMetadataRequirementspatch, and the null slot by reaching the target of every virtual call in every reached method (plusObject.toString/Enum.toString, which no call site names) and by dropping theisVirtualfilter inWasmGCVirtualTableBuilder—enum-map-set,exceptions-stacktraceandexception-propagationall match JDK 25 and the corpus is at 143/145.
Touching the backend itself means rebuilding compiler.wasm — which now works, reproducibly, in
~5–9 minutes (§6). clone() is worked around per class in the class library, and division, class names
and the vtable slots are fixed by compiler patches (§5); what remains is exceptions-finally, the
invalid-wasm fault in §4b.
File I/O works, in memory. TeaVM 0.14.1 ships a complete java.nio.file implementation backed by
an in-memory filesystem (org.teavm.runtime.fs.memory.*) that the WasmGC backend uses by default — it
just started empty, so the first thing any file program does failed. Three class-library changes made it
usable (§5): pre-create /tmp in the virtual filesystem (so createTempFile works), add the Java 10
Reader.transferTo (so Files.readString/writeString/copy even compile), and make
Files.createDirectories idempotent for a one-component path. The result is that create, write,
append, read, rename, copy, list, delete and the java.io streams all match the JDK, and the
filesystem lives inside the module, and it can be persisted (opt-in, off by default): the module hands a snapshot to the host
at the end of a run and asks for it back on first use, so a program's files survive to the next run
(IndexedDB in the browser — localStorage does not exist in a Web Worker — and TEAVM_FS_FILE for the runner). The path semantics also have a switch
— POSIX by default, Windows for java.io.File on request — though the nio layer stays POSIX, so that
switch is partial. Details and the exact probes are in patches/README.md.
| Missing / broken | Programs |
|---|---|
Matcher.results() |
regex-groups now passes. TMatcher vendored (§5); the body continues from the matcher's current position like the JDK does |
DecimalFormatSymbols.clone() |
stringformat, parse-format, string-format-more. The null was never in DecimalFormat: it clones its symbols, and Object.clone() returns a vtable-less object under WasmGC (§4b). TDecimalFormatSymbols vendored with an explicit field copy |
%e/%E conversions |
the second half of stringformat; TFormatter produced Unknown format conversion: e |
%n conversion |
stringformat, io-writers; Unknown format conversion: n |
Character.UnicodeBlock |
char-classes; neither UnicodeBlock nor Character.Subset exists in TeaVM 0.14.1. TCharacter vendored and the nested types appended; the block table is generated from the JDK (§5) |
java.time missing its Java 9+ additions |
instant-duration. java.time is not absent: it is implemented by the bundled ThreeTen backport (org.threeten.bp.*, in the runtime archive) and StdlibConverter maps org/threeten/bp/ → java/time/ for the SDK. ThreeTen predates Java 9, so Duration.toXxxPart() was missing from the API entirely; org/threeten/bp/Duration vendored with the six accessors |
Throwable.suppressed null |
exceptions-suppressed. The field initializer never runs (TeaVM constructs through the @Rename("<init>") init(…) methods, not the real constructor), so addSuppressed/getSuppressed dereferenced null. TThrowable vendored with lazy accessors |
getStackTrace() fake/incomplete frames |
exceptions-stacktrace, exception-propagation get real frames now (class and method names), because the worker enables the stack deobfuscator and ships compiler.wasm-deobfuscator.wasm. Both programs still fail on the codegen fault in §4b, which is now the only thing left |
EnumSet/EnumMap null pointer in AbstractStringBuilder.insert |
enum-map-set |
reflection-members. Class/field reflection already worked, but getDeclaredMethods() returned empty, so getMethod/getDeclaredMethod always threw. ReflectionDependencyListener preserves only the members a ReflectionSupplier lists, and the fork's supplier listed all fields for application classes but no methods; the compiler patch mirrors the field rule (§5, patches/README.md) |
|
Class.forName for a class whose class object was never touched |
reflection's last line. TClass.forName builds its name map from generated ClassInfo, which only contains classes already materialized — String.class.getName() earlier in the same program makes it resolve. A wasmGC metadata limitation, not the supplier |
String.lines() was in this list and is fixed — see §5 and patches/README.md for the recipe.
Everything marked fixed here is either a one-shot vendored class or (for real stack traces) a loader
option in the worker; none of them needed the compiler rebuilt.
- File I/O has no directory model: NIO paths fail cleanly. A browser playground has nowhere to put them; a deliberate drop, not a bug.
- Kotlin is not supported. Doppio-modern was the only working route and was unusably slow (7–20 min cold, 163 MB of jars fetched cross-origin). Dropped.
- ADVANCED optimization (see §2).
Eight files, all drop-in replacements in the fork (three of them of TeaVM core, not the fork — items 4,
6 and 7; item 6 also needs two lines in the fork's own Compiler.java), plus a set of vendored
class-library classes.
patches/README.md has the details and reproduction steps; the short version:
-
StdlibConverter.java— applies@Rename, renamesInnerClasses/EnclosingMethod/nest attributes, emits each member once, relaxesACC_ABSTRACTwhere the platform's method is not abstract, drops a superinterface that renames to the class itself, and fills an annotation in from the platform's own class file (elements and theirAnnotationDefaultvalues). -
ArchiveBuilder.java— normalises archive entry names to/. It named entries fromPath.toString(), so on Windows they came out asorg\teavm\classlib\...and TeaVM could not find a single class: every locally built archive was unusable, failing insidegenerateWebAssemblywithdereferencing a null pointer,helloincluded. On Linux the separator is already/, so the change is a no-op — which is why the fork's CI artifacts were never affected and only local builds broke. This was the blocker for all classlib work. -
CompilerResourceSupplier.java— theReflectionSupplierthe fork registers for the compiler. TeaVM'sReflectionDependencyListenerpreserves only the members a supplier lists; this one listed every field of an application class but no methods, sogetDeclaredMethods()was empty andgetMethod/getDeclaredMethodalways threw. The patch mirrors the field rule for methods. This is a compiler change —:compiler:optimizeWasmGC, not the archives. -
ClassMetadataRequirements.java— a TeaVM core class, shadowed throughcompiler/src/patches/java. Whether a class carries its name in the generatedClassInfowas decided per class from the statically knownClassvalues reachingClass.getName(), so any class obtained viagetClass(),forName,getSuperclassor aClassin a collection had no name:getName()returnednullandClass.forNamecould not resolve it. The patch makesgetNameandforName(either overload) mean every class gets its name. Also a compiler change. -
DivisionCheckTransformer.java(a new file, not a replacement) — aClassHolderTransformerthat rewrites int/longDIVIDE/MODULOinto calls toorg.teavm.classlib.impl.math.Division, so a zero divisor throwsArithmeticExceptioninstead of trapping in wasm. Registered inCompiler.createTeaVMforWasmGCTargetonly. Also a compiler change. -
WasmGCVirtualTableBuilder.java— a second TeaVM core class, shadowed the same way (with its one hppc map swapped for aHashMap, since the compiler module has no hppc on its classpath). TheisVirtualfilter is dropped when recording a class's implementors: that predicate is keyed by the method reference as written at the call site, so an inherited implementation (Object.toStringreached throughMain$Level.toString) was never recorded — see §4b. The fork'sCompiler.javasupplies the other half: aDependencyListenerthat reaches the target of every virtual call in every reached method (a method a call site names must exist out of line, because the call may read a vtable slot), plusObject.toString/Enum.toString, which no call site names because the reference at those sites is the subclass's. -
BaseWasmGenerationVisitor.java— the third TeaVM core shadow. The statement-sequence emitters walk a list verbatim, so a statement the decompiler left after an unconditional exit still got emitted — and a secondreturnputs a second value on the wasm stack. It now stops at a directly following return or throw (visitManyandvisit(SequentialStatement), which is what a method body is). Deliberately not recursive: dropping statements after a block that returns brokeCharsetEncoder.encode, because the generator's block typing expects the walk to reach the end of every list. See §4b. -
Vendored class-library classes, in
compiler/src/sharedClasslibEmu/java/org/teavm/classlib/...and each listed by name inprepareTeaVMClassLib's include list:Class Change java/lang/TStringlines()addedjava/util/regex/TMatcherresults()addedjava/text/TDecimalFormatSymbolsclone()copies its fields explicitly instead ofsuper.clone(), which is broken under WasmGC (§4b)java/util/TFormatter%e/%Econversions added (exponent pattern + an explicit exponent sign), and%naddedjava/lang/TThrowablegetSuppressed/addSuppressedtolerate the nullsuppressedfield TeaVM's construction path leaves behindjava/lang/TCharacterCharacter.SubsetandCharacter.UnicodeBlockappended — neither exists upstream. The 339 block constants and their ranges are generated from the JDK byscripts/gen-unicode-blocks.java, soof()agrees with the JDK exactly (including code points in no block, and the surrogate ranges)org/threeten/bp/Durationthe six Java 9 toXxxPart()accessors added — this is the packagejava.timeis converted from, so they were missing from the Java API entirely (§2)org/teavm/classlib/impl/math/Divisionnew class (not upstream) — div/remfor int/long that throw on a zero divisor and special-caseMIN_VALUE / -1; the target ofDivisionCheckTransformer(item 4)org/teavm/runtime/fs/memory/InMemoryVirtualFileSystemone line in the constructor creates /tmp, becausecreateTempFilestarts injava.io.tmpdirand the virtual filesystem starts empty — without it every temp-file program failed with "Directory does not exist"java/io/TReaderthe Java 10 transferTo(Writer)added. TeaVM's ownTFiles.readString/writeString/copycall it, so without it those three did not compile against the SDKjava/nio/file/TFilescreateDirectoriesswallowsFileAlreadyExistsExceptionfor a one-component path, as the JDK does —Files.createDirectories(existingDir)threw instead of being a no-oporg/teavm/runtime/fs/memory/VirtualFileImplits path walk accepts \\as a separator as well as/, so Windows-style paths resolveorg/teavm/runtime/fs/memory/InMemoryVirtualFileSystemalso carries the persistence snapshot/restore and reads the platform flag (see above) TCharacteris the one vendored class with substantial new API rather than a fix, and its data is the JDK's, not hand-typed: runjava scripts/gen-unicode-blocks.javaand paste the output between thegeneratedmarkers (the script's header explains it). Two semantics were measured against the JDK rather than assumed —Subset.equals/hashCodeare identity-based, sohashCodeis notname.hashCode(). -
The stack deobfuscator, which is not a patch but a loader option the worker now passes when it instantiates a program, plus the
compiler.wasm-deobfuscator.wasmartifact that makes it work (scripts/fetch-teavm.mjsvendors it;scripts/build.mjscopies it next tocompiler.wasm). See §2: this is what turnsgetStackTrace()from TeaVM's stand-in frames into real ones.
Adding an API takes four steps, no Binaryen, ~2.5 minutes:
- Take the class's source from the published
teavm-classlib-<version>-sources.jarand add the method. Do not use the GitHub tag of the same version: the0.14.1tag'sTStringimportsorg.teavm.interop.Intrinsified, which exists in no 0.14.1 jar. This cost a build cycle. - Put it in
compiler/src/sharedClasslibEmu/java/....generateClassLibcompiles this source set into the SDK automatically. - Add the class to the include list in
prepareTeaVMClassLib(compiler/build.gradle) so the runtime archive carries it too. - Rebuild both archives and re-vendor them (§6).
TScanner, TField, TFileInputStream and friends are already vendored this way, so the mechanism
is the fork's own — but note the cost: these are copies of core classes, and a TeaVM bump means
re-syncing each one against the new sources jar.
- JDK 25 (
C:\Program Files\Microsoft\jdk-25.0.4.101-hotspothere). - The fork clone, pinned at
Vsprocessing/teavm-javac@d1514ab(TeaVM 0.14.1). Gradle dependencies come from Maven Central; the clone atD:\DevWork\teavm-javac-buildhas them cached. - Binaryen (
wasm-opt) is only needed to rebuildcompiler.wasm— not for the classlib archives. - Node 20+ for the package scripts, and Chrome for the corpus (CDP).
cp patches/StdlibConverter.java teavm-javac/compiler/src/main/java/org/teavm/javac/
cp patches/ArchiveBuilder.java teavm-javac/compiler/src/main/java/org/teavm/javac/
cd teavm-javac
JAVA_HOME=/path/to/jdk-25 ./gradlew :compiler:generateClassLib :compiler:buildTeaVMClassLibThen vendor both:
cp compiler/build/classlib/compile-classlib-teavm.bin assets/
cp compiler/build/classlib/runtime-classlib-teavm.bin assets/
# update SDK_SHA256 / RUNTIME_SHA256 in scripts/fetch-teavm.mjsnpm run vendor:teavm copies all five artifacts from assets/ into vendor/teavm/, checksum-verifying
each — nothing is downloaded, so a clone builds and runs offline. npm run build then writes
dist/. assets/ is the source of truth: the two archives are outputs of the fork build, and
compiler.wasm is too now that it carries patches/CompilerResourceSupplier.java (§5). Provenance for
every file — the pinned fork commit, the patches that apply, and the upstream URL for diffing — is in
scripts/fetch-teavm.mjs's header.
This used to be "never possible in this environment". It works now: patches/Dockerfile pins JDK 25
and Binaryen 133, and the whole build runs in a container. Measured on one machine with a cold Gradle
cache: 8m53s for :compiler:optimizeWasmGC, most of it generateWasmGC plus wasm-opt -O4.
cd /path/to/teavm-javac
docker build -f patches/Dockerfile -t teavm-javac-build .
docker run --rm -v "$PWD":/work -v teavm-gradle:/root/.gradle teavm-javac-build \
bash ./gradlew :compiler:optimizeWasmGC --console=plain --no-daemonThe three artifacts this package vendors come from:
| File | Where |
|---|---|
compiler.wasm |
compiler/build/generated/teavm/wasm-gc-optimized/compiler.wasm |
compiler.wasm-deobfuscator.wasm |
the same directory |
compiler.wasm-runtime.js |
compiler/build/generated/teavm/wasm-gc/compiler.wasm-runtime.js (patched by patchWasmGCRuntime) |
:compiler:createDist is not required — it additionally runs generateJavaScript and the npm
wrapper (which needs Node). The three files above are the whole vendored surface.
Two things were measured rather than assumed:
- The runtime glue is bit-for-bit reproducible. The rebuilt
compiler.wasm-runtime.jshashes to exactly the pinned4709a64b…, although the build environment differs. - The rebuilt compiler is functionally equivalent. Its
compiler.wasmdiffers byte-wise (4700c30b…, 5.46 MB, versus the pinnedea83c69d…— a differentwasm-optversion), but a full corpus run with it gives the same 137/145 and the same 8 failures. The pinned artifact is kept: do not swap it without re-running the corpus.
Build inside the container, not on a Windows bind mount. Gradle's input hashing over Docker
Desktop's 9p/virtiofs mount is pathologically slow — du over the clone takes minutes — so copy the
source in (~38 MB) and build there; the extracted JDK (696 MB) is fetched by the build itself.
npm test # artefact/compile smoke, seconds
TEAVM_JAVAC_DIST=… node scripts/sdk-check.mjs # 7 SDK-surface snippets, ~1 s, no browser
TEAVM_JAVAC_DIST=… node scripts/compile-program.mjs --all # all 145 programs, compile only, ~1 min
TEAVM_JAVAC_DIST=… node scripts/generate-check.mjs <id>… # compile + generate + validate the module
TEAVM_JAVAC_DIST=… node scripts/run-program.mjs <id> --jdk # compile + generate + RUN, and diff against JDK 25
ONLY=<id>,<id> node corpus/run.mjs teavm # run in a browser, ~1–2 min
node corpus/run.mjs teavm # full corpus, ~2–9 min depending on loadcompile-program.mjs and generate-check.mjs are the tools that make this tractable: the first
separates an SDK-surface problem from a TeaVM one, the second catches the code generator emitting
invalid wasm — both without a browser, which is how the try/finally bug was minimised and how a
regression shows up in seconds rather than after a corpus run. run-program.mjs goes one stage
further and actually runs the program, printing its stdout and (with --jdk) the local JDK's stdout
for the same source: it turns "does it run" into "does it match", and a wasm trap into a named Java
stack, which is what located the clone() bug in one probe after several rounds of reading code had
failed. A TeaVM trap with --trace reports the wasm functions by name — DecimalFormat.forDigit — so
add a print to the class you suspect only when the stack is not enough.
To check a program's output rather than just that it ran, compare against the JDK running the same
source. The corpus harness only reports success, so a wrong-but-plausible implementation passes it
(lines() was verified this way).
A corpus run is the gate. Nothing here is "fixed" until a full run says the pass count went up and the regression count did not, and the numbers in §1 are updated.
- Bump
COMMIT/TEAVM_VERSIONinscripts/fetch-teavm.mjsand all five checksums. - Re-apply the three patches by hand — check each still applies and still matters:
StdlibConverter: re-check@Renamehandling,InnerClasses, duplicate members, abstractdefaults, self-extendingAnnotation, and annotation elements. All six were found by measurement; a new TeaVM may fix some upstream.ArchiveBuilder: check whether the separator is normalised upstream. If it is, delete the patch.TString(and any later vendored class): diff against the new sources jar and re-apply the added method, rather than copying the old file forward.TCharacteris the awkward one: take the new upstream file, then re-append the nestedSubset/UnicodeBlocktypes, re-runningscripts/gen-unicode-blocks.javaso the block table matches the new JDK build (the JDK's table is the authority, not TeaVM's).org/threeten/bp/Durationis the simple case: diff it and re-add the sixtoXxxPart()accessors — or delete the vendored copy entirely if the new ThreeTen carries them, which is the preferred ending for any of these.
- Rebuild both archives, re-vendor, update checksums.
- Run
sdk-check.mjs(it encodes every SDK bug we have fixed, so it is the regression net), thencompile-program.mjs --all, then the full corpus. - If the compiler itself must be rebuilt (
compiler.wasm, i.e. any change to codegen or to TeVM internals): use the container recipe in §6 — it is proven, ~9 minutes, and it pins JDK 25 and Binaryen. Re-verify the two workarounds in §2 (SIMPLEoptimization, stdin wiring) on the new build, then run the full corpus before swapping the artifact (the rebuilt module differs byte-wise from the pinned one, so "it compiles" is not enough).
- Version-matched sources: published
-sources.jar, not the GitHub tag. @Renamesemantics: TeaVM declares a member under an internal name and renames it when building the runtime. The SDK must show the Java-visible name; do not copy the target blindly (<init>/<clinit>must keep their names, and a rename that collides must be skipped).- Annotation
AnnotationDefault: an annotation element re-declared from reflection loses its default value, and javac then rejects@Deprecated(since = "9"). - Both archives: an SDK-only change compiles and then fails at runtime.
- Absolute paths /
setincmd:%VAR%expands beforesetruns on the same line. Cost several wasted calls here; use literal paths in one-liners. NODE_ENV=productionsilently skips devDependencies duringnpm install— CI will hit it.
| Symptom | Where the fault is | First tool |
|---|---|---|
cannot find symbol / cyclic inheritance / ambiguous |
SDK surface (converter) | compile-program.mjs |
Compiler throws a raw JS TypeError, no diagnostic |
javac-in-wasm or SDK, crashing rather than reporting | compile-program.mjs |
WebAssembly.compile(): … failed: expected N elements on the stack |
code generation | generate-check.mjs |
RuntimeError: dereferencing a null pointer during generation, every program |
the runtime archive is malformed — check entry separators (§5.2) | generate-check.mjs; diff the archive against the CI artifact |
RuntimeError/ArrayIndexOutOfBounds at run time |
runtime class library | corpus/run.mjs teavm, or run-program.mjs <id> --trace without a browser |
dereferencing a null pointer at run time on an object that is not null — a freshly cloned one — with the trap in a getter |
Object.clone() under WasmGC: the copy has no vtable (§4b) |
run-program.mjs <id> --trace; then copy the fields explicitly in the class that clones |
(could not fetch message) |
the runtime raised and compiler.wasm-runtime.js could not read the message off the exception — the program threw where it should not have |
corpus/run.mjs |
| Blank output, no error | error plumbing (§2) | worker-java.ts |
| Hang | ADVANCED optimization, or a runtime loop with no teavm_stopped() |
try SIMPLE |
The 6 regressions block a straight swap (§1). Most of §4c is reachable with the §5 recipe; the code-generation faults in §4b are not, without a wasm-backend change.
stringformat, parse-format and string-format-more are fixed. The written-up lead (a CLDR native
getNumberFormatMap not being linked, §9 of the previous revision) was wrong, as were the two
attributions before it — DecimalFormat.forDigit and the grouped-integer conversion. What settled it
was measurement at three levels:
run-program.mjs --trace— the trap arrived with named Java frames (DecimalFormat.forDigitcallingsymbols.getZeroDigit()). A TeaVM trap is not limited to a wasm function index; the name section is intact underSIMPLE, so this is always available and is the cheapest first move. Bisecting the program (partial stdout) narrowed it toDecimalFormat.format(long); the stack named the exact call.- An instrumented classlib (
dbgprints in a vendoredTDecimalFormat) showedsymbolswas non-null but itsgetClass().getName()wasnull— so the field was fine and the object was broken. That killed every "the field is never assigned" reading in one build. - The same shape reproduced in four lines of user code: a clone whose fields read back correctly and whose first virtual call traps.
Root cause and fix: §4b and §5. The lesson the cluster keeps teaching: cut it to a probe and run it — three attributions made by reading code were all wrong, and the probe that worked was the cheapest.
exceptions-suppressed is fixed (a null field, §4c). The other two needed two things, and got the
first: getStackTrace() now returns real frames, because the worker enables the stack
deobfuscator (§2/§5). That is not a cosmetic change — before it, every frame was
Throwable$FakeClass.fakeMethod, and a half-built deobfuscator gave frames whose names were null.
exceptions is fixed — the divide-by-zero trap is now a Java-level check routed by
DivisionCheckTransformer (§4b/§5). exceptions-finally remains: the finally { return } wasm, which
is backend work.
The stream-lambda trap in the last two was the same class of fault as enum-map-set (§4b), one
instance further on: java.lang.String.equals was dispatched through a vtable slot whose
implementation had been dropped, because nothing called it out of line and the call sites that did
named the receiver's type. Both are fixed by reaching every virtual call target, which is the
general form of the fix rather than another forced method.
| Item | What it takes |
|---|---|
| (nothing) | The corpus is at 145/145 with zero regressions. Every fault this document tracks is fixed (§5): the five compiler ones (division, class names, the toString slot, the general vtable slot, the dead sibling return) and the file-system gaps that made file-io fail |
If the engine is to be pushed further, the corpus is the first place to look: it samples 145 programs, which is a fraction of the API. The fault families worth watching for are the ones this session found repeatedly — a virtual call through a vtable slot, and anything that depends on a method the dependency analysis decided was unreachable.
run-program.mjs --trace names the trapping frame, --emit writes the module, and
wasm-dis (binaryen-version_133/bin/wasm-dis.exe) turns it into readable wat with Java names. For a
shape that only misbehaves in one pipeline, TEAVM_OPT_LEVEL compares levels.
The float cluster (stringformat, parse-format, string-format-more — one Object.clone() bug,
§4b/§5), io-writers (%n), exceptions-suppressed (Throwable.suppressed), char-classes
(Character.UnicodeBlock, generated from the JDK), instant-duration (Duration.toXxxPart(), which
also took the compile count to 145/145), reflection-members (the CompilerResourceSupplier
patch), exceptions (DivisionCheckTransformer + the Division helper), reflection (the
ClassMetadataRequirements patch: its last line, Class.forName, now resolves), enum-map-set,
exceptions-stacktrace and exception-propagation (the vtable fix: WasmGCVirtualTableBuilder plus
the Compiler listener that reaches every virtual call target and Object.toString/Enum.toString),
and exceptions-finally (the wasm backend now stops at a direct return in a statement sequence — §4b).
Each was verified by diffing the program's output against JDK 25, not just by the corpus going green.
- LiveCodes wiring: not started.
vendors.ts+lang-java-script.ts, keeping thelivecodes.java.*contract. Until then this is a package, not an engine a user can select. - Publishing:
@live-codes/java-wasm@0.1.0is ready to publish — licence and third-party notices, types,sideEffectsand aprepublishOnlyguard are in place, and the tarball is 4.26 MB. The SDK-patch strategy is settled (§5: we ship build outputs plus vendored classlib classes, not a rewritten third-party binary). Remaining: sign-off on the GPLv2+CE "corresponding source" wording. - Scala lives in a separate package (
@live-codes/scala-wasm, JSPI-only, no stdin); a Doppio fallback belongs in LiveCodes' language config, not inside that package.